ui: add protected dashboard CRUD screens and assignment docs

This commit is contained in:
Syahdan 2026-04-29 18:10:31 +07:00
parent bf42037619
commit cb0d4d7809
24 changed files with 2662 additions and 101 deletions

View file

@ -0,0 +1,123 @@
import { Button } from "@minmon/ui/components/button";
import { cn } from "@minmon/ui/lib/utils";
import { Link, Outlet, useRouterState } from "@tanstack/react-router";
import { Activity, BookText, Globe, LayoutGrid, Server, Wrench } from "lucide-react";
const dashboardLinks = [
{
to: "/dashboard",
label: "Overview",
description: "Health, alerts, and recent checks",
icon: LayoutGrid,
},
{
to: "/dashboard/servers",
label: "Servers",
description: "Inventory and host details",
icon: Server,
},
{
to: "/dashboard/services",
label: "Services",
description: "Processes and uptime targets",
icon: Wrench,
},
{
to: "/dashboard/domains",
label: "Domains",
description: "DNS, SSL, and expiry status",
icon: Globe,
},
{
to: "/dashboard/notes",
label: "Notes",
description: "Runbooks and operational handover",
icon: BookText,
},
] as const;
export function DashboardShell({
userName,
userEmail,
}: {
userName?: string | null;
userEmail?: string | null;
}) {
const pathname = useRouterState({
select: (state) => state.location.pathname,
});
return (
<div className="min-h-full bg-muted/20">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6 lg:px-6">
<section className="border bg-card">
<div className="grid gap-4 px-5 py-5 lg:grid-cols-[1.4fr_0.6fr] lg:px-6">
<div className="space-y-2">
<p className="text-[11px] uppercase tracking-[0.24em] text-muted-foreground">
Server Management Dashboard
</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.
</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">
<div>
<p className="text-[11px] uppercase tracking-[0.16em] text-muted-foreground">Signed in as</p>
<p className="mt-1 text-sm font-medium">{userName ?? "Administrator"}</p>
<p className="text-xs text-muted-foreground">{userEmail ?? "admin@minmon.local"}</p>
</div>
<div className="flex flex-wrap gap-2">
<Link to="/dashboard/servers/new">
<Button size="sm">Add server</Button>
</Link>
<Link to="/dashboard/notes/new">
<Button size="sm" variant="outline">
New note
</Button>
</Link>
</div>
</div>
</div>
</section>
<div className="grid gap-6 lg:grid-cols-[240px_minmax(0,1fr)]">
<aside className="h-fit border bg-card">
<div className="border-b px-4 py-3">
<div className="flex items-center gap-2 text-sm font-medium">
<Activity className="size-4" />
Navigation
</div>
</div>
<nav className="grid gap-1 p-2">
{dashboardLinks.map(({ to, label, description, icon: Icon }) => {
const active = pathname === to || pathname.startsWith(`${to}/`);
return (
<Link
key={to}
to={to}
className={cn(
"grid gap-0.5 border px-3 py-2 text-left transition-colors",
active ? "border-foreground/20 bg-muted" : "border-transparent hover:border-border hover:bg-muted/40",
)}
>
<span className="flex items-center gap-2 text-sm font-medium">
<Icon className="size-4" />
{label}
</span>
<span className="text-xs text-muted-foreground">{description}</span>
</Link>
);
})}
</nav>
</aside>
<main className="min-w-0">
<Outlet />
</main>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,282 @@
import { Button } from "@minmon/ui/components/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@minmon/ui/components/card";
import { Input } from "@minmon/ui/components/input";
import { Label } from "@minmon/ui/components/label";
import { StatusBadge } from "@minmon/ui/components/status-badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableWrapper,
} 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";
type BadgeVariant = "neutral" | "success" | "warning" | "destructive" | "info";
export function PageSection({ title, description, action, children }: SectionProps) {
return (
<section className="grid gap-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="space-y-1">
<h2 className="text-lg font-semibold tracking-tight">{title}</h2>
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div>
{action ? <div>{action}</div> : null}
</div>
{children}
</section>
);
}
export function SummaryCard({
label,
value,
hint,
status,
}: {
label: string;
value: string;
hint: string;
status?: { label: string; variant: BadgeVariant };
}) {
return (
<Card>
<CardHeader>
<CardDescription>{label}</CardDescription>
{status ? <StatusBadge variant={status.variant}>{status.label}</StatusBadge> : null}
</CardHeader>
<CardContent className="space-y-2">
<p className="text-2xl font-semibold tracking-tight">{value}</p>
<p className="text-sm text-muted-foreground">{hint}</p>
</CardContent>
</Card>
);
}
export function ResourceListCard<TItem>({
title,
description,
columns,
items,
emptyTitle,
emptyDescription,
}: {
title: string;
description: string;
columns: { key: string; header: string; render: (item: TItem) => ReactNode; className?: string }[];
items: TItem[];
emptyTitle: string;
emptyDescription: string;
}) {
return (
<Card className="gap-0">
<CardHeader className="border-b">
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent className="px-0">
{items.length ? (
<TableWrapper className="border-0">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
{columns.map((column) => (
<TableHead key={column.key} className={column.className}>
{column.header}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, index) => (
<TableRow key={index}>
{columns.map((column) => (
<TableCell key={column.key} className={column.className}>
{column.render(item)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</TableWrapper>
) : (
<EmptyState title={emptyTitle} description={emptyDescription} className="border-0" />
)}
</CardContent>
</Card>
);
}
export function DetailCard({
title,
description,
rows,
}: {
title: string;
description: string;
rows: { label: string; value: ReactNode }[];
}) {
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
{rows.map((row) => (
<div key={row.label} className="space-y-1 border-l-2 border-border pl-3">
<p className="text-[11px] uppercase tracking-[0.16em] text-muted-foreground">{row.label}</p>
<div className="text-sm">{row.value}</div>
</div>
))}
</CardContent>
</Card>
);
}
export function FormCard({
title,
description,
children,
footer,
}: {
title: string;
description: string;
children: ReactNode;
footer?: ReactNode;
}) {
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent className="grid gap-4">{children}</CardContent>
{footer ? <CardFooter className="justify-between gap-3">{footer}</CardFooter> : null}
</Card>
);
}
export function FieldGroup({
label,
htmlFor,
description,
children,
}: {
label: string;
htmlFor: string;
description?: string;
children: ReactNode;
}) {
return (
<div className="grid gap-2">
<Label htmlFor={htmlFor}>{label}</Label>
{children}
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
</div>
);
}
export function TextField(props: React.ComponentProps<typeof Input> & { label: string; description?: string }) {
const { label, description, id, ...inputProps } = props;
return (
<FieldGroup label={label} htmlFor={id ?? inputProps.name ?? label} description={description}>
<Input id={id ?? inputProps.name ?? label} {...inputProps} />
</FieldGroup>
);
}
export function TextAreaField(
props: React.ComponentProps<typeof Textarea> & { label: string; description?: string },
) {
const { label, description, id, ...textareaProps } = props;
return (
<FieldGroup label={label} htmlFor={id ?? textareaProps.name ?? label} description={description}>
<Textarea id={id ?? textareaProps.name ?? label} {...textareaProps} />
</FieldGroup>
);
}
export function EmptyState({
title,
description,
action,
className,
}: {
title: string;
description: string;
action?: ReactNode;
className?: string;
}) {
return (
<div className={"border px-4 py-8 text-center " + (className ?? "")}>
<p className="text-sm font-medium">{title}</p>
<p className="mx-auto mt-2 max-w-lg text-sm text-muted-foreground">{description}</p>
{action ? <div className="mt-4 flex justify-center">{action}</div> : null}
</div>
);
}
export function QueryStateCard({
title,
description,
action,
}: {
title: string;
description: string;
action?: ReactNode;
}) {
return <EmptyState title={title} description={description} action={action} />;
}
export function SectionLinks({ items }: { items: { label: string; to: string; detail: string }[] }) {
return (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{items.map((item) => (
<Link key={item.to} to={item.to} className="border bg-card p-4 transition-colors hover:bg-muted/30">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium">{item.label}</p>
<p className="mt-1 text-xs text-muted-foreground">{item.detail}</p>
</div>
<ChevronRight className="size-4 text-muted-foreground" />
</div>
</Link>
))}
</div>
);
}
export function FormActions({ cancelTo, submitLabel }: { cancelTo: string; submitLabel: string }) {
return (
<>
<Link to={cancelTo}>
<Button variant="ghost">Cancel</Button>
</Link>
<Button type="submit">{submitLabel}</Button>
</>
);
}
interface SectionProps {
title: string;
description?: string;
action?: ReactNode;
children: ReactNode;
}

View file

@ -7,26 +7,34 @@ export default function Header() {
const links = [
{ to: "/", label: "Home" },
{ to: "/dashboard", label: "Dashboard" },
{ to: "/dashboard/servers", label: "Servers" },
{ to: "/dashboard/services", label: "Services" },
{ to: "/dashboard/domains", label: "Domains" },
{ to: "/dashboard/notes", label: "Notes" },
] as const;
return (
<div>
<div className="flex flex-row items-center justify-between px-2 py-1">
<nav className="flex gap-4 text-lg">
<div className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/75">
<div className="mx-auto flex max-w-7xl flex-row items-center justify-between gap-4 px-4 py-3 lg:px-6">
<div className="flex items-center gap-6">
<Link to="/" className="text-sm font-semibold tracking-[0.2em] uppercase">
minmon
</Link>
<nav className="flex flex-wrap gap-4 text-sm text-muted-foreground">
{links.map(({ to, label }) => {
return (
<Link key={to} to={to}>
<Link key={to} to={to} activeProps={{ className: "text-foreground" }}>
{label}
</Link>
);
})}
</nav>
</nav>
</div>
<div className="flex items-center gap-2">
<ModeToggle />
<UserMenu />
</div>
</div>
<hr />
</div>
);
}

View file

@ -0,0 +1,16 @@
import { redirect } from "@tanstack/react-router";
import { authClient } from "@/lib/auth-client";
export async function requireSession() {
const session = await authClient.getSession();
if (!session.data) {
redirect({
to: "/login",
throw: true,
});
}
return { session };
}

View file

@ -0,0 +1,426 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as LoginRouteImport } from './routes/login'
import { Route as DashboardRouteImport } from './routes/dashboard'
import { Route as IndexRouteImport } from './routes/index'
import { Route as DashboardIndexRouteImport } from './routes/dashboard.index'
import { Route as DashboardServicesRouteImport } from './routes/dashboard.services'
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 DashboardServersNewRouteImport } from './routes/dashboard.servers.new'
import { Route as DashboardServersServerIdRouteImport } from './routes/dashboard.servers.$serverId'
import { Route as DashboardNotesNewRouteImport } from './routes/dashboard.notes.new'
import { Route as DashboardNotesNoteIdRouteImport } from './routes/dashboard.notes.$noteId'
import { Route as DashboardDomainsNewRouteImport } from './routes/dashboard.domains.new'
import { Route as DashboardDomainsDomainIdRouteImport } from './routes/dashboard.domains.$domainId'
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const DashboardRoute = DashboardRouteImport.update({
id: '/dashboard',
path: '/dashboard',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const DashboardIndexRoute = DashboardIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => DashboardRoute,
} as any)
const DashboardServicesRoute = DashboardServicesRouteImport.update({
id: '/services',
path: '/services',
getParentRoute: () => DashboardRoute,
} as any)
const DashboardServersRoute = DashboardServersRouteImport.update({
id: '/servers',
path: '/servers',
getParentRoute: () => DashboardRoute,
} as any)
const DashboardNotesRoute = DashboardNotesRouteImport.update({
id: '/notes',
path: '/notes',
getParentRoute: () => DashboardRoute,
} as any)
const DashboardDomainsRoute = DashboardDomainsRouteImport.update({
id: '/domains',
path: '/domains',
getParentRoute: () => DashboardRoute,
} as any)
const DashboardServicesNewRoute = DashboardServicesNewRouteImport.update({
id: '/new',
path: '/new',
getParentRoute: () => DashboardServicesRoute,
} as any)
const DashboardServicesServiceIdRoute =
DashboardServicesServiceIdRouteImport.update({
id: '/$serviceId',
path: '/$serviceId',
getParentRoute: () => DashboardServicesRoute,
} as any)
const DashboardServersNewRoute = DashboardServersNewRouteImport.update({
id: '/new',
path: '/new',
getParentRoute: () => DashboardServersRoute,
} as any)
const DashboardServersServerIdRoute =
DashboardServersServerIdRouteImport.update({
id: '/$serverId',
path: '/$serverId',
getParentRoute: () => DashboardServersRoute,
} as any)
const DashboardNotesNewRoute = DashboardNotesNewRouteImport.update({
id: '/new',
path: '/new',
getParentRoute: () => DashboardNotesRoute,
} as any)
const DashboardNotesNoteIdRoute = DashboardNotesNoteIdRouteImport.update({
id: '/$noteId',
path: '/$noteId',
getParentRoute: () => DashboardNotesRoute,
} as any)
const DashboardDomainsNewRoute = DashboardDomainsNewRouteImport.update({
id: '/new',
path: '/new',
getParentRoute: () => DashboardDomainsRoute,
} as any)
const DashboardDomainsDomainIdRoute =
DashboardDomainsDomainIdRouteImport.update({
id: '/$domainId',
path: '/$domainId',
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/': typeof DashboardIndexRoute
'/dashboard/domains/$domainId': typeof DashboardDomainsDomainIdRoute
'/dashboard/domains/new': typeof DashboardDomainsNewRoute
'/dashboard/notes/$noteId': typeof DashboardNotesNoteIdRoute
'/dashboard/notes/new': typeof DashboardNotesNewRoute
'/dashboard/servers/$serverId': typeof DashboardServersServerIdRoute
'/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': typeof DashboardIndexRoute
'/dashboard/domains/$domainId': typeof DashboardDomainsDomainIdRoute
'/dashboard/domains/new': typeof DashboardDomainsNewRoute
'/dashboard/notes/$noteId': typeof DashboardNotesNoteIdRoute
'/dashboard/notes/new': typeof DashboardNotesNewRoute
'/dashboard/servers/$serverId': typeof DashboardServersServerIdRoute
'/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/': typeof DashboardIndexRoute
'/dashboard/domains/$domainId': typeof DashboardDomainsDomainIdRoute
'/dashboard/domains/new': typeof DashboardDomainsNewRoute
'/dashboard/notes/$noteId': typeof DashboardNotesNoteIdRoute
'/dashboard/notes/new': typeof DashboardNotesNewRoute
'/dashboard/servers/$serverId': typeof DashboardServersServerIdRoute
'/dashboard/servers/new': typeof DashboardServersNewRoute
'/dashboard/services/$serviceId': typeof DashboardServicesServiceIdRoute
'/dashboard/services/new': typeof DashboardServicesNewRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/dashboard'
| '/login'
| '/dashboard/domains'
| '/dashboard/notes'
| '/dashboard/servers'
| '/dashboard/services'
| '/dashboard/'
| '/dashboard/domains/$domainId'
| '/dashboard/domains/new'
| '/dashboard/notes/$noteId'
| '/dashboard/notes/new'
| '/dashboard/servers/$serverId'
| '/dashboard/servers/new'
| '/dashboard/services/$serviceId'
| '/dashboard/services/new'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/login'
| '/dashboard/domains'
| '/dashboard/notes'
| '/dashboard/servers'
| '/dashboard/services'
| '/dashboard'
| '/dashboard/domains/$domainId'
| '/dashboard/domains/new'
| '/dashboard/notes/$noteId'
| '/dashboard/notes/new'
| '/dashboard/servers/$serverId'
| '/dashboard/servers/new'
| '/dashboard/services/$serviceId'
| '/dashboard/services/new'
id:
| '__root__'
| '/'
| '/dashboard'
| '/login'
| '/dashboard/domains'
| '/dashboard/notes'
| '/dashboard/servers'
| '/dashboard/services'
| '/dashboard/'
| '/dashboard/domains/$domainId'
| '/dashboard/domains/new'
| '/dashboard/notes/$noteId'
| '/dashboard/notes/new'
| '/dashboard/servers/$serverId'
| '/dashboard/servers/new'
| '/dashboard/services/$serviceId'
| '/dashboard/services/new'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
DashboardRoute: typeof DashboardRouteWithChildren
LoginRoute: typeof LoginRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/dashboard': {
id: '/dashboard'
path: '/dashboard'
fullPath: '/dashboard'
preLoaderRoute: typeof DashboardRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/dashboard/': {
id: '/dashboard/'
path: '/'
fullPath: '/dashboard/'
preLoaderRoute: typeof DashboardIndexRouteImport
parentRoute: typeof DashboardRoute
}
'/dashboard/services': {
id: '/dashboard/services'
path: '/services'
fullPath: '/dashboard/services'
preLoaderRoute: typeof DashboardServicesRouteImport
parentRoute: typeof DashboardRoute
}
'/dashboard/servers': {
id: '/dashboard/servers'
path: '/servers'
fullPath: '/dashboard/servers'
preLoaderRoute: typeof DashboardServersRouteImport
parentRoute: typeof DashboardRoute
}
'/dashboard/notes': {
id: '/dashboard/notes'
path: '/notes'
fullPath: '/dashboard/notes'
preLoaderRoute: typeof DashboardNotesRouteImport
parentRoute: typeof DashboardRoute
}
'/dashboard/domains': {
id: '/dashboard/domains'
path: '/domains'
fullPath: '/dashboard/domains'
preLoaderRoute: typeof DashboardDomainsRouteImport
parentRoute: typeof DashboardRoute
}
'/dashboard/services/new': {
id: '/dashboard/services/new'
path: '/new'
fullPath: '/dashboard/services/new'
preLoaderRoute: typeof DashboardServicesNewRouteImport
parentRoute: typeof DashboardServicesRoute
}
'/dashboard/services/$serviceId': {
id: '/dashboard/services/$serviceId'
path: '/$serviceId'
fullPath: '/dashboard/services/$serviceId'
preLoaderRoute: typeof DashboardServicesServiceIdRouteImport
parentRoute: typeof DashboardServicesRoute
}
'/dashboard/servers/new': {
id: '/dashboard/servers/new'
path: '/new'
fullPath: '/dashboard/servers/new'
preLoaderRoute: typeof DashboardServersNewRouteImport
parentRoute: typeof DashboardServersRoute
}
'/dashboard/servers/$serverId': {
id: '/dashboard/servers/$serverId'
path: '/$serverId'
fullPath: '/dashboard/servers/$serverId'
preLoaderRoute: typeof DashboardServersServerIdRouteImport
parentRoute: typeof DashboardServersRoute
}
'/dashboard/notes/new': {
id: '/dashboard/notes/new'
path: '/new'
fullPath: '/dashboard/notes/new'
preLoaderRoute: typeof DashboardNotesNewRouteImport
parentRoute: typeof DashboardNotesRoute
}
'/dashboard/notes/$noteId': {
id: '/dashboard/notes/$noteId'
path: '/$noteId'
fullPath: '/dashboard/notes/$noteId'
preLoaderRoute: typeof DashboardNotesNoteIdRouteImport
parentRoute: typeof DashboardNotesRoute
}
'/dashboard/domains/new': {
id: '/dashboard/domains/new'
path: '/new'
fullPath: '/dashboard/domains/new'
preLoaderRoute: typeof DashboardDomainsNewRouteImport
parentRoute: typeof DashboardDomainsRoute
}
'/dashboard/domains/$domainId': {
id: '/dashboard/domains/$domainId'
path: '/$domainId'
fullPath: '/dashboard/domains/$domainId'
preLoaderRoute: typeof DashboardDomainsDomainIdRouteImport
parentRoute: typeof DashboardDomainsRoute
}
}
}
interface DashboardDomainsRouteChildren {
DashboardDomainsDomainIdRoute: typeof DashboardDomainsDomainIdRoute
DashboardDomainsNewRoute: typeof DashboardDomainsNewRoute
}
const DashboardDomainsRouteChildren: DashboardDomainsRouteChildren = {
DashboardDomainsDomainIdRoute: DashboardDomainsDomainIdRoute,
DashboardDomainsNewRoute: DashboardDomainsNewRoute,
}
const DashboardDomainsRouteWithChildren =
DashboardDomainsRoute._addFileChildren(DashboardDomainsRouteChildren)
interface DashboardNotesRouteChildren {
DashboardNotesNoteIdRoute: typeof DashboardNotesNoteIdRoute
DashboardNotesNewRoute: typeof DashboardNotesNewRoute
}
const DashboardNotesRouteChildren: DashboardNotesRouteChildren = {
DashboardNotesNoteIdRoute: DashboardNotesNoteIdRoute,
DashboardNotesNewRoute: DashboardNotesNewRoute,
}
const DashboardNotesRouteWithChildren = DashboardNotesRoute._addFileChildren(
DashboardNotesRouteChildren,
)
interface DashboardServersRouteChildren {
DashboardServersServerIdRoute: typeof DashboardServersServerIdRoute
DashboardServersNewRoute: typeof DashboardServersNewRoute
}
const DashboardServersRouteChildren: DashboardServersRouteChildren = {
DashboardServersServerIdRoute: DashboardServersServerIdRoute,
DashboardServersNewRoute: DashboardServersNewRoute,
}
const DashboardServersRouteWithChildren =
DashboardServersRoute._addFileChildren(DashboardServersRouteChildren)
interface DashboardServicesRouteChildren {
DashboardServicesServiceIdRoute: typeof DashboardServicesServiceIdRoute
DashboardServicesNewRoute: typeof DashboardServicesNewRoute
}
const DashboardServicesRouteChildren: DashboardServicesRouteChildren = {
DashboardServicesServiceIdRoute: DashboardServicesServiceIdRoute,
DashboardServicesNewRoute: DashboardServicesNewRoute,
}
const DashboardServicesRouteWithChildren =
DashboardServicesRoute._addFileChildren(DashboardServicesRouteChildren)
interface DashboardRouteChildren {
DashboardDomainsRoute: typeof DashboardDomainsRouteWithChildren
DashboardNotesRoute: typeof DashboardNotesRouteWithChildren
DashboardServersRoute: typeof DashboardServersRouteWithChildren
DashboardServicesRoute: typeof DashboardServicesRouteWithChildren
DashboardIndexRoute: typeof DashboardIndexRoute
}
const DashboardRouteChildren: DashboardRouteChildren = {
DashboardDomainsRoute: DashboardDomainsRouteWithChildren,
DashboardNotesRoute: DashboardNotesRouteWithChildren,
DashboardServersRoute: DashboardServersRouteWithChildren,
DashboardServicesRoute: DashboardServicesRouteWithChildren,
DashboardIndexRoute: DashboardIndexRoute,
}
const DashboardRouteWithChildren = DashboardRoute._addFileChildren(
DashboardRouteChildren,
)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
DashboardRoute: DashboardRouteWithChildren,
LoginRoute: LoginRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()

View file

@ -46,7 +46,7 @@ function RootComponent() {
disableTransitionOnChange
storageKey="vite-ui-theme"
>
<div className="grid grid-rows-[auto_1fr] h-svh">
<div className="grid min-h-svh grid-rows-[auto_1fr] bg-background">
<Header />
<Outlet />
</div>

View file

@ -0,0 +1,106 @@
import { Button } from "@minmon/ui/components/button";
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 { queryClient, trpc, trpcClient } from "@/utils/trpc";
export const Route = createFileRoute("/dashboard/domains/new")({
component: RouteComponent,
});
type DomainFormValues = {
serviceId: string;
name: string;
expectedServerIp: string;
remarks: string;
};
function RouteComponent() {
const navigate = useNavigate();
const services = useQuery(trpc.domains.serviceOptions.queryOptions());
const createDomain = useMutation({
mutationFn: async (values: DomainFormValues) =>
trpcClient.domains.create.mutate({
serviceId: values.serviceId,
name: values.name,
expectedServerIp: values.expectedServerIp || undefined,
remarks: values.remarks || null,
}),
onSuccess: async (created) => {
toast.success("Domain created");
await queryClient.invalidateQueries();
navigate({ to: "/dashboard/domains/$domainId", params: { domainId: created.id } });
},
onError: (error) => toast.error(error.message),
});
const defaultValues: DomainFormValues = {
serviceId: "",
name: "",
expectedServerIp: "",
remarks: "",
};
const form = useForm({
defaultValues,
onSubmit: async ({ value }) => {
await createDomain.mutateAsync(value);
},
});
if (services.isLoading) {
return <QueryStateCard title="Loading services" description="Fetching service options for the domain form." />;
}
if (services.isError || !services.data) {
return <QueryStateCard title="Service options unavailable" description="Create a service before adding domains." />;
}
return (
<PageSection title="Add domain" description="Create a domain or subdomain attached to a service.">
<form
className="grid gap-4"
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
>
<FormCard
title="Domain details"
description="Expected server IP will default from the selected service's server when omitted."
footer={
<>
<Button type="button" variant="ghost" onClick={() => navigate({ to: "/dashboard/domains" })}>
Cancel
</Button>
<form.Subscribe selector={(state) => ({ isSubmitting: state.isSubmitting })}>
{({ isSubmitting }) => <Button type="submit">{isSubmitting ? "Saving..." : "Save domain"}</Button>}
</form.Subscribe>
</>
}
>
<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>
<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)} placeholder="dashboard.minmon.dev" />}</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)} placeholder="203.0.113.10" />}</form.Field>
</div>
<form.Field name="remarks">{(field) => <TextAreaField label="Remarks" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} />}</form.Field>
</FormCard>
</form>
</PageSection>
);
}

