docs: add report draft and dashboard form refinements

This commit is contained in:
Syahdan 2026-04-29 21:27:36 +07:00
parent 4e0cf0140f
commit 441e709193
26 changed files with 1700 additions and 505 deletions

View file

@ -58,7 +58,7 @@ export function DashboardShell({
</p>
<h1 className="text-2xl font-semibold tracking-tight">Operations overview for demos and day-to-day admin tasks</h1>
<p className="max-w-2xl text-sm text-muted-foreground">
Use this area to review monitored assets, inspect detail states, and stage CRUD forms that can connect to tRPC routers as they are added.
Use this area to review monitored assets, inspect detail states, currently used to monitor my homelab setup.
</p>
</div>
<div className="grid gap-3 border-l-0 border-t pt-4 lg:border-l lg:border-t-0 lg:pl-6 lg:pt-0">
@ -91,7 +91,7 @@ export function DashboardShell({
</div>
<nav className="grid gap-1 p-2">
{dashboardLinks.map(({ to, label, description, icon: Icon }) => {
const active = pathname === to || pathname.startsWith(`${to}/`);
const active = to === "/dashboard" ? pathname === to : pathname === to || pathname.startsWith(`${to}/`);
return (
<Link

View file

@ -9,6 +9,13 @@ import {
} from "@minmon/ui/components/card";
import { Input } from "@minmon/ui/components/input";
import { Label } from "@minmon/ui/components/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@minmon/ui/components/select";
import { StatusBadge } from "@minmon/ui/components/status-badge";
import {
Table,
@ -21,8 +28,8 @@ import {
} from "@minmon/ui/components/table";
import { Textarea } from "@minmon/ui/components/textarea";
import { Link } from "@tanstack/react-router";
import { ChevronRight } from "lucide-react";
import type { ReactNode } from "react";
import { Check, ChevronRight, Trash2 } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
type BadgeVariant = "neutral" | "success" | "warning" | "destructive" | "info";
@ -66,7 +73,7 @@ export function SummaryCard({
);
}
export function ResourceListCard<TItem>({
export function ResourceListCard<TItem extends { id: string }>({
title,
description,
columns,
@ -82,7 +89,7 @@ export function ResourceListCard<TItem>({
emptyDescription: string;
}) {
return (
<Card className="gap-0">
<Card className="py-0 pt-4 gap-0">
<CardHeader className="border-b">
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
@ -101,8 +108,8 @@ export function ResourceListCard<TItem>({
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, index) => (
<TableRow key={index}>
{items.map((item) => (
<TableRow key={item.id}>
{columns.map((column) => (
<TableCell key={column.key} className={column.className}>
{column.render(item)}
@ -213,6 +220,108 @@ export function TextAreaField(
);
}
export function SelectField({
label,
description,
id,
name,
value,
onValueChange,
onBlur,
placeholder,
options,
}: {
label: string;
description?: string;
id?: string;
name?: string;
value: string;
onValueChange: (value: string) => void;
onBlur?: React.FocusEventHandler<HTMLButtonElement>;
placeholder?: string;
options: { value: string; label: string }[];
}) {
const fieldId = id ?? name ?? label;
const selectedOption = options.find((option) => option.value === value);
return (
<FieldGroup label={label} htmlFor={fieldId} description={description}>
<Select name={name} value={value} onValueChange={(nextValue) => onValueChange(nextValue ?? "")}>
<SelectTrigger id={fieldId} className="w-full" onBlur={onBlur}>
<SelectValue placeholder={placeholder ?? "Select an option"}>
{selectedOption?.label}
</SelectValue>
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FieldGroup>
);
}
export function ConfirmDeleteIconButton({
itemLabel,
onConfirm,
disabled,
}: {
itemLabel: string;
onConfirm: () => void;
disabled?: boolean;
}) {
const [isConfirming, setIsConfirming] = useState(false);
useEffect(() => {
if (!isConfirming) {
return;
}
const timeoutId = window.setTimeout(() => {
setIsConfirming(false);
}, 4000);
return () => {
window.clearTimeout(timeoutId);
};
}, [isConfirming]);
useEffect(() => {
if (disabled) {
setIsConfirming(false);
}
}, [disabled]);
return (
<div className="flex justify-center">
<Button
type="button"
variant={isConfirming ? "destructive" : "destructive"}
size="icon"
className="size-8"
disabled={disabled}
aria-label={isConfirming ? `Confirm delete ${itemLabel}` : `Delete ${itemLabel}`}
onBlur={() => setIsConfirming(false)}
onClick={() => {
if (isConfirming) {
onConfirm();
setIsConfirming(false);
return;
}
setIsConfirming(true);
}}
>
{isConfirming ? <Check /> : <Trash2 />}
<span className="sr-only">{isConfirming ? `Confirm delete ${itemLabel}` : `Delete ${itemLabel}`}</span>
</Button>
</div>
);
}
export function EmptyState({
title,
description,

View file

@ -2,7 +2,7 @@ import { Button } from "@minmon/ui/components/button";
import { Input } from "@minmon/ui/components/input";
import { Label } from "@minmon/ui/components/label";
import { useForm } from "@tanstack/react-form";
import { Link, useNavigate } from "@tanstack/react-router";
import { useNavigate } from "@tanstack/react-router";
import { toast } from "sonner";
import z from "zod";
@ -53,85 +53,84 @@ export default function SignInForm() {
}
return (
<div className="mx-auto mt-10 grid w-full max-w-md gap-6 border bg-card p-6">
<div className="space-y-2 text-center">
<p className="text-[11px] uppercase tracking-[0.24em] text-muted-foreground">Admin Access</p>
<h1 className="text-3xl font-bold tracking-tight">Sign in to minmon</h1>
<p className="text-sm text-muted-foreground">
Use your administrator account to open the protected monitoring dashboard.
</p>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
className="space-y-4"
>
<div>
<form.Field name="email">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>Email</Label>
<Input
id={field.name}
name={field.name}
type="email"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.map((error) => (
<p key={error?.message} className="text-xs text-red-500">
{error?.message}
</p>
))}
</div>
)}
</form.Field>
<div className="mx-auto flex min-h-[calc(100dvh-8rem)] w-full max-w-5xl items-center justify-center px-4 py-10">
<div className="grid w-full max-w-md gap-6 border bg-card p-6">
<div className="space-y-2 text-center">
<p className="text-[11px] uppercase tracking-[0.24em] text-muted-foreground">Admin Access</p>
<h1 className="text-3xl font-bold tracking-tight">Sign in</h1>
<p className="text-sm text-muted-foreground">
Use your administrator account to open the protected monitoring dashboard.
</p>
</div>
<div>
<form.Field name="password">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>Password</Label>
<Input
id={field.name}
name={field.name}
type="password"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.map((error) => (
<p key={error?.message} className="text-xs text-red-500">
{error?.message}
</p>
))}
</div>
)}
</form.Field>
</div>
<form.Subscribe
selector={(state) => ({ canSubmit: state.canSubmit, isSubmitting: state.isSubmitting })}
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
className="space-y-4"
>
{({ canSubmit, isSubmitting }) => (
<Button type="submit" className="w-full" disabled={!canSubmit || isSubmitting}>
{isSubmitting ? "Submitting..." : "Sign In"}
</Button>
)}
</form.Subscribe>
</form>
<div>
<form.Field name="email">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>Email</Label>
<Input
id={field.name}
name={field.name}
type="email"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.map((error) => (
<p key={error?.message} className="text-xs text-red-500">
{error?.message}
</p>
))}
</div>
)}
</form.Field>
</div>
<div className="grid gap-2 border-t pt-4 text-center text-xs text-muted-foreground">
<p>Student demo flow uses administrator sign-in only.</p>
<Link to="/dashboard" className="text-primary underline-offset-4 hover:underline">
Open dashboard after sign in
</Link>
<div>
<form.Field name="password">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>Password</Label>
<Input
id={field.name}
name={field.name}
type="password"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.map((error) => (
<p key={error?.message} className="text-xs text-red-500">
{error?.message}
</p>
))}
</div>
)}
</form.Field>
</div>
<form.Subscribe
selector={(state) => ({ canSubmit: state.canSubmit, isSubmitting: state.isSubmitting })}
>
{({ canSubmit, isSubmitting }) => (
<Button type="submit" className="w-full" disabled={!canSubmit || isSubmitting}>
{isSubmitting ? "Submitting..." : "Sign In"}
</Button>
)}
</form.Subscribe>
</form>
<div className="grid gap-2 border-t pt-4 text-center text-xs text-muted-foreground">
<p>Student demo flow uses administrator sign-in only.</p>
</div>
</div>
</div>
);

View file

@ -17,14 +17,14 @@ import { Route as DashboardServicesRouteImport } from './routes/dashboard.servic
import { Route as DashboardServersRouteImport } from './routes/dashboard.servers'
import { Route as DashboardNotesRouteImport } from './routes/dashboard.notes'
import { Route as DashboardDomainsRouteImport } from './routes/dashboard.domains'
import { Route as DashboardServicesNewRouteImport } from './routes/dashboard.services_.new'
import { Route as DashboardServicesServiceIdRouteImport } from './routes/dashboard.services_.$serviceId'
import { Route as DashboardServicesNewRouteImport } from './routes/dashboard.services.new'
import { Route as DashboardServersNewRouteImport } from './routes/dashboard.servers_.new'
import { Route as DashboardServersServerIdRouteImport } from './routes/dashboard.servers_.$serverId'
import { Route as DashboardServersNewRouteImport } from './routes/dashboard.servers.new'
import { Route as DashboardNotesNewRouteImport } from './routes/dashboard.notes_.new'
import { Route as DashboardNotesNoteIdRouteImport } from './routes/dashboard.notes_.$noteId'
import { Route as DashboardNotesNewRouteImport } from './routes/dashboard.notes.new'
import { Route as DashboardDomainsNewRouteImport } from './routes/dashboard.domains_.new'
import { Route as DashboardDomainsDomainIdRouteImport } from './routes/dashboard.domains_.$domainId'
import { Route as DashboardDomainsNewRouteImport } from './routes/dashboard.domains.new'
const LoginRoute = LoginRouteImport.update({
id: '/login',
@ -66,16 +66,21 @@ const DashboardDomainsRoute = DashboardDomainsRouteImport.update({
path: '/domains',
getParentRoute: () => DashboardRoute,
} as any)
const DashboardServicesNewRoute = DashboardServicesNewRouteImport.update({
id: '/services_/new',
path: '/services/new',
getParentRoute: () => DashboardRoute,
} as any)
const DashboardServicesServiceIdRoute =
DashboardServicesServiceIdRouteImport.update({
id: '/services_/$serviceId',
path: '/services/$serviceId',
getParentRoute: () => DashboardRoute,
} as any)
const DashboardServicesNewRoute = DashboardServicesNewRouteImport.update({
id: '/new',
path: '/new',
getParentRoute: () => DashboardServicesRoute,
const DashboardServersNewRoute = DashboardServersNewRouteImport.update({
id: '/servers_/new',
path: '/servers/new',
getParentRoute: () => DashboardRoute,
} as any)
const DashboardServersServerIdRoute =
DashboardServersServerIdRouteImport.update({
@ -83,20 +88,20 @@ const DashboardServersServerIdRoute =
path: '/servers/$serverId',
getParentRoute: () => DashboardRoute,
} as any)
const DashboardServersNewRoute = DashboardServersNewRouteImport.update({
id: '/new',
path: '/new',
getParentRoute: () => DashboardServersRoute,
const DashboardNotesNewRoute = DashboardNotesNewRouteImport.update({
id: '/notes_/new',
path: '/notes/new',
getParentRoute: () => DashboardRoute,
} as any)
const DashboardNotesNoteIdRoute = DashboardNotesNoteIdRouteImport.update({
id: '/notes_/$noteId',
path: '/notes/$noteId',
getParentRoute: () => DashboardRoute,
} as any)
const DashboardNotesNewRoute = DashboardNotesNewRouteImport.update({
id: '/new',
path: '/new',
getParentRoute: () => DashboardNotesRoute,
const DashboardDomainsNewRoute = DashboardDomainsNewRouteImport.update({
id: '/domains_/new',
path: '/domains/new',
getParentRoute: () => DashboardRoute,
} as any)
const DashboardDomainsDomainIdRoute =
DashboardDomainsDomainIdRouteImport.update({
@ -104,65 +109,60 @@ const DashboardDomainsDomainIdRoute =
path: '/domains/$domainId',
getParentRoute: () => DashboardRoute,
} as any)
const DashboardDomainsNewRoute = DashboardDomainsNewRouteImport.update({
id: '/new',
path: '/new',
getParentRoute: () => DashboardDomainsRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/dashboard': typeof DashboardRouteWithChildren
'/login': typeof LoginRoute
'/dashboard/domains': typeof DashboardDomainsRouteWithChildren
'/dashboard/notes': typeof DashboardNotesRouteWithChildren
'/dashboard/servers': typeof DashboardServersRouteWithChildren
'/dashboard/services': typeof DashboardServicesRouteWithChildren
'/dashboard/domains': typeof DashboardDomainsRoute
'/dashboard/notes': typeof DashboardNotesRoute
'/dashboard/servers': typeof DashboardServersRoute
'/dashboard/services': typeof DashboardServicesRoute
'/dashboard/': typeof DashboardIndexRoute
'/dashboard/domains/new': typeof DashboardDomainsNewRoute
'/dashboard/domains/$domainId': typeof DashboardDomainsDomainIdRoute
'/dashboard/notes/new': typeof DashboardNotesNewRoute
'/dashboard/domains/new': typeof DashboardDomainsNewRoute
'/dashboard/notes/$noteId': typeof DashboardNotesNoteIdRoute
'/dashboard/servers/new': typeof DashboardServersNewRoute
'/dashboard/notes/new': typeof DashboardNotesNewRoute
'/dashboard/servers/$serverId': typeof DashboardServersServerIdRoute
'/dashboard/services/new': typeof DashboardServicesNewRoute
'/dashboard/servers/new': typeof DashboardServersNewRoute
'/dashboard/services/$serviceId': typeof DashboardServicesServiceIdRoute
'/dashboard/services/new': typeof DashboardServicesNewRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/login': typeof LoginRoute
'/dashboard/domains': typeof DashboardDomainsRouteWithChildren
'/dashboard/notes': typeof DashboardNotesRouteWithChildren
'/dashboard/servers': typeof DashboardServersRouteWithChildren
'/dashboard/services': typeof DashboardServicesRouteWithChildren
'/dashboard/domains': typeof DashboardDomainsRoute
'/dashboard/notes': typeof DashboardNotesRoute
'/dashboard/servers': typeof DashboardServersRoute
'/dashboard/services': typeof DashboardServicesRoute
'/dashboard': typeof DashboardIndexRoute
'/dashboard/domains/new': typeof DashboardDomainsNewRoute
'/dashboard/domains/$domainId': typeof DashboardDomainsDomainIdRoute
'/dashboard/notes/new': typeof DashboardNotesNewRoute
'/dashboard/domains/new': typeof DashboardDomainsNewRoute
'/dashboard/notes/$noteId': typeof DashboardNotesNoteIdRoute
'/dashboard/servers/new': typeof DashboardServersNewRoute
'/dashboard/notes/new': typeof DashboardNotesNewRoute
'/dashboard/servers/$serverId': typeof DashboardServersServerIdRoute
'/dashboard/services/new': typeof DashboardServicesNewRoute
'/dashboard/servers/new': typeof DashboardServersNewRoute
'/dashboard/services/$serviceId': typeof DashboardServicesServiceIdRoute
'/dashboard/services/new': typeof DashboardServicesNewRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/dashboard': typeof DashboardRouteWithChildren
'/login': typeof LoginRoute
'/dashboard/domains': typeof DashboardDomainsRouteWithChildren
'/dashboard/notes': typeof DashboardNotesRouteWithChildren
'/dashboard/servers': typeof DashboardServersRouteWithChildren
'/dashboard/services': typeof DashboardServicesRouteWithChildren
'/dashboard/domains': typeof DashboardDomainsRoute
'/dashboard/notes': typeof DashboardNotesRoute
'/dashboard/servers': typeof DashboardServersRoute
'/dashboard/services': typeof DashboardServicesRoute
'/dashboard/': typeof DashboardIndexRoute
'/dashboard/domains/new': typeof DashboardDomainsNewRoute
'/dashboard/domains_/$domainId': typeof DashboardDomainsDomainIdRoute
'/dashboard/notes/new': typeof DashboardNotesNewRoute
'/dashboard/domains_/new': typeof DashboardDomainsNewRoute
'/dashboard/notes_/$noteId': typeof DashboardNotesNoteIdRoute
'/dashboard/servers/new': typeof DashboardServersNewRoute
'/dashboard/notes_/new': typeof DashboardNotesNewRoute
'/dashboard/servers_/$serverId': typeof DashboardServersServerIdRoute
'/dashboard/services/new': typeof DashboardServicesNewRoute
'/dashboard/servers_/new': typeof DashboardServersNewRoute
'/dashboard/services_/$serviceId': typeof DashboardServicesServiceIdRoute
'/dashboard/services_/new': typeof DashboardServicesNewRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@ -175,14 +175,14 @@ export interface FileRouteTypes {
| '/dashboard/servers'
| '/dashboard/services'
| '/dashboard/'
| '/dashboard/domains/new'
| '/dashboard/domains/$domainId'
| '/dashboard/notes/new'
| '/dashboard/domains/new'
| '/dashboard/notes/$noteId'
| '/dashboard/servers/new'
| '/dashboard/notes/new'
| '/dashboard/servers/$serverId'
| '/dashboard/services/new'
| '/dashboard/servers/new'
| '/dashboard/services/$serviceId'
| '/dashboard/services/new'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@ -192,14 +192,14 @@ export interface FileRouteTypes {
| '/dashboard/servers'
| '/dashboard/services'
| '/dashboard'
| '/dashboard/domains/new'
| '/dashboard/domains/$domainId'
| '/dashboard/notes/new'
| '/dashboard/domains/new'
| '/dashboard/notes/$noteId'
| '/dashboard/servers/new'
| '/dashboard/notes/new'
| '/dashboard/servers/$serverId'
| '/dashboard/services/new'
| '/dashboard/servers/new'
| '/dashboard/services/$serviceId'
| '/dashboard/services/new'
id:
| '__root__'
| '/'
@ -210,14 +210,14 @@ export interface FileRouteTypes {
| '/dashboard/servers'
| '/dashboard/services'
| '/dashboard/'
| '/dashboard/domains/new'
| '/dashboard/domains_/$domainId'
| '/dashboard/notes/new'
| '/dashboard/domains_/new'
| '/dashboard/notes_/$noteId'
| '/dashboard/servers/new'
| '/dashboard/notes_/new'
| '/dashboard/servers_/$serverId'
| '/dashboard/services/new'
| '/dashboard/servers_/new'
| '/dashboard/services_/$serviceId'
| '/dashboard/services_/new'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@ -284,6 +284,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DashboardDomainsRouteImport
parentRoute: typeof DashboardRoute
}
'/dashboard/services_/new': {
id: '/dashboard/services_/new'
path: '/services/new'
fullPath: '/dashboard/services/new'
preLoaderRoute: typeof DashboardServicesNewRouteImport
parentRoute: typeof DashboardRoute
}
'/dashboard/services_/$serviceId': {
id: '/dashboard/services_/$serviceId'
path: '/services/$serviceId'
@ -291,12 +298,12 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DashboardServicesServiceIdRouteImport
parentRoute: typeof DashboardRoute
}
'/dashboard/services/new': {
id: '/dashboard/services/new'
path: '/new'
fullPath: '/dashboard/services/new'
preLoaderRoute: typeof DashboardServicesNewRouteImport
parentRoute: typeof DashboardServicesRoute
'/dashboard/servers_/new': {
id: '/dashboard/servers_/new'
path: '/servers/new'
fullPath: '/dashboard/servers/new'
preLoaderRoute: typeof DashboardServersNewRouteImport
parentRoute: typeof DashboardRoute
}
'/dashboard/servers_/$serverId': {
id: '/dashboard/servers_/$serverId'
@ -305,12 +312,12 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DashboardServersServerIdRouteImport
parentRoute: typeof DashboardRoute
}
'/dashboard/servers/new': {
id: '/dashboard/servers/new'
path: '/new'
fullPath: '/dashboard/servers/new'
preLoaderRoute: typeof DashboardServersNewRouteImport
parentRoute: typeof DashboardServersRoute
'/dashboard/notes_/new': {
id: '/dashboard/notes_/new'
path: '/notes/new'
fullPath: '/dashboard/notes/new'
preLoaderRoute: typeof DashboardNotesNewRouteImport
parentRoute: typeof DashboardRoute
}
'/dashboard/notes_/$noteId': {
id: '/dashboard/notes_/$noteId'
@ -319,12 +326,12 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DashboardNotesNoteIdRouteImport
parentRoute: typeof DashboardRoute
}
'/dashboard/notes/new': {
id: '/dashboard/notes/new'
path: '/new'
fullPath: '/dashboard/notes/new'
preLoaderRoute: typeof DashboardNotesNewRouteImport
parentRoute: typeof DashboardNotesRoute
'/dashboard/domains_/new': {
id: '/dashboard/domains_/new'
path: '/domains/new'
fullPath: '/dashboard/domains/new'
preLoaderRoute: typeof DashboardDomainsNewRouteImport
parentRoute: typeof DashboardRoute
}
'/dashboard/domains_/$domainId': {
id: '/dashboard/domains_/$domainId'
@ -333,83 +340,39 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DashboardDomainsDomainIdRouteImport
parentRoute: typeof DashboardRoute
}
'/dashboard/domains/new': {
id: '/dashboard/domains/new'
path: '/new'
fullPath: '/dashboard/domains/new'
preLoaderRoute: typeof DashboardDomainsNewRouteImport
parentRoute: typeof DashboardDomainsRoute
}
}
}
interface DashboardDomainsRouteChildren {
interface DashboardRouteChildren {
DashboardDomainsRoute: typeof DashboardDomainsRoute
DashboardNotesRoute: typeof DashboardNotesRoute
DashboardServersRoute: typeof DashboardServersRoute
DashboardServicesRoute: typeof DashboardServicesRoute
DashboardIndexRoute: typeof DashboardIndexRoute
DashboardDomainsDomainIdRoute: typeof DashboardDomainsDomainIdRoute
DashboardDomainsNewRoute: typeof DashboardDomainsNewRoute
}
const DashboardDomainsRouteChildren: DashboardDomainsRouteChildren = {
DashboardDomainsNewRoute: DashboardDomainsNewRoute,
}
const DashboardDomainsRouteWithChildren =
DashboardDomainsRoute._addFileChildren(DashboardDomainsRouteChildren)
interface DashboardNotesRouteChildren {
DashboardNotesNoteIdRoute: typeof DashboardNotesNoteIdRoute
DashboardNotesNewRoute: typeof DashboardNotesNewRoute
}
const DashboardNotesRouteChildren: DashboardNotesRouteChildren = {
DashboardNotesNewRoute: DashboardNotesNewRoute,
}
const DashboardNotesRouteWithChildren = DashboardNotesRoute._addFileChildren(
DashboardNotesRouteChildren,
)
interface DashboardServersRouteChildren {
DashboardServersServerIdRoute: typeof DashboardServersServerIdRoute
DashboardServersNewRoute: typeof DashboardServersNewRoute
}
const DashboardServersRouteChildren: DashboardServersRouteChildren = {
DashboardServersNewRoute: DashboardServersNewRoute,
}
const DashboardServersRouteWithChildren =
DashboardServersRoute._addFileChildren(DashboardServersRouteChildren)
interface DashboardServicesRouteChildren {
DashboardServicesServiceIdRoute: typeof DashboardServicesServiceIdRoute
DashboardServicesNewRoute: typeof DashboardServicesNewRoute
}
const DashboardServicesRouteChildren: DashboardServicesRouteChildren = {
DashboardServicesNewRoute: DashboardServicesNewRoute,
}
const DashboardServicesRouteWithChildren =
DashboardServicesRoute._addFileChildren(DashboardServicesRouteChildren)
interface DashboardRouteChildren {
DashboardDomainsRoute: typeof DashboardDomainsRouteWithChildren
DashboardNotesRoute: typeof DashboardNotesRouteWithChildren
DashboardServersRoute: typeof DashboardServersRouteWithChildren
DashboardServicesRoute: typeof DashboardServicesRouteWithChildren
DashboardIndexRoute: typeof DashboardIndexRoute
DashboardDomainsDomainIdRoute: typeof DashboardDomainsDomainIdRoute
DashboardNotesNoteIdRoute: typeof DashboardNotesNoteIdRoute
DashboardServersServerIdRoute: typeof DashboardServersServerIdRoute
DashboardServicesServiceIdRoute: typeof DashboardServicesServiceIdRoute
}
const DashboardRouteChildren: DashboardRouteChildren = {
DashboardDomainsRoute: DashboardDomainsRouteWithChildren,
DashboardNotesRoute: DashboardNotesRouteWithChildren,
DashboardServersRoute: DashboardServersRouteWithChildren,
DashboardServicesRoute: DashboardServicesRouteWithChildren,
DashboardDomainsRoute: DashboardDomainsRoute,
DashboardNotesRoute: DashboardNotesRoute,
DashboardServersRoute: DashboardServersRoute,
DashboardServicesRoute: DashboardServicesRoute,
DashboardIndexRoute: DashboardIndexRoute,
DashboardDomainsDomainIdRoute: DashboardDomainsDomainIdRoute,
DashboardDomainsNewRoute: DashboardDomainsNewRoute,
DashboardNotesNoteIdRoute: DashboardNotesNoteIdRoute,
DashboardNotesNewRoute: DashboardNotesNewRoute,
DashboardServersServerIdRoute: DashboardServersServerIdRoute,
DashboardServersNewRoute: DashboardServersNewRoute,
DashboardServicesServiceIdRoute: DashboardServicesServiceIdRoute,
DashboardServicesNewRoute: DashboardServicesNewRoute,
}
const DashboardRouteWithChildren = DashboardRoute._addFileChildren(

View file

@ -1,10 +1,11 @@
import { Button } from "@minmon/ui/components/button";
import { StatusBadge } from "@minmon/ui/components/status-badge";
import { statusBadgeVariants } from "@minmon/ui/components/status-badge";
import { cn } from "@minmon/ui/lib/utils";
import { useMutation, useQuery } from "@tanstack/react-query";
import { createFileRoute, Link } from "@tanstack/react-router";
import { toast } from "sonner";
import { PageSection, QueryStateCard, ResourceListCard } from "@/components/dashboard-ui";
import { ConfirmDeleteIconButton, PageSection, QueryStateCard, ResourceListCard } from "@/components/dashboard-ui";
import { queryClient, trpc, trpcClient } from "@/utils/trpc";
export const Route = createFileRoute("/dashboard/domains")({
@ -93,38 +94,29 @@ function RouteComponent() {
header: "DNS status",
className: "w-36",
render: (item: (typeof domains.data)[number]) => (
<StatusBadge variant={dnsStatusVariant(item.resolutionStatus)}>{item.resolutionStatus}</StatusBadge>
<button
type="button"
disabled={checkDns.isPending}
className={cn(
statusBadgeVariants({ variant: dnsStatusVariant(item.resolutionStatus) }),
"cursor-pointer transition-opacity hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-60",
)}
onClick={() => checkDns.mutate(item.id)}
>
{item.resolutionStatus}
</button>
),
},
{
key: "actions",
header: "Actions",
className: "w-44",
className: "w-16",
render: (item: (typeof domains.data)[number]) => (
<div className="flex flex-col items-start gap-2">
<Button
type="button"
variant="ghost"
className="h-auto px-0"
disabled={checkDns.isPending}
onClick={() => checkDns.mutate(item.id)}
>
Check DNS
</Button>
<Button
type="button"
variant="ghost"
className="h-auto px-0 text-red-600 hover:text-red-700"
disabled={removeDomain.isPending}
onClick={() => {
if (confirm(`Delete domain \"${item.name}\"?`)) {
removeDomain.mutate(item.id);
}
}}
>
Delete
</Button>
</div>
<ConfirmDeleteIconButton
itemLabel={`domain ${item.name}`}
disabled={removeDomain.isPending}
onConfirm={() => removeDomain.mutate(item.id)}
/>
),
},
]}

View file

@ -1,4 +1,4 @@
import { DetailCard, FormCard, PageSection, QueryStateCard, TextAreaField, TextField } from "@/components/dashboard-ui";
import { DetailCard, FormCard, PageSection, QueryStateCard, SelectField, TextAreaField, TextField } from "@/components/dashboard-ui";
import { queryClient, trpc, trpcClient } from "@/utils/trpc";
import { Button } from "@minmon/ui/components/button";
import { StatusBadge } from "@minmon/ui/components/status-badge";
@ -88,6 +88,11 @@ function RouteComponent() {
return <QueryStateCard title="Domain unavailable" description="The requested domain could not be loaded." />;
}
const serviceOptions = services.data.map((item) => ({
value: item.id,
label: `${item.name} (${item.id})`,
}));
return (
<div className="grid gap-6">
<PageSection title={domain.data.name} description="View DNS check results and edit this domain record.">
@ -134,16 +139,18 @@ function RouteComponent() {
>
<form.Field name="serviceId">
{(field) => (
<TextField
label="Service ID"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
description={`Available: ${services.data.map((item) => `${item.name} (${item.id})`).join(", ")}`}
/>
)}
</form.Field>
<SelectField
label="Service"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onValueChange={field.handleChange}
description="Select the service this domain belongs to."
placeholder="Select a service"
options={serviceOptions}
/>
)}
</form.Field>
<div className="grid gap-4 md:grid-cols-2">
<form.Field name="name">{(field) => <TextField label="Domain name" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} />}</form.Field>
<form.Field name="expectedServerIp">{(field) => <TextField label="Expected server IP" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} />}</form.Field>

View file

@ -3,10 +3,10 @@ import { useForm } from "@tanstack/react-form";
import { useMutation, useQuery } from "@tanstack/react-query";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { toast } from "sonner";
import { FormCard, PageSection, QueryStateCard, TextAreaField, TextField } from "@/components/dashboard-ui";
import { FormCard, PageSection, QueryStateCard, SelectField, TextAreaField, TextField } from "@/components/dashboard-ui";
import { queryClient, trpc, trpcClient } from "@/utils/trpc";
export const Route = createFileRoute("/dashboard/domains/new")({
export const Route = createFileRoute("/dashboard/domains_/new")({
component: RouteComponent,
});
@ -58,6 +58,11 @@ function RouteComponent() {
return <QueryStateCard title="Service options unavailable" description="Create a service before adding domains." />;
}
const serviceOptions = services.data.map((item) => ({
value: item.id,
label: `${item.name} (${item.id})`,
}));
return (
<PageSection title="Add domain" description="Create a domain or subdomain attached to a service.">
<form
@ -84,13 +89,15 @@ function RouteComponent() {
>
<form.Field name="serviceId">
{(field) => (
<TextField
label="Service ID"
<SelectField
label="Service"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
description={`Available: ${services.data.map((item) => `${item.name} (${item.id})`).join(", ")}`}
onValueChange={field.handleChange}
description="Select the service this domain belongs to."
placeholder="Select a service"
options={serviceOptions}
/>
)}
</form.Field>

View file

@ -3,7 +3,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { createFileRoute, Link } from "@tanstack/react-router";
import { toast } from "sonner";
import { PageSection, QueryStateCard, ResourceListCard } from "@/components/dashboard-ui";
import { ConfirmDeleteIconButton, PageSection, QueryStateCard, ResourceListCard } from "@/components/dashboard-ui";
import { queryClient, trpc, trpcClient } from "@/utils/trpc";
export const Route = createFileRoute("/dashboard/notes")({
@ -76,21 +76,13 @@ function RouteComponent() {
{
key: "actions",
header: "Actions",
className: "w-28",
className: "w-16",
render: (item: (typeof notes.data)[number]) => (
<Button
type="button"
variant="ghost"
className="h-auto px-0 text-red-600 hover:text-red-700"
<ConfirmDeleteIconButton
itemLabel={`note ${item.title}`}
disabled={removeNote.isPending}
onClick={() => {
if (confirm(`Delete note \"${item.title}\"?`)) {
removeNote.mutate(item.id);
}
}}
>
Delete
</Button>
onConfirm={() => removeNote.mutate(item.id)}
/>
),
},
]}

View file

@ -4,7 +4,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEffect } from "react";
import { toast } from "sonner";
import { DetailCard, FormCard, PageSection, QueryStateCard, TextAreaField, TextField } from "@/components/dashboard-ui";
import { DetailCard, FormCard, PageSection, QueryStateCard, SelectField, TextAreaField, TextField } from "@/components/dashboard-ui";
import { queryClient, trpc, trpcClient } from "@/utils/trpc";
export const Route = createFileRoute("/dashboard/notes_/$noteId")({
@ -75,6 +75,12 @@ function RouteComponent() {
return <QueryStateCard title="Note unavailable" description="The requested note could not be loaded." />;
}
const serverMap = new Map(servers.data.map((item) => [item.id, item.name]));
const serverOptions = servers.data.map((item) => ({
value: item.id,
label: `${item.name} (${item.id})`,
}));
return (
<div className="grid gap-6">
<PageSection title={note.data.title} description="View and edit this note.">
@ -82,7 +88,7 @@ function RouteComponent() {
title="Note summary"
description="Current note metadata and content preview."
rows={[
{ label: "Server ID", value: note.data.serverId },
{ label: "Server", value: serverMap.get(note.data.serverId) || note.data.serverId },
{ label: "Category", value: note.data.category || "-" },
{ label: "Updated", value: new Date(note.data.updatedAt).toLocaleString() },
{ label: "Content", value: note.data.content },
@ -114,13 +120,15 @@ function RouteComponent() {
>
<form.Field name="serverId">
{(field) => (
<TextField
label="Server ID"
<SelectField
label="Server"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
description={`Available: ${servers.data.map((item) => `${item.name} (${item.id})`).join(", ")}`}
onValueChange={field.handleChange}
description="Select the server this note belongs to."
placeholder="Select a server"
options={serverOptions}
/>
)}
</form.Field>

View file

@ -3,10 +3,10 @@ import { useForm } from "@tanstack/react-form";
import { useMutation, useQuery } from "@tanstack/react-query";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { toast } from "sonner";
import { FormCard, PageSection, QueryStateCard, TextAreaField, TextField } from "@/components/dashboard-ui";
import { FormCard, PageSection, QueryStateCard, SelectField, TextAreaField, TextField } from "@/components/dashboard-ui";
import { queryClient, trpc, trpcClient } from "@/utils/trpc";
export const Route = createFileRoute("/dashboard/notes/new")({
export const Route = createFileRoute("/dashboard/notes_/new")({
component: RouteComponent,
});
@ -58,6 +58,11 @@ function RouteComponent() {
return <QueryStateCard title="Server options unavailable" description="Add or load servers before creating notes." />;
}
const serverOptions = servers.data.map((item) => ({
value: item.id,
label: `${item.name} (${item.id})`,
}));
return (
<PageSection title="New note" description="Add documentation or operational notes for a server.">
<form
@ -84,13 +89,15 @@ function RouteComponent() {
>
<form.Field name="serverId">
{(field) => (
<TextField
label="Server ID"
<SelectField
label="Server"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
description={`Available: ${servers.data.map((item) => `${item.name} (${item.id})`).join(", ")}`}
onValueChange={field.handleChange}
description="Select the server this note belongs to."
placeholder="Select a server"
options={serverOptions}
/>
)}
</form.Field>

View file

@ -4,7 +4,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { createFileRoute, Link } from "@tanstack/react-router";
import { toast } from "sonner";
import { PageSection, QueryStateCard, ResourceListCard } from "@/components/dashboard-ui";
import { ConfirmDeleteIconButton, PageSection, QueryStateCard, ResourceListCard } from "@/components/dashboard-ui";
import { queryClient, trpc, trpcClient } from "@/utils/trpc";
export const Route = createFileRoute("/dashboard/servers")({
@ -99,21 +99,13 @@ function RouteComponent() {
{
key: "actions",
header: "Actions",
className: "w-28",
className: "w-16",
render: (item: (typeof servers.data)[number]) => (
<Button
type="button"
variant="ghost"
className="h-auto px-0 text-red-600 hover:text-red-700"
<ConfirmDeleteIconButton
itemLabel={`server ${item.name}`}
disabled={removeServer.isPending}
onClick={() => {
if (confirm(`Delete server \"${item.name}\" and all related services, domains, and notes?`)) {
removeServer.mutate(item.id);
}
}}
>
Delete
</Button>
onConfirm={() => removeServer.mutate(item.id)}
/>
),
},
]}

View file

@ -2,10 +2,10 @@ import { Button } from "@minmon/ui/components/button";
import { StatusBadge } from "@minmon/ui/components/status-badge";
import { useForm } from "@tanstack/react-form";
import { useMutation, useQuery } from "@tanstack/react-query";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useEffect } from "react";
import { toast } from "sonner";
import { DetailCard, FormCard, PageSection, QueryStateCard, TextAreaField, TextField } from "@/components/dashboard-ui";
import { DetailCard, FormCard, PageSection, QueryStateCard, ResourceListCard, SelectField, TextAreaField, TextField } from "@/components/dashboard-ui";
import { queryClient, trpc, trpcClient } from "@/utils/trpc";
export const Route = createFileRoute("/dashboard/servers_/$serverId")({
@ -23,10 +23,17 @@ type ServerFormValues = {
status: "active" | "maintenance" | "inactive";
};
const serverStatusOptions = [
{ value: "active", label: "Active" },
{ value: "maintenance", label: "Maintenance" },
{ value: "inactive", label: "Inactive" },
] as const;
function RouteComponent() {
const { serverId } = Route.useParams();
const navigate = useNavigate();
const server = useQuery(trpc.servers.byId.queryOptions({ id: serverId }));
const notes = useQuery(trpc.notes.list.queryOptions({ serverId }));
const updateServer = useMutation({
mutationFn: async (values: ServerFormValues) =>
@ -108,6 +115,54 @@ function RouteComponent() {
/>
</PageSection>
{notes.isLoading ? (
<QueryStateCard title="Loading server notes" description="Fetching notes attached to this server." />
) : notes.isError || !notes.data ? (
<QueryStateCard title="Server notes unavailable" description="The notes for this server could not be loaded." />
) : (
<PageSection
title="Server notes"
description="Notes and internal documentation attached to this server."
action={
<Link to="/dashboard/notes/new">
<Button variant="outline">New note</Button>
</Link>
}
>
<ResourceListCard
title="Attached notes"
description="Review server-specific documentation without leaving this detail page."
columns={[
{
key: "title",
header: "Title",
render: (item: (typeof notes.data)[number]) => (
<div className="grid gap-1">
<Link to="/dashboard/notes/$noteId" params={{ noteId: item.id }} className="font-medium hover:underline">
{item.title}
</Link>
<span className="text-xs text-muted-foreground line-clamp-2">{item.content}</span>
</div>
),
},
{
key: "category",
header: "Category",
render: (item: (typeof notes.data)[number]) => item.category || "-",
},
{
key: "updatedAt",
header: "Updated",
render: (item: (typeof notes.data)[number]) => new Date(item.updatedAt).toLocaleString(),
},
]}
items={notes.data}
emptyTitle="No notes for this server"
emptyDescription="Add a note to document maintenance, incidents, or internal server context."
/>
</PageSection>
)}
<form
className="grid gap-4"
onSubmit={(e) => {
@ -137,7 +192,7 @@ function RouteComponent() {
<form.Field name="operatingSystem">{(field) => <TextField label="Operating system" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} />}</form.Field>
<form.Field name="location">{(field) => <TextField label="Location" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} />}</form.Field>
<form.Field name="provider">{(field) => <TextField label="Provider" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} />}</form.Field>
<form.Field name="status">{(field) => <TextField label="Status" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value as "active" | "maintenance" | "inactive")} />}</form.Field>
<form.Field name="status">{(field) => <SelectField label="Status" name={field.name} value={field.state.value} onBlur={field.handleBlur} onValueChange={(value) => field.handleChange(value as ServerFormValues["status"])} placeholder="Select server status" options={[...serverStatusOptions]} />}</form.Field>
</div>
<form.Field name="description">{(field) => <TextAreaField label="Description" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} />}</form.Field>
</FormCard>

View file

@ -3,10 +3,10 @@ import { useForm } from "@tanstack/react-form";
import { useMutation } from "@tanstack/react-query";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { toast } from "sonner";
import { FormCard, PageSection, TextAreaField, TextField } from "@/components/dashboard-ui";
import { FormCard, PageSection, SelectField, TextAreaField, TextField } from "@/components/dashboard-ui";
import { queryClient, trpcClient } from "@/utils/trpc";
export const Route = createFileRoute("/dashboard/servers/new")({
export const Route = createFileRoute("/dashboard/servers_/new")({
component: RouteComponent,
});
@ -21,6 +21,12 @@ type ServerFormValues = {
status: "active" | "maintenance" | "inactive";
};
const serverStatusOptions = [
{ value: "active", label: "Active" },
{ value: "maintenance", label: "Maintenance" },
{ value: "inactive", label: "Inactive" },
] as const;
function RouteComponent() {
const navigate = useNavigate();
const createServer = useMutation({
@ -160,13 +166,14 @@ function RouteComponent() {
</form.Field>
<form.Field name="status">
{(field) => (
<TextField
<SelectField
label="Status"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value as "active" | "maintenance" | "inactive")}
placeholder="active"
onValueChange={(value) => field.handleChange(value as ServerFormValues["status"])}
placeholder="Select server status"
options={[...serverStatusOptions]}
/>
)}
</form.Field>

View file

@ -4,7 +4,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { createFileRoute, Link } from "@tanstack/react-router";
import { toast } from "sonner";
import { PageSection, QueryStateCard, ResourceListCard } from "@/components/dashboard-ui";
import { ConfirmDeleteIconButton, PageSection, QueryStateCard, ResourceListCard } from "@/components/dashboard-ui";
import { queryClient, trpc, trpcClient } from "@/utils/trpc";
export const Route = createFileRoute("/dashboard/services")({
@ -96,21 +96,13 @@ function RouteComponent() {
{
key: "actions",
header: "Actions",
className: "w-28",
className: "w-16",
render: (item: (typeof services.data)[number]) => (
<Button
type="button"
variant="ghost"
className="h-auto px-0 text-red-600 hover:text-red-700"
<ConfirmDeleteIconButton
itemLabel={`service ${item.name}`}
disabled={removeService.isPending}
onClick={() => {
if (confirm(`Delete service \"${item.name}\" and its related domains?`)) {
removeService.mutate(item.id);
}
}}
>
Delete
</Button>
onConfirm={() => removeService.mutate(item.id)}
/>
),
},
]}

View file

@ -5,7 +5,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEffect } from "react";
import { toast } from "sonner";
import { DetailCard, FormCard, PageSection, QueryStateCard, TextAreaField, TextField } from "@/components/dashboard-ui";
import { DetailCard, FormCard, PageSection, QueryStateCard, SelectField, TextAreaField, TextField } from "@/components/dashboard-ui";
import { queryClient, trpc, trpcClient } from "@/utils/trpc";
export const Route = createFileRoute("/dashboard/services_/$serviceId")({
@ -23,6 +23,29 @@ type ServiceFormValues = {
description: string;
};
const serviceTypeOptions = [
{ value: "web_app", label: "Web app" },
{ value: "mail", label: "Mail" },
{ value: "database", label: "Database" },
{ value: "api", label: "API" },
{ value: "proxy", label: "Proxy" },
{ value: "other", label: "Other" },
] as const;
const serviceProtocolOptions = [
{ value: "http", label: "HTTP" },
{ value: "https", label: "HTTPS" },
{ value: "tcp", label: "TCP" },
{ value: "udp", label: "UDP" },
{ value: "other", label: "Other" },
] as const;
const serviceStatusOptions = [
{ value: "active", label: "Active" },
{ value: "down", label: "Down" },
{ value: "maintenance", label: "Maintenance" },
] as const;
function RouteComponent() {
const { serviceId } = Route.useParams();
const navigate = useNavigate();
@ -92,6 +115,11 @@ function RouteComponent() {
return <QueryStateCard title="Service unavailable" description="The requested service could not be loaded." />;
}
const serverOptions = servers.data.map((item) => ({
value: item.id,
label: `${item.name} (${item.id})`,
}));
return (
<div className="grid gap-6">
<PageSection title={service.data.name} description="View and edit the selected service.">
@ -134,23 +162,25 @@ function RouteComponent() {
>
<form.Field name="serverId">
{(field) => (
<TextField
label="Server ID"
<SelectField
label="Server"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
description={`Available: ${servers.data.map((item) => `${item.name} (${item.id})`).join(", ")}`}
onValueChange={field.handleChange}
description="Select the server that owns this service."
placeholder="Select a server"
options={serverOptions}
/>
)}
</form.Field>
<div className="grid gap-4 md:grid-cols-2">
<form.Field name="name">{(field) => <TextField label="Service name" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} />}</form.Field>
<form.Field name="type">{(field) => <TextField label="Service type" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value as ServiceFormValues["type"])} />}</form.Field>
<form.Field name="type">{(field) => <SelectField label="Service type" name={field.name} value={field.state.value} onBlur={field.handleBlur} onValueChange={(value) => field.handleChange(value as ServiceFormValues["type"])} placeholder="Select service type" options={[...serviceTypeOptions]} />}</form.Field>
<form.Field name="internalPort">{(field) => <TextField label="Internal port" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} />}</form.Field>
<form.Field name="externalPort">{(field) => <TextField label="External port" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} />}</form.Field>
<form.Field name="protocol">{(field) => <TextField label="Protocol" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value as ServiceFormValues["protocol"])} />}</form.Field>
<form.Field name="status">{(field) => <TextField label="Status" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value as ServiceFormValues["status"])} />}</form.Field>
<form.Field name="protocol">{(field) => <SelectField label="Protocol" name={field.name} value={field.state.value} onBlur={field.handleBlur} onValueChange={(value) => field.handleChange(value as ServiceFormValues["protocol"])} placeholder="Select protocol" options={[...serviceProtocolOptions]} />}</form.Field>
<form.Field name="status">{(field) => <SelectField label="Status" name={field.name} value={field.state.value} onBlur={field.handleBlur} onValueChange={(value) => field.handleChange(value as ServiceFormValues["status"])} placeholder="Select service status" options={[...serviceStatusOptions]} />}</form.Field>
</div>
<form.Field name="description">{(field) => <TextAreaField label="Description" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} />}</form.Field>
</FormCard>

View file

@ -3,10 +3,10 @@ import { useForm } from "@tanstack/react-form";
import { useMutation, useQuery } from "@tanstack/react-query";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { toast } from "sonner";
import { FormCard, PageSection, QueryStateCard, TextAreaField, TextField } from "@/components/dashboard-ui";
import { FormCard, PageSection, QueryStateCard, SelectField, TextAreaField, TextField } from "@/components/dashboard-ui";
import { queryClient, trpc, trpcClient } from "@/utils/trpc";
export const Route = createFileRoute("/dashboard/services/new")({
export const Route = createFileRoute("/dashboard/services_/new")({
component: RouteComponent,
});
@ -21,6 +21,29 @@ type ServiceFormValues = {
description: string;
};
const serviceTypeOptions = [
{ value: "web_app", label: "Web app" },
{ value: "mail", label: "Mail" },
{ value: "database", label: "Database" },
{ value: "api", label: "API" },
{ value: "proxy", label: "Proxy" },
{ value: "other", label: "Other" },
] as const;
const serviceProtocolOptions = [
{ value: "http", label: "HTTP" },
{ value: "https", label: "HTTPS" },
{ value: "tcp", label: "TCP" },
{ value: "udp", label: "UDP" },
{ value: "other", label: "Other" },
] as const;
const serviceStatusOptions = [
{ value: "active", label: "Active" },
{ value: "down", label: "Down" },
{ value: "maintenance", label: "Maintenance" },
] as const;
function RouteComponent() {
const navigate = useNavigate();
const servers = useQuery(trpc.servers.list.queryOptions());
@ -70,6 +93,11 @@ function RouteComponent() {
return <QueryStateCard title="Server options unavailable" description="Add or load servers before creating a service." />;
}
const serverOptions = servers.data.map((item) => ({
value: item.id,
label: `${item.name} (${item.id})`,
}));
return (
<PageSection title="Add service" description="Create a new service under an existing server.">
<form
@ -96,23 +124,25 @@ function RouteComponent() {
>
<form.Field name="serverId">
{(field) => (
<TextField
label="Server ID"
<SelectField
label="Server"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
description={`Available: ${servers.data.map((item) => `${item.name} (${item.id})`).join(", ")}`}
onValueChange={field.handleChange}
description="Select the server that owns this service."
placeholder="Select a server"
options={serverOptions}
/>
)}
</form.Field>
<div className="grid gap-4 md:grid-cols-2">
<form.Field name="name">{(field) => <TextField label="Service name" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} />}</form.Field>
<form.Field name="type">{(field) => <TextField label="Service type" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value as ServiceFormValues["type"])} />}</form.Field>
<form.Field name="type">{(field) => <SelectField label="Service type" name={field.name} value={field.state.value} onBlur={field.handleBlur} onValueChange={(value) => field.handleChange(value as ServiceFormValues["type"])} placeholder="Select service type" options={[...serviceTypeOptions]} />}</form.Field>
<form.Field name="internalPort">{(field) => <TextField label="Internal port" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} />}</form.Field>
<form.Field name="externalPort">{(field) => <TextField label="External port" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} />}</form.Field>
<form.Field name="protocol">{(field) => <TextField label="Protocol" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value as ServiceFormValues["protocol"])} />}</form.Field>
<form.Field name="status">{(field) => <TextField label="Status" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value as ServiceFormValues["status"])} />}</form.Field>
<form.Field name="protocol">{(field) => <SelectField label="Protocol" name={field.name} value={field.state.value} onBlur={field.handleBlur} onValueChange={(value) => field.handleChange(value as ServiceFormValues["protocol"])} placeholder="Select protocol" options={[...serviceProtocolOptions]} />}</form.Field>
<form.Field name="status">{(field) => <SelectField label="Status" name={field.name} value={field.state.value} onBlur={field.handleBlur} onValueChange={(value) => field.handleChange(value as ServiceFormValues["status"])} placeholder="Select service status" options={[...serviceStatusOptions]} />}</form.Field>
</div>
<form.Field name="description">{(field) => <TextAreaField label="Description" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} />}</form.Field>
</FormCard>