View file

@ -0,0 +1,137 @@
import { Button } from "@minmon/ui/components/button";
import { StatusBadge } from "@minmon/ui/components/status-badge";
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 { queryClient, trpc, trpcClient } from "@/utils/trpc";
export const Route = createFileRoute("/dashboard/domains")({
component: RouteComponent,
});
function dnsStatusVariant(status: "MATCH" | "MISMATCH" | "UNRESOLVED") {
if (status === "MATCH") return "success" as const;
if (status === "MISMATCH") return "warning" as const;
return "destructive" as const;
}
function RouteComponent() {
const domains = useQuery(trpc.domains.list.queryOptions());
const services = useQuery(trpc.services.list.queryOptions());
const removeDomain = useMutation({
mutationFn: async (id: string) => trpcClient.domains.delete.mutate({ id }),
onSuccess: async () => {
toast.success("Domain deleted");
await queryClient.invalidateQueries();
},
onError: (error) => toast.error(error.message),
});
const checkDns = useMutation({
mutationFn: async (id: string) => trpcClient.domains.check.mutate({ id }),
onSuccess: async () => {
toast.success("DNS check completed");
await queryClient.invalidateQueries();
},
onError: (error) => toast.error(error.message),
});
if (domains.isLoading || services.isLoading) {
return <QueryStateCard title="Loading domains" description="Fetching domain records and service references." />;
}
if (domains.isError || services.isError || !domains.data || !services.data) {
return <QueryStateCard title="Domains unavailable" description="The domain list could not be loaded." />;
}
const serviceMap = new Map(services.data.map((item) => [item.id, item.name]));
return (
<PageSection
title="Domains"
description="Manage service domains, expected IPs, and DNS resolution checks."
action={
<Link to="/dashboard/domains/new">
<Button>Add domain</Button>
</Link>
}
>
<ResourceListCard
title="Domain list"
description="Use the Check DNS action to resolve A records and compare them with the expected server IP."
columns={[
{
key: "name",
header: "Domain",
render: (item: (typeof domains.data)[number]) => (
<div className="grid gap-1">
<Link to="/dashboard/domains/$domainId" params={{ domainId: item.id }} className="font-medium hover:underline">
{item.name}
</Link>
<span className="text-xs text-muted-foreground">{item.remarks || "No remarks"}</span>
</div>
),
},
{
key: "service",
header: "Service",
render: (item: (typeof domains.data)[number]) => serviceMap.get(item.serviceId) || item.serviceId,
},
{
key: "expectedServerIp",
header: "Expected IP",
render: (item: (typeof domains.data)[number]) => item.expectedServerIp,
},
{
key: "lastResolvedIp",
header: "Last resolved IP",
render: (item: (typeof domains.data)[number]) => item.lastResolvedIp || "-",
},
{
key: "status",
header: "DNS status",
className: "w-36",
render: (item: (typeof domains.data)[number]) => (
<StatusBadge variant={dnsStatusVariant(item.resolutionStatus)}>{item.resolutionStatus}</StatusBadge>
),
},
{
key: "actions",
header: "Actions",
className: "w-44",
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>
),
},
]}
items={domains.data}
emptyTitle="No domains added"
emptyDescription="Create a domain after adding a service."
/>
</PageSection>
);
}

View file

@ -0,0 +1,169 @@
import { StatusBadge } from "@minmon/ui/components/status-badge";
import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router";
import {
PageSection,
QueryStateCard,
ResourceListCard,
SectionLinks,
SummaryCard,
} from "@/components/dashboard-ui";
import { trpc } from "@/utils/trpc";
export const Route = createFileRoute("/dashboard/")({
component: RouteComponent,
});
function RouteComponent() {
const summary = useQuery(trpc.dashboard.summary.queryOptions());
if (summary.isLoading) {
return (
<QueryStateCard
title="Loading dashboard summary"
description="Fetching current counts, DNS check results, and recent notes."
/>
);
}
if (summary.isError || !summary.data) {
return (
<QueryStateCard
title="Dashboard summary unavailable"
description="The overview could not be loaded from the protected dashboard API."
/>
);
}
const { totals, domainChecks, inactiveServices, recentNotes, recentServiceIssues } = summary.data;
return (
<div className="grid gap-6">
<PageSection
title="Dashboard"
description="Summary of servers, services, domains, DNS results, and maintenance items."
>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<SummaryCard
label="Total servers"
value={String(totals.servers)}
hint="All managed private servers currently recorded in the system."
/>
<SummaryCard
label="Total services"
value={String(totals.services)}
hint="Applications or services attached to managed servers."
status={
inactiveServices > 0
? { label: `${inactiveServices} inactive/down`, variant: "warning" }
: { label: "All active", variant: "success" }
}
/>
<SummaryCard
label="Total domains"
value={String(totals.domains)}
hint="Domains and subdomains attached to services."
/>
<SummaryCard
label="DNS match results"
value={String(domainChecks.match)}
hint={`${domainChecks.mismatch} mismatch · ${domainChecks.unresolved} unresolved/error`}
status={
domainChecks.mismatch || domainChecks.unresolved
? { label: "Needs review", variant: "warning" }
: { label: "Healthy", variant: "success" }
}
/>
</div>
</PageSection>
<PageSection title="Areas" description="Open each section to manage data and review detail pages.">
<SectionLinks
items={[
{ label: "Servers", to: "/dashboard/servers", detail: "Create, edit, delete, and inspect server records." },
{ label: "Services", to: "/dashboard/services", detail: "Manage services running on each server." },
{ label: "Domains", to: "/dashboard/domains", detail: "Check DNS and compare resolved IPs against expected IPs." },
{ label: "Notes", to: "/dashboard/notes", detail: "Write documentation and operational notes per server." },
]}
/>
</PageSection>
<PageSection title="Recent service issues" description="Services currently marked down or under maintenance.">
<ResourceListCard
title="Inactive or down services"
description="These services affect the dashboard summary and should be reviewed first."
columns={[
{
key: "name",
header: "Service",
render: (item: (typeof recentServiceIssues)[number]) => (
<Link
to="/dashboard/services/$serviceId"
params={{ serviceId: item.id }}
className="font-medium hover:underline"
>
{item.name}
</Link>
),
},
{
key: "serverName",
header: "Server",
render: (item: (typeof recentServiceIssues)[number]) => item.serverName,
},
{
key: "status",
header: "Status",
className: "w-40",
render: (item: (typeof recentServiceIssues)[number]) => (
<StatusBadge variant={item.status === "down" ? "destructive" : "warning"}>
{item.status}
</StatusBadge>
),
},
]}
items={recentServiceIssues}
emptyTitle="No inactive services"
emptyDescription="All services are currently marked active."
/>
</PageSection>
<PageSection title="Recent notes" description="Latest documentation updates that may help during demos or maintenance.">
<ResourceListCard
title="Recently updated notes"
description="Recent server notes and internal documentation entries."
columns={[
{
key: "title",
header: "Title",
render: (item: (typeof recentNotes)[number]) => (
<Link
to="/dashboard/notes/$noteId"
params={{ noteId: item.id }}
className="font-medium hover:underline"
>
{item.title}
</Link>
),
},
{
key: "category",
header: "Category",
render: (item: (typeof recentNotes)[number]) => item.category || "-",
},
{
key: "updatedAt",
header: "Updated",
render: (item: (typeof recentNotes)[number]) =>
item.updatedAt ? new Date(item.updatedAt).toLocaleString() : "-",
},
]}
items={recentNotes}
emptyTitle="No recent notes"
emptyDescription="Create a note from the Notes section to start documenting servers."
/>
</PageSection>
</div>
);
}

View file

@ -0,0 +1,106 @@
import { Button } from "@minmon/ui/components/button";
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 { queryClient, trpc, trpcClient } from "@/utils/trpc";
export const Route = createFileRoute("/dashboard/notes/new")({
component: RouteComponent,
});
type NoteFormValues = {
serverId: string;
title: string;
category: string;
content: string;
};
function RouteComponent() {
const navigate = useNavigate();
const servers = useQuery(trpc.servers.list.queryOptions());
const createNote = useMutation({
mutationFn: async (values: NoteFormValues) =>
trpcClient.notes.create.mutate({
serverId: values.serverId,
title: values.title,
category: values.category || null,
content: values.content,
}),
onSuccess: async (created) => {
toast.success("Note created");
await queryClient.invalidateQueries();
navigate({ to: "/dashboard/notes/$noteId", params: { noteId: created.id } });
},
onError: (error) => toast.error(error.message),
});
const defaultValues: NoteFormValues = {
serverId: "",
title: "",
category: "general",
content: "",
};
const form = useForm({
defaultValues,
onSubmit: async ({ value }) => {
await createNote.mutateAsync(value);
},
});
if (servers.isLoading) {
return <QueryStateCard title="Loading servers" description="Fetching server options for the note form." />;
}
if (servers.isError || !servers.data) {
return <QueryStateCard title="Server options unavailable" description="Add or load servers before creating notes." />;
}
return (
<PageSection title="New note" description="Add documentation or operational notes for a server.">
<form
className="grid gap-4"
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
>
<FormCard
title="Note details"
description="Each note belongs to one server."
footer={
<>
<Button type="button" variant="ghost" onClick={() => navigate({ to: "/dashboard/notes" })}>
Cancel
</Button>
<form.Subscribe selector={(state) => ({ isSubmitting: state.isSubmitting })}>
{({ isSubmitting }) => <Button type="submit">{isSubmitting ? "Saving..." : "Save note"}</Button>}
</form.Subscribe>
</>
}
>
<form.Field name="serverId">
{(field) => (
<TextField
label="Server ID"
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(", ")}`}
/>
)}
</form.Field>
<div className="grid gap-4 md:grid-cols-2">
<form.Field name="title">{(field) => <TextField label="Title" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} />}</form.Field>
<form.Field name="category">{(field) => <TextField label="Category" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} />}</form.Field>
</div>
<form.Field name="content">{(field) => <TextAreaField label="Content" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} className="min-h-40" />}</form.Field>
</FormCard>
</form>
</PageSection>
);
}

View file

@ -0,0 +1,103 @@
import { Button } from "@minmon/ui/components/button";
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 { queryClient, trpc, trpcClient } from "@/utils/trpc";
export const Route = createFileRoute("/dashboard/notes")({
component: RouteComponent,
});
function RouteComponent() {
const notes = useQuery(trpc.notes.list.queryOptions());
const servers = useQuery(trpc.servers.list.queryOptions());
const removeNote = useMutation({
mutationFn: async (id: string) => trpcClient.notes.delete.mutate({ id }),
onSuccess: async () => {
toast.success("Note deleted");
await queryClient.invalidateQueries();
},
onError: (error) => toast.error(error.message),
});
if (notes.isLoading || servers.isLoading) {
return <QueryStateCard title="Loading notes" description="Fetching server documentation and internal notes." />;
}
if (notes.isError || servers.isError || !notes.data || !servers.data) {
return <QueryStateCard title="Notes unavailable" description="The note list could not be loaded." />;
}
const serverMap = new Map(servers.data.map((item) => [item.id, item.name]));
return (
<PageSection
title="Notes"
description="Manage documentation, incident notes, and internal reminders for each server."
action={
<Link to="/dashboard/notes/new">
<Button>New note</Button>
</Link>
}
>
<ResourceListCard
title="Server notes"
description="Each note belongs to one server and acts as lightweight internal documentation."
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: "server",
header: "Server",
render: (item: (typeof notes.data)[number]) => serverMap.get(item.serverId) || item.serverId,
},
{
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(),
},
{
key: "actions",
header: "Actions",
className: "w-28",
render: (item: (typeof notes.data)[number]) => (
<Button
type="button"
variant="ghost"
className="h-auto px-0 text-red-600 hover:text-red-700"
disabled={removeNote.isPending}
onClick={() => {
if (confirm(`Delete note \"${item.title}\"?`)) {
removeNote.mutate(item.id);
}
}}
>
Delete
</Button>
),
},
]}
items={notes.data}
emptyTitle="No notes yet"
emptyDescription="Add a note to document maintenance, incidents, or general server information."
/>
</PageSection>
);
}

View file

@ -0,0 +1,190 @@
import { Button } from "@minmon/ui/components/button";
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 { queryClient, trpcClient } from "@/utils/trpc";
export const Route = createFileRoute("/dashboard/servers/new")({
component: RouteComponent,
});
type ServerFormValues = {
name: string;
primaryIpAddress: string;
secondaryIpAddress: string;
operatingSystem: string;
location: string;
provider: string;
description: string;
status: "active" | "maintenance" | "inactive";
};
function RouteComponent() {
const navigate = useNavigate();
const createServer = useMutation({
mutationFn: async (values: ServerFormValues) =>
trpcClient.servers.create.mutate({
...values,
secondaryIpAddress: values.secondaryIpAddress || null,
operatingSystem: values.operatingSystem || null,
location: values.location || null,
provider: values.provider || null,
description: values.description || null,
}),
onSuccess: async (created) => {
toast.success("Server created");
await queryClient.invalidateQueries();
navigate({ to: "/dashboard/servers/$serverId", params: { serverId: created.id } });
},
onError: (error) => {
toast.error(error.message);
},
});
const defaultValues: ServerFormValues = {
name: "",
primaryIpAddress: "",
secondaryIpAddress: "",
operatingSystem: "",
location: "",
provider: "",
description: "",
status: "active",
};
const form = useForm({
defaultValues,
onSubmit: async ({ value }) => {
await createServer.mutateAsync(value);
},
});
return (
<PageSection title="Add server" description="Create a new managed server record.">
<form
className="grid gap-4"
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
>
<FormCard
title="Server details"
description="Server name and primary IP are required."
footer={
<>
<Button type="button" variant="ghost" onClick={() => navigate({ to: "/dashboard/servers" })}>
Cancel
</Button>
<form.Subscribe selector={(state) => ({ isSubmitting: state.isSubmitting })}>
{({ isSubmitting }) => <Button type="submit">{isSubmitting ? "Saving..." : "Save server"}</Button>}
</form.Subscribe>
</>
}
>
<div className="grid gap-4 md:grid-cols-2">
<form.Field name="name">
{(field) => (
<TextField
label="Server name"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="Production VPS"
/>
)}
</form.Field>
<form.Field name="primaryIpAddress">
{(field) => (
<TextField
label="Primary IP address"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="203.0.113.10"
/>
)}
</form.Field>
<form.Field name="secondaryIpAddress">
{(field) => (
<TextField
label="Secondary IP address"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="203.0.113.11"
/>
)}
</form.Field>
<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)}
placeholder="Ubuntu 24.04 LTS"
/>
)}
</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)}
placeholder="Singapore"
/>
)}
</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)}
placeholder="DigitalOcean"
/>
)}
</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")}
placeholder="active"
/>
)}
</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)}
placeholder="Describe the purpose of this server."
/>
)}
</form.Field>
</FormCard>
</form>
</PageSection>
);
}

View file

@ -0,0 +1,126 @@
import { Button } from "@minmon/ui/components/button";
import { StatusBadge } from "@minmon/ui/components/status-badge";
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 { queryClient, trpc, trpcClient } from "@/utils/trpc";
export const Route = createFileRoute("/dashboard/servers")({
component: RouteComponent,
});
function serverStatusVariant(status: "active" | "maintenance" | "inactive") {
if (status === "active") return "success" as const;
if (status === "maintenance") return "warning" as const;
return "neutral" as const;
}
function RouteComponent() {
const servers = useQuery(trpc.servers.list.queryOptions());
const removeServer = useMutation({
mutationFn: async (id: string) => trpcClient.servers.delete.mutate({ id }),
onSuccess: async () => {
toast.success("Server deleted");
await queryClient.invalidateQueries();
},
onError: (error) => {
toast.error(error.message);
},
});
if (servers.isLoading) {
return (
<QueryStateCard
title="Loading servers"
description="Fetching the current server inventory from the protected dashboard API."
/>
);
}
if (servers.isError || !servers.data) {
return (
<QueryStateCard
title="Servers unavailable"
description="The server list could not be loaded right now."
/>
);
}
return (
<PageSection
title="Servers"
description="Manage server inventory, IP addresses, operating systems, and current status."
action={
<Link to="/dashboard/servers/new">
<Button>Add server</Button>
</Link>
}
>
<ResourceListCard
title="Server list"
description="Each server can have many services and notes. Deleting a server also removes its related records."
columns={[
{
key: "name",
header: "Server",
render: (item: (typeof servers.data)[number]) => (
<div className="grid gap-1">
<Link
to="/dashboard/servers/$serverId"
params={{ serverId: item.id }}
className="font-medium hover:underline"
>
{item.name}
</Link>
<span className="text-xs text-muted-foreground">{item.description || "No description"}</span>
</div>
),
},
{
key: "primaryIpAddress",
header: "Primary IP",
render: (item: (typeof servers.data)[number]) => item.primaryIpAddress,
},
{
key: "location",
header: "Location / Provider",
render: (item: (typeof servers.data)[number]) => [item.location, item.provider].filter(Boolean).join(" / ") || "-",
},
{
key: "status",
header: "Status",
className: "w-32",
render: (item: (typeof servers.data)[number]) => (
<StatusBadge variant={serverStatusVariant(item.status)}>{item.status}</StatusBadge>
),
},
{
key: "actions",
header: "Actions",
className: "w-28",
render: (item: (typeof servers.data)[number]) => (
<Button
type="button"
variant="ghost"
className="h-auto px-0 text-red-600 hover:text-red-700"
disabled={removeServer.isPending}
onClick={() => {
if (confirm(`Delete server \"${item.name}\" and all related services, domains, and notes?`)) {
removeServer.mutate(item.id);
}
}}
>
Delete
</Button>
),
},
]}
items={servers.data}
emptyTitle="No servers added"
emptyDescription="Add your first server to start managing services, notes, and domains."
/>
</PageSection>
);
}

View file

@ -0,0 +1,122 @@
import { Button } from "@minmon/ui/components/button";
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 { queryClient, trpc, trpcClient } from "@/utils/trpc";
export const Route = createFileRoute("/dashboard/services/new")({
component: RouteComponent,
});
type ServiceFormValues = {
serverId: string;
name: string;
type: "web_app" | "mail" | "database" | "api" | "proxy" | "other";
internalPort: string;
externalPort: string;
protocol: "http" | "https" | "tcp" | "udp" | "other";
status: "active" | "down" | "maintenance";
description: string;
};
function RouteComponent() {
const navigate = useNavigate();
const servers = useQuery(trpc.servers.list.queryOptions());
const createService = useMutation({
mutationFn: async (values: ServiceFormValues) =>
trpcClient.services.create.mutate({
serverId: values.serverId,
name: values.name,
type: values.type,
internalPort: values.internalPort ? Number(values.internalPort) : null,
externalPort: values.externalPort ? Number(values.externalPort) : null,
protocol: values.protocol,
status: values.status,
description: values.description || null,
}),
onSuccess: async (created) => {
toast.success("Service created");
await queryClient.invalidateQueries();
navigate({ to: "/dashboard/services/$serviceId", params: { serviceId: created.id } });
},
onError: (error) => toast.error(error.message),
});
const defaultValues: ServiceFormValues = {
serverId: "",
name: "",
type: "web_app",
internalPort: "",
externalPort: "",
protocol: "http",
status: "active",
description: "",
};
const form = useForm({
defaultValues,
onSubmit: async ({ value }) => {
await createService.mutateAsync(value);
},
});
if (servers.isLoading) {
return <QueryStateCard title="Loading servers" description="Fetching server options for the service form." />;
}
if (servers.isError || !servers.data) {
return <QueryStateCard title="Server options unavailable" description="Add or load servers before creating a service." />;
}
return (
<PageSection title="Add service" description="Create a new service under an existing server.">
<form
className="grid gap-4"
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
>
<FormCard
title="Service details"
description="A service must belong to a server."
footer={
<>
<Button type="button" variant="ghost" onClick={() => navigate({ to: "/dashboard/services" })}>
Cancel
</Button>
<form.Subscribe selector={(state) => ({ isSubmitting: state.isSubmitting })}>
{({ isSubmitting }) => <Button type="submit">{isSubmitting ? "Saving..." : "Save service"}</Button>}
</form.Subscribe>
</>
}
>
<form.Field name="serverId">
{(field) => (
<TextField
label="Server ID"
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(", ")}`}
/>
)}
</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="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>
</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>
</form>
</PageSection>
);
}

View file

@ -0,0 +1,123 @@
import { Button } from "@minmon/ui/components/button";
import { StatusBadge } from "@minmon/ui/components/status-badge";
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 { queryClient, trpc, trpcClient } from "@/utils/trpc";
export const Route = createFileRoute("/dashboard/services")({
component: RouteComponent,
});
function serviceStatusVariant(status: "active" | "down" | "maintenance") {
if (status === "active") return "success" as const;
if (status === "down") return "destructive" as const;
return "warning" as const;
}
function RouteComponent() {
const services = useQuery(trpc.services.list.queryOptions());
const servers = useQuery(trpc.servers.list.queryOptions());
const removeService = useMutation({
mutationFn: async (id: string) => trpcClient.services.delete.mutate({ id }),
onSuccess: async () => {
toast.success("Service deleted");
await queryClient.invalidateQueries();
},
onError: (error) => toast.error(error.message),
});
if (services.isLoading || servers.isLoading) {
return <QueryStateCard title="Loading services" description="Fetching service records and server references." />;
}
if (services.isError || servers.isError || !services.data || !servers.data) {
return <QueryStateCard title="Services unavailable" description="The service list could not be loaded." />;
}
const serverMap = new Map(servers.data.map((item) => [item.id, item.name]));
return (
<PageSection
title="Services"
description="Manage services running on each server, including ports, protocol, and current status."
action={
<Link to="/dashboard/services/new">
<Button>Add service</Button>
</Link>
}
>
<ResourceListCard
title="Service list"
description="Each service belongs to exactly one server. Domains are attached to services."
columns={[
{
key: "name",
header: "Service",
render: (item: (typeof services.data)[number]) => (
<div className="grid gap-1">
<Link
to="/dashboard/services/$serviceId"
params={{ serviceId: item.id }}
className="font-medium hover:underline"
>
{item.name}
</Link>
<span className="text-xs text-muted-foreground">{item.description || "No description"}</span>
</div>
),
},
{
key: "server",
header: "Server",
render: (item: (typeof services.data)[number]) => serverMap.get(item.serverId) || item.serverId,
},
{
key: "ports",
header: "Ports",
render: (item: (typeof services.data)[number]) =>
`${item.internalPort ?? "-"} / ${item.externalPort ?? "-"}`,
},
{
key: "protocol",
header: "Protocol",
render: (item: (typeof services.data)[number]) => item.protocol,
},
{
key: "status",
header: "Status",
className: "w-32",
render: (item: (typeof services.data)[number]) => (
<StatusBadge variant={serviceStatusVariant(item.status)}>{item.status}</StatusBadge>
),
},
{
key: "actions",
header: "Actions",
className: "w-28",
render: (item: (typeof services.data)[number]) => (
<Button
type="button"
variant="ghost"
className="h-auto px-0 text-red-600 hover:text-red-700"
disabled={removeService.isPending}
onClick={() => {
if (confirm(`Delete service \"${item.name}\" and its related domains?`)) {
removeService.mutate(item.id);
}
}}
>
Delete
</Button>
),
},
]}
items={services.data}
emptyTitle="No services added"
emptyDescription="Create a service after adding at least one server."
/>
</PageSection>
);
}

View file

@ -1,33 +1,17 @@
import { useQuery } from "@tanstack/react-query";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router";
import { authClient } from "@/lib/auth-client";
import { trpc } from "@/utils/trpc";
import { DashboardShell } from "@/components/dashboard-shell";
import { requireSession } from "@/lib/protected-route";
export const Route = createFileRoute("/dashboard")({
component: RouteComponent,
beforeLoad: async () => {
const session = await authClient.getSession();
if (!session.data) {
redirect({
to: "/login",
throw: true,
});
}
return { session };
},
beforeLoad: requireSession,
});
function RouteComponent() {
const { session } = Route.useRouteContext();
const privateData = useQuery(trpc.privateData.queryOptions());
return (
<div>
<h1>Dashboard</h1>
<p>Welcome {session.data?.user.name}</p>
<p>API: {privateData.data?.message}</p>
</div>
<DashboardShell userName={session.data?.user.name} userEmail={session.data?.user.email} />
);
}