Compare commits

..

No commits in common. "a6edf804b4bd71d5d79ba7428be5f901759b55ea" and "afd60706c335e3128a418df7062efb8af172cd87" have entirely different histories.

62 changed files with 770 additions and 2654 deletions

View file

@ -1,23 +1,7 @@
import { Button } from "@minmon/ui/components/button"; import { Button } from "@minmon/ui/components/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@minmon/ui/components/sheet";
import { cn } from "@minmon/ui/lib/utils"; import { cn } from "@minmon/ui/lib/utils";
import { Link, Outlet, useRouterState } from "@tanstack/react-router"; import { Link, Outlet, useRouterState } from "@tanstack/react-router";
import { import { Activity, BookText, Globe, LayoutGrid, Server, Wrench } from "lucide-react";
Activity,
BookText,
Globe,
LayoutGrid,
MenuIcon,
Server,
Wrench,
} from "lucide-react";
import { useEffect, useState } from "react";
const dashboardLinks = [ const dashboardLinks = [
{ {
@ -52,48 +36,6 @@ const dashboardLinks = [
}, },
] as const; ] as const;
function isActive(pathname: string, to: string): boolean {
return to === "/dashboard"
? pathname === to
: pathname === to || pathname.startsWith(`${to}/`);
}
function NavItems({
pathname,
onNavigate,
}: {
pathname: string;
onNavigate?: () => void;
}) {
return (
<nav className="grid gap-1 p-2" aria-label="Dashboard sections">
{dashboardLinks.map(({ to, label, description, icon: Icon }) => {
const active = isActive(pathname, to);
return (
<Link
key={to}
to={to}
aria-current={active ? "page" : undefined}
onClick={onNavigate}
className={cn(
"grid gap-0.5 border px-3 py-2 text-left transition-colors outline-none focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50",
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>
);
}
export function DashboardShell({ export function DashboardShell({
userName, userName,
userEmail, userEmail,
@ -105,70 +47,25 @@ export function DashboardShell({
select: (state) => state.location.pathname, select: (state) => state.location.pathname,
}); });
const [mobileOpen, setMobileOpen] = useState(false);
useEffect(() => {
setMobileOpen(false);
}, [pathname]);
return ( return (
<div className="min-h-full bg-muted/20"> <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"> <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"> <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="grid gap-4 px-5 py-5 lg:grid-cols-[1.4fr_0.6fr] lg:px-6">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-3 lg:hidden">
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label="Open navigation menu"
>
<MenuIcon className="size-4" />
</Button>
}
/>
<SheetContent side="left" className="p-0">
<SheetHeader className="border-b px-4 py-3">
<SheetTitle className="flex items-center gap-2">
<Activity className="size-4" />
Navigation
</SheetTitle>
</SheetHeader>
<NavItems
pathname={pathname}
onNavigate={() => setMobileOpen(false)}
/>
</SheetContent>
</Sheet>
<p className="text-[11px] uppercase tracking-[0.24em] text-muted-foreground"> <p className="text-[11px] uppercase tracking-[0.24em] text-muted-foreground">
Server Management Dashboard Server Management Dashboard
</p> </p>
</div> <h1 className="text-2xl font-semibold tracking-tight">Operations overview for demos and day-to-day admin tasks</h1>
<p className="hidden text-[11px] uppercase tracking-[0.24em] text-muted-foreground lg:block">
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"> <p className="max-w-2xl text-sm text-muted-foreground">
Use this area to review monitored assets, inspect detail Use this area to review monitored assets, inspect detail states, and stage CRUD forms that can connect to tRPC routers as they are added.
states, currently used to monitor my homelab setup.
</p> </p>
</div> </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 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> <div>
<p className="text-[11px] uppercase tracking-[0.16em] text-muted-foreground"> <p className="text-[11px] uppercase tracking-[0.16em] text-muted-foreground">Signed in as</p>
Signed in as <p className="mt-1 text-sm font-medium">{userName ?? "Administrator"}</p>
</p> <p className="text-xs text-muted-foreground">{userEmail ?? "admin@minmon.local"}</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>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Link to="/dashboard/servers/new"> <Link to="/dashboard/servers/new">
@ -185,14 +82,35 @@ export function DashboardShell({
</section> </section>
<div className="grid gap-6 lg:grid-cols-[240px_minmax(0,1fr)]"> <div className="grid gap-6 lg:grid-cols-[240px_minmax(0,1fr)]">
<aside className="hidden h-fit border bg-card lg:block"> <aside className="h-fit border bg-card">
<div className="border-b px-4 py-3"> <div className="border-b px-4 py-3">
<div className="flex items-center gap-2 text-sm font-medium"> <div className="flex items-center gap-2 text-sm font-medium">
<Activity className="size-4" /> <Activity className="size-4" />
Navigation Navigation
</div> </div>
</div> </div>
<NavItems pathname={pathname} /> <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> </aside>
<main className="min-w-0"> <main className="min-w-0">

View file

@ -9,13 +9,6 @@ import {
} from "@minmon/ui/components/card"; } from "@minmon/ui/components/card";
import { Input } from "@minmon/ui/components/input"; import { Input } from "@minmon/ui/components/input";
import { Label } from "@minmon/ui/components/label"; 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 { StatusBadge } from "@minmon/ui/components/status-badge";
import { import {
Table, Table,
@ -28,8 +21,8 @@ import {
} from "@minmon/ui/components/table"; } from "@minmon/ui/components/table";
import { Textarea } from "@minmon/ui/components/textarea"; import { Textarea } from "@minmon/ui/components/textarea";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { Check, ChevronRight, Trash2 } from "lucide-react"; import { ChevronRight } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react"; import type { ReactNode } from "react";
type BadgeVariant = "neutral" | "success" | "warning" | "destructive" | "info"; type BadgeVariant = "neutral" | "success" | "warning" | "destructive" | "info";
@ -73,7 +66,7 @@ export function SummaryCard({
); );
} }
export function ResourceListCard<TItem extends { id: string }>({ export function ResourceListCard<TItem>({
title, title,
description, description,
columns, columns,
@ -89,7 +82,7 @@ export function ResourceListCard<TItem extends { id: string }>({
emptyDescription: string; emptyDescription: string;
}) { }) {
return ( return (
<Card className="py-0 pt-4 gap-0"> <Card className="gap-0">
<CardHeader className="border-b"> <CardHeader className="border-b">
<CardTitle>{title}</CardTitle> <CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription> <CardDescription>{description}</CardDescription>
@ -108,8 +101,8 @@ export function ResourceListCard<TItem extends { id: string }>({
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{items.map((item) => ( {items.map((item, index) => (
<TableRow key={item.id}> <TableRow key={index}>
{columns.map((column) => ( {columns.map((column) => (
<TableCell key={column.key} className={column.className}> <TableCell key={column.key} className={column.className}>
{column.render(item)} {column.render(item)}
@ -178,246 +171,61 @@ export function FormCard({
); );
} }
type FieldError = string | { message?: string } | null | undefined;
function normalizeFieldErrors(errors?: FieldError[]): string[] {
if (!errors?.length) return [];
return errors
.map((err) => {
if (!err) return null;
if (typeof err === "string") return err;
return err.message ?? null;
})
.filter((msg): msg is string => Boolean(msg && msg.trim()));
}
export function FieldGroup({ export function FieldGroup({
label, label,
htmlFor, htmlFor,
description, description,
errors,
children, children,
}: { }: {
label: string; label: string;
htmlFor: string; htmlFor: string;
description?: string; description?: string;
errors?: FieldError[];
children: ReactNode; children: ReactNode;
}) { }) {
const messages = normalizeFieldErrors(errors);
const hasError = messages.length > 0;
const descriptionId = description ? `${htmlFor}-description` : undefined;
const errorId = hasError ? `${htmlFor}-error` : undefined;
return ( return (
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor={htmlFor}>{label}</Label> <Label htmlFor={htmlFor}>{label}</Label>
{children} {children}
{description ? ( {description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
<p id={descriptionId} className="text-xs text-muted-foreground">
{description}
</p>
) : null}
{hasError ? (
<p id={errorId} className="text-xs text-destructive" role="alert">
{messages.join(", ")}
</p>
) : null}
</div> </div>
); );
} }
function describedBy(...ids: (string | undefined)[]): string | undefined { export function TextField(props: React.ComponentProps<typeof Input> & { label: string; description?: string }) {
const filtered = ids.filter((id): id is string => Boolean(id)); const { label, description, id, ...inputProps } = props;
return filtered.length ? filtered.join(" ") : undefined;
}
export function TextField(
props: React.ComponentProps<typeof Input> & {
label: string;
description?: string;
errors?: FieldError[];
},
) {
const { label, description, errors, id, ...inputProps } = props;
const fieldId = id ?? inputProps.name ?? label;
const messages = normalizeFieldErrors(errors);
const hasError = messages.length > 0;
const descriptionId = description ? `${fieldId}-description` : undefined;
const errorId = hasError ? `${fieldId}-error` : undefined;
return ( return (
<FieldGroup label={label} htmlFor={fieldId} description={description} errors={errors}> <FieldGroup label={label} htmlFor={id ?? inputProps.name ?? label} description={description}>
<Input <Input id={id ?? inputProps.name ?? label} {...inputProps} />
id={fieldId}
aria-invalid={hasError || undefined}
aria-describedby={describedBy(descriptionId, errorId)}
{...inputProps}
/>
</FieldGroup> </FieldGroup>
); );
} }
export function TextAreaField( export function TextAreaField(
props: React.ComponentProps<typeof Textarea> & { props: React.ComponentProps<typeof Textarea> & { label: string; description?: string },
label: string;
description?: string;
errors?: FieldError[];
},
) { ) {
const { label, description, errors, id, ...textareaProps } = props; const { label, description, id, ...textareaProps } = props;
const fieldId = id ?? textareaProps.name ?? label;
const messages = normalizeFieldErrors(errors);
const hasError = messages.length > 0;
const descriptionId = description ? `${fieldId}-description` : undefined;
const errorId = hasError ? `${fieldId}-error` : undefined;
return ( return (
<FieldGroup label={label} htmlFor={fieldId} description={description} errors={errors}> <FieldGroup label={label} htmlFor={id ?? textareaProps.name ?? label} description={description}>
<Textarea <Textarea id={id ?? textareaProps.name ?? label} {...textareaProps} />
id={fieldId}
aria-invalid={hasError || undefined}
aria-describedby={describedBy(descriptionId, errorId)}
{...textareaProps}
/>
</FieldGroup> </FieldGroup>
); );
} }
export function SelectField({
label,
description,
errors,
id,
name,
value,
onValueChange,
onBlur,
placeholder,
options,
}: {
label: string;
description?: string;
errors?: FieldError[];
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);
const messages = normalizeFieldErrors(errors);
const hasError = messages.length > 0;
const descriptionId = description ? `${fieldId}-description` : undefined;
const errorId = hasError ? `${fieldId}-error` : undefined;
return (
<FieldGroup label={label} htmlFor={fieldId} description={description} errors={errors}>
<Select name={name} value={value} onValueChange={(nextValue) => onValueChange(nextValue ?? "")}>
<SelectTrigger
id={fieldId}
className="w-full"
onBlur={onBlur}
aria-invalid={hasError || undefined}
aria-describedby={describedBy(descriptionId, errorId)}
>
<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" : "ghost"}
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({ export function EmptyState({
title, title,
description, description,
action, action,
icon,
className, className,
}: { }: {
title: string; title: string;
description: string; description: string;
action?: ReactNode; action?: ReactNode;
icon?: ReactNode;
className?: string; className?: string;
}) { }) {
return ( return (
<div className={"border px-4 py-10 text-center " + (className ?? "")}> <div className={"border px-4 py-8 text-center " + (className ?? "")}>
{icon ? (
<div className="mx-auto mb-3 flex size-10 items-center justify-center rounded-full bg-muted text-muted-foreground">
{icon}
</div>
) : null}
<p className="text-sm font-medium">{title}</p> <p className="text-sm font-medium">{title}</p>
<p className="mx-auto mt-2 max-w-lg text-sm text-muted-foreground">{description}</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} {action ? <div className="mt-4 flex justify-center">{action}</div> : null}

View file

@ -4,13 +4,31 @@ import { ModeToggle } from "./mode-toggle";
import UserMenu from "./user-menu"; import UserMenu from "./user-menu";
export default function Header() { 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 ( return (
<div className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/75"> <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="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"> <div className="flex items-center gap-6">
<Link to="/dashboard" className="text-sm font-semibold tracking-[0.2em] uppercase"> <Link to="/" className="text-sm font-semibold tracking-[0.2em] uppercase">
minmon minmon
</Link> </Link>
<nav className="flex flex-wrap gap-4 text-sm text-muted-foreground">
{links.map(({ to, label }) => {
return (
<Link key={to} to={to} activeProps={{ className: "text-foreground" }}>
{label}
</Link>
);
})}
</nav>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ModeToggle /> <ModeToggle />

View file

@ -1,12 +1,12 @@
import { Button } from "@minmon/ui/components/button"; 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 { useForm } from "@tanstack/react-form";
import { useNavigate } from "@tanstack/react-router"; import { Link, useNavigate } from "@tanstack/react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import z from "zod"; import z from "zod";
import { TextField } from "@/components/dashboard-ui";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { formatErrorMessage } from "@/utils/errors";
import Loader from "./loader"; import Loader from "./loader";
@ -35,7 +35,7 @@ export default function SignInForm() {
toast.success("Sign in successful"); toast.success("Sign in successful");
}, },
onError: (error) => { onError: (error) => {
toast.error(formatErrorMessage(error, "Could not sign in")); toast.error(error.error.message || error.error.statusText);
}, },
}, },
); );
@ -53,11 +53,10 @@ export default function SignInForm() {
} }
return ( return (
<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="mx-auto mt-10 grid w-full max-w-md gap-6 border bg-card p-6">
<div className="grid w-full max-w-md gap-6 border bg-card p-6">
<div className="space-y-2 text-center"> <div className="space-y-2 text-center">
<p className="text-[11px] uppercase tracking-[0.24em] text-muted-foreground">Admin Access</p> <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> <h1 className="text-3xl font-bold tracking-tight">Sign in to minmon</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Use your administrator account to open the protected monitoring dashboard. Use your administrator account to open the protected monitoring dashboard.
</p> </p>
@ -70,51 +69,59 @@ export default function SignInForm() {
form.handleSubmit(); form.handleSubmit();
}} }}
className="space-y-4" className="space-y-4"
noValidate
> >
<div>
<form.Field name="email"> <form.Field name="email">
{(field) => ( {(field) => (
<TextField <div className="space-y-2">
label="Email" <Label htmlFor={field.name}>Email</Label>
<Input
id={field.name} id={field.name}
name={field.name} name={field.name}
type="email" type="email"
autoComplete="email"
value={field.state.value} value={field.state.value}
onBlur={field.handleBlur} onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)} onChange={(e) => field.handleChange(e.target.value)}
errors={field.state.meta.errors}
/> />
{field.state.meta.errors.map((error) => (
<p key={error?.message} className="text-xs text-red-500">
{error?.message}
</p>
))}
</div>
)} )}
</form.Field> </form.Field>
</div>
<div>
<form.Field name="password"> <form.Field name="password">
{(field) => ( {(field) => (
<TextField <div className="space-y-2">
label="Password" <Label htmlFor={field.name}>Password</Label>
<Input
id={field.name} id={field.name}
name={field.name} name={field.name}
type="password" type="password"
autoComplete="current-password"
value={field.state.value} value={field.state.value}
onBlur={field.handleBlur} onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)} onChange={(e) => field.handleChange(e.target.value)}
errors={field.state.meta.errors}
/> />
{field.state.meta.errors.map((error) => (
<p key={error?.message} className="text-xs text-red-500">
{error?.message}
</p>
))}
</div>
)} )}
</form.Field> </form.Field>
</div>
<form.Subscribe <form.Subscribe
selector={(state) => ({ canSubmit: state.canSubmit, isSubmitting: state.isSubmitting })} selector={(state) => ({ canSubmit: state.canSubmit, isSubmitting: state.isSubmitting })}
> >
{({ canSubmit, isSubmitting }) => ( {({ canSubmit, isSubmitting }) => (
<Button <Button type="submit" className="w-full" disabled={!canSubmit || isSubmitting}>
type="submit" {isSubmitting ? "Submitting..." : "Sign In"}
className="w-full"
disabled={!canSubmit}
loading={isSubmitting}
>
Sign In
</Button> </Button>
)} )}
</form.Subscribe> </form.Subscribe>
@ -122,7 +129,9 @@ export default function SignInForm() {
<div className="grid gap-2 border-t pt-4 text-center text-xs text-muted-foreground"> <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> <p>Student demo flow uses administrator sign-in only.</p>
</div> <Link to="/" className="text-primary underline-offset-4 hover:underline">
Return to homepage
</Link>
</div> </div>
</div> </div>
); );

View file

@ -1,12 +1,12 @@
import { Button } from "@minmon/ui/components/button"; 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 { useForm } from "@tanstack/react-form";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import z from "zod"; import z from "zod";
import { TextField } from "@/components/dashboard-ui";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { formatErrorMessage } from "@/utils/errors";
import Loader from "./loader"; import Loader from "./loader";
@ -37,7 +37,7 @@ export default function SignUpForm({ onSwitchToSignIn }: { onSwitchToSignIn: ()
toast.success("Sign up successful"); toast.success("Sign up successful");
}, },
onError: (error) => { onError: (error) => {
toast.error(formatErrorMessage(error, "Could not sign up")); toast.error(error.error.message || error.error.statusText);
}, },
}, },
); );
@ -66,73 +66,92 @@ export default function SignUpForm({ onSwitchToSignIn }: { onSwitchToSignIn: ()
form.handleSubmit(); form.handleSubmit();
}} }}
className="space-y-4" className="space-y-4"
noValidate
> >
<div>
<form.Field name="name"> <form.Field name="name">
{(field) => ( {(field) => (
<TextField <div className="space-y-2">
label="Name" <Label htmlFor={field.name}>Name</Label>
<Input
id={field.name} id={field.name}
name={field.name} name={field.name}
autoComplete="name"
value={field.state.value} value={field.state.value}
onBlur={field.handleBlur} onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)} onChange={(e) => field.handleChange(e.target.value)}
errors={field.state.meta.errors}
/> />
{field.state.meta.errors.map((error) => (
<p key={error?.message} className="text-red-500">
{error?.message}
</p>
))}
</div>
)} )}
</form.Field> </form.Field>
</div>
<div>
<form.Field name="email"> <form.Field name="email">
{(field) => ( {(field) => (
<TextField <div className="space-y-2">
label="Email" <Label htmlFor={field.name}>Email</Label>
<Input
id={field.name} id={field.name}
name={field.name} name={field.name}
type="email" type="email"
autoComplete="email"
value={field.state.value} value={field.state.value}
onBlur={field.handleBlur} onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)} onChange={(e) => field.handleChange(e.target.value)}
errors={field.state.meta.errors}
/> />
{field.state.meta.errors.map((error) => (
<p key={error?.message} className="text-red-500">
{error?.message}
</p>
))}
</div>
)} )}
</form.Field> </form.Field>
</div>
<div>
<form.Field name="password"> <form.Field name="password">
{(field) => ( {(field) => (
<TextField <div className="space-y-2">
label="Password" <Label htmlFor={field.name}>Password</Label>
<Input
id={field.name} id={field.name}
name={field.name} name={field.name}
type="password" type="password"
autoComplete="new-password"
value={field.state.value} value={field.state.value}
onBlur={field.handleBlur} onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)} onChange={(e) => field.handleChange(e.target.value)}
errors={field.state.meta.errors}
/> />
{field.state.meta.errors.map((error) => (
<p key={error?.message} className="text-red-500">
{error?.message}
</p>
))}
</div>
)} )}
</form.Field> </form.Field>
</div>
<form.Subscribe <form.Subscribe
selector={(state) => ({ canSubmit: state.canSubmit, isSubmitting: state.isSubmitting })} selector={(state) => ({ canSubmit: state.canSubmit, isSubmitting: state.isSubmitting })}
> >
{({ canSubmit, isSubmitting }) => ( {({ canSubmit, isSubmitting }) => (
<Button <Button type="submit" className="w-full" disabled={!canSubmit || isSubmitting}>
type="submit" {isSubmitting ? "Submitting..." : "Sign Up"}
className="w-full"
disabled={!canSubmit}
loading={isSubmitting}
>
Sign Up
</Button> </Button>
)} )}
</form.Subscribe> </form.Subscribe>
</form> </form>
<div className="mt-4 text-center"> <div className="mt-4 text-center">
<Button variant="link" onClick={onSwitchToSignIn}> <Button
variant="link"
onClick={onSwitchToSignIn}
className="text-indigo-600 hover:text-indigo-800"
>
Already have an account? Sign In Already have an account? Sign In
</Button> </Button>
</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 DashboardServersRouteImport } from './routes/dashboard.servers'
import { Route as DashboardNotesRouteImport } from './routes/dashboard.notes' import { Route as DashboardNotesRouteImport } from './routes/dashboard.notes'
import { Route as DashboardDomainsRouteImport } from './routes/dashboard.domains' import { Route as DashboardDomainsRouteImport } from './routes/dashboard.domains'
import { Route as DashboardServicesNewRouteImport } from './routes/dashboard.services_.new' import { Route as DashboardServicesNewRouteImport } from './routes/dashboard.services.new'
import { Route as DashboardServicesServiceIdRouteImport } from './routes/dashboard.services_.$serviceId' import { Route as DashboardServicesServiceIdRouteImport } from './routes/dashboard.services.$serviceId'
import { Route as DashboardServersNewRouteImport } from './routes/dashboard.servers_.new' import { Route as DashboardServersNewRouteImport } from './routes/dashboard.servers.new'
import { Route as DashboardServersServerIdRouteImport } from './routes/dashboard.servers_.$serverId' import { Route as DashboardServersServerIdRouteImport } from './routes/dashboard.servers.$serverId'
import { Route as DashboardNotesNewRouteImport } from './routes/dashboard.notes_.new' import { Route as DashboardNotesNewRouteImport } from './routes/dashboard.notes.new'
import { Route as DashboardNotesNoteIdRouteImport } from './routes/dashboard.notes_.$noteId' import { Route as DashboardNotesNoteIdRouteImport } from './routes/dashboard.notes.$noteId'
import { Route as DashboardDomainsNewRouteImport } from './routes/dashboard.domains_.new' import { Route as DashboardDomainsNewRouteImport } from './routes/dashboard.domains.new'
import { Route as DashboardDomainsDomainIdRouteImport } from './routes/dashboard.domains_.$domainId' import { Route as DashboardDomainsDomainIdRouteImport } from './routes/dashboard.domains.$domainId'
const LoginRoute = LoginRouteImport.update({ const LoginRoute = LoginRouteImport.update({
id: '/login', id: '/login',
@ -67,57 +67,57 @@ const DashboardDomainsRoute = DashboardDomainsRouteImport.update({
getParentRoute: () => DashboardRoute, getParentRoute: () => DashboardRoute,
} as any) } as any)
const DashboardServicesNewRoute = DashboardServicesNewRouteImport.update({ const DashboardServicesNewRoute = DashboardServicesNewRouteImport.update({
id: '/services_/new', id: '/new',
path: '/services/new', path: '/new',
getParentRoute: () => DashboardRoute, getParentRoute: () => DashboardServicesRoute,
} as any) } as any)
const DashboardServicesServiceIdRoute = const DashboardServicesServiceIdRoute =
DashboardServicesServiceIdRouteImport.update({ DashboardServicesServiceIdRouteImport.update({
id: '/services_/$serviceId', id: '/$serviceId',
path: '/services/$serviceId', path: '/$serviceId',
getParentRoute: () => DashboardRoute, getParentRoute: () => DashboardServicesRoute,
} as any) } as any)
const DashboardServersNewRoute = DashboardServersNewRouteImport.update({ const DashboardServersNewRoute = DashboardServersNewRouteImport.update({
id: '/servers_/new', id: '/new',
path: '/servers/new', path: '/new',
getParentRoute: () => DashboardRoute, getParentRoute: () => DashboardServersRoute,
} as any) } as any)
const DashboardServersServerIdRoute = const DashboardServersServerIdRoute =
DashboardServersServerIdRouteImport.update({ DashboardServersServerIdRouteImport.update({
id: '/servers_/$serverId', id: '/$serverId',
path: '/servers/$serverId', path: '/$serverId',
getParentRoute: () => DashboardRoute, getParentRoute: () => DashboardServersRoute,
} as any) } as any)
const DashboardNotesNewRoute = DashboardNotesNewRouteImport.update({ const DashboardNotesNewRoute = DashboardNotesNewRouteImport.update({
id: '/notes_/new', id: '/new',
path: '/notes/new', path: '/new',
getParentRoute: () => DashboardRoute, getParentRoute: () => DashboardNotesRoute,
} as any) } as any)
const DashboardNotesNoteIdRoute = DashboardNotesNoteIdRouteImport.update({ const DashboardNotesNoteIdRoute = DashboardNotesNoteIdRouteImport.update({
id: '/notes_/$noteId', id: '/$noteId',
path: '/notes/$noteId', path: '/$noteId',
getParentRoute: () => DashboardRoute, getParentRoute: () => DashboardNotesRoute,
} as any) } as any)
const DashboardDomainsNewRoute = DashboardDomainsNewRouteImport.update({ const DashboardDomainsNewRoute = DashboardDomainsNewRouteImport.update({
id: '/domains_/new', id: '/new',
path: '/domains/new', path: '/new',
getParentRoute: () => DashboardRoute, getParentRoute: () => DashboardDomainsRoute,
} as any) } as any)
const DashboardDomainsDomainIdRoute = const DashboardDomainsDomainIdRoute =
DashboardDomainsDomainIdRouteImport.update({ DashboardDomainsDomainIdRouteImport.update({
id: '/domains_/$domainId', id: '/$domainId',
path: '/domains/$domainId', path: '/$domainId',
getParentRoute: () => DashboardRoute, getParentRoute: () => DashboardDomainsRoute,
} as any) } as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/dashboard': typeof DashboardRouteWithChildren '/dashboard': typeof DashboardRouteWithChildren
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/dashboard/domains': typeof DashboardDomainsRoute '/dashboard/domains': typeof DashboardDomainsRouteWithChildren
'/dashboard/notes': typeof DashboardNotesRoute '/dashboard/notes': typeof DashboardNotesRouteWithChildren
'/dashboard/servers': typeof DashboardServersRoute '/dashboard/servers': typeof DashboardServersRouteWithChildren
'/dashboard/services': typeof DashboardServicesRoute '/dashboard/services': typeof DashboardServicesRouteWithChildren
'/dashboard/': typeof DashboardIndexRoute '/dashboard/': typeof DashboardIndexRoute
'/dashboard/domains/$domainId': typeof DashboardDomainsDomainIdRoute '/dashboard/domains/$domainId': typeof DashboardDomainsDomainIdRoute
'/dashboard/domains/new': typeof DashboardDomainsNewRoute '/dashboard/domains/new': typeof DashboardDomainsNewRoute
@ -131,10 +131,10 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/dashboard/domains': typeof DashboardDomainsRoute '/dashboard/domains': typeof DashboardDomainsRouteWithChildren
'/dashboard/notes': typeof DashboardNotesRoute '/dashboard/notes': typeof DashboardNotesRouteWithChildren
'/dashboard/servers': typeof DashboardServersRoute '/dashboard/servers': typeof DashboardServersRouteWithChildren
'/dashboard/services': typeof DashboardServicesRoute '/dashboard/services': typeof DashboardServicesRouteWithChildren
'/dashboard': typeof DashboardIndexRoute '/dashboard': typeof DashboardIndexRoute
'/dashboard/domains/$domainId': typeof DashboardDomainsDomainIdRoute '/dashboard/domains/$domainId': typeof DashboardDomainsDomainIdRoute
'/dashboard/domains/new': typeof DashboardDomainsNewRoute '/dashboard/domains/new': typeof DashboardDomainsNewRoute
@ -150,19 +150,19 @@ export interface FileRoutesById {
'/': typeof IndexRoute '/': typeof IndexRoute
'/dashboard': typeof DashboardRouteWithChildren '/dashboard': typeof DashboardRouteWithChildren
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/dashboard/domains': typeof DashboardDomainsRoute '/dashboard/domains': typeof DashboardDomainsRouteWithChildren
'/dashboard/notes': typeof DashboardNotesRoute '/dashboard/notes': typeof DashboardNotesRouteWithChildren
'/dashboard/servers': typeof DashboardServersRoute '/dashboard/servers': typeof DashboardServersRouteWithChildren
'/dashboard/services': typeof DashboardServicesRoute '/dashboard/services': typeof DashboardServicesRouteWithChildren
'/dashboard/': typeof DashboardIndexRoute '/dashboard/': typeof DashboardIndexRoute
'/dashboard/domains_/$domainId': typeof DashboardDomainsDomainIdRoute '/dashboard/domains/$domainId': typeof DashboardDomainsDomainIdRoute
'/dashboard/domains_/new': typeof DashboardDomainsNewRoute '/dashboard/domains/new': typeof DashboardDomainsNewRoute
'/dashboard/notes_/$noteId': typeof DashboardNotesNoteIdRoute '/dashboard/notes/$noteId': typeof DashboardNotesNoteIdRoute
'/dashboard/notes_/new': typeof DashboardNotesNewRoute '/dashboard/notes/new': typeof DashboardNotesNewRoute
'/dashboard/servers_/$serverId': typeof DashboardServersServerIdRoute '/dashboard/servers/$serverId': typeof DashboardServersServerIdRoute
'/dashboard/servers_/new': typeof DashboardServersNewRoute '/dashboard/servers/new': typeof DashboardServersNewRoute
'/dashboard/services_/$serviceId': typeof DashboardServicesServiceIdRoute '/dashboard/services/$serviceId': typeof DashboardServicesServiceIdRoute
'/dashboard/services_/new': typeof DashboardServicesNewRoute '/dashboard/services/new': typeof DashboardServicesNewRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
@ -210,14 +210,14 @@ export interface FileRouteTypes {
| '/dashboard/servers' | '/dashboard/servers'
| '/dashboard/services' | '/dashboard/services'
| '/dashboard/' | '/dashboard/'
| '/dashboard/domains_/$domainId' | '/dashboard/domains/$domainId'
| '/dashboard/domains_/new' | '/dashboard/domains/new'
| '/dashboard/notes_/$noteId' | '/dashboard/notes/$noteId'
| '/dashboard/notes_/new' | '/dashboard/notes/new'
| '/dashboard/servers_/$serverId' | '/dashboard/servers/$serverId'
| '/dashboard/servers_/new' | '/dashboard/servers/new'
| '/dashboard/services_/$serviceId' | '/dashboard/services/$serviceId'
| '/dashboard/services_/new' | '/dashboard/services/new'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
@ -284,97 +284,134 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DashboardDomainsRouteImport preLoaderRoute: typeof DashboardDomainsRouteImport
parentRoute: typeof DashboardRoute parentRoute: typeof DashboardRoute
} }
'/dashboard/services_/new': { '/dashboard/services/new': {
id: '/dashboard/services_/new' id: '/dashboard/services/new'
path: '/services/new' path: '/new'
fullPath: '/dashboard/services/new' fullPath: '/dashboard/services/new'
preLoaderRoute: typeof DashboardServicesNewRouteImport preLoaderRoute: typeof DashboardServicesNewRouteImport
parentRoute: typeof DashboardRoute parentRoute: typeof DashboardServicesRoute
} }
'/dashboard/services_/$serviceId': { '/dashboard/services/$serviceId': {
id: '/dashboard/services_/$serviceId' id: '/dashboard/services/$serviceId'
path: '/services/$serviceId' path: '/$serviceId'
fullPath: '/dashboard/services/$serviceId' fullPath: '/dashboard/services/$serviceId'
preLoaderRoute: typeof DashboardServicesServiceIdRouteImport preLoaderRoute: typeof DashboardServicesServiceIdRouteImport
parentRoute: typeof DashboardRoute parentRoute: typeof DashboardServicesRoute
} }
'/dashboard/servers_/new': { '/dashboard/servers/new': {
id: '/dashboard/servers_/new' id: '/dashboard/servers/new'
path: '/servers/new' path: '/new'
fullPath: '/dashboard/servers/new' fullPath: '/dashboard/servers/new'
preLoaderRoute: typeof DashboardServersNewRouteImport preLoaderRoute: typeof DashboardServersNewRouteImport
parentRoute: typeof DashboardRoute parentRoute: typeof DashboardServersRoute
} }
'/dashboard/servers_/$serverId': { '/dashboard/servers/$serverId': {
id: '/dashboard/servers_/$serverId' id: '/dashboard/servers/$serverId'
path: '/servers/$serverId' path: '/$serverId'
fullPath: '/dashboard/servers/$serverId' fullPath: '/dashboard/servers/$serverId'
preLoaderRoute: typeof DashboardServersServerIdRouteImport preLoaderRoute: typeof DashboardServersServerIdRouteImport
parentRoute: typeof DashboardRoute parentRoute: typeof DashboardServersRoute
} }
'/dashboard/notes_/new': { '/dashboard/notes/new': {
id: '/dashboard/notes_/new' id: '/dashboard/notes/new'
path: '/notes/new' path: '/new'
fullPath: '/dashboard/notes/new' fullPath: '/dashboard/notes/new'
preLoaderRoute: typeof DashboardNotesNewRouteImport preLoaderRoute: typeof DashboardNotesNewRouteImport
parentRoute: typeof DashboardRoute parentRoute: typeof DashboardNotesRoute
} }
'/dashboard/notes_/$noteId': { '/dashboard/notes/$noteId': {
id: '/dashboard/notes_/$noteId' id: '/dashboard/notes/$noteId'
path: '/notes/$noteId' path: '/$noteId'
fullPath: '/dashboard/notes/$noteId' fullPath: '/dashboard/notes/$noteId'
preLoaderRoute: typeof DashboardNotesNoteIdRouteImport preLoaderRoute: typeof DashboardNotesNoteIdRouteImport
parentRoute: typeof DashboardRoute parentRoute: typeof DashboardNotesRoute
} }
'/dashboard/domains_/new': { '/dashboard/domains/new': {
id: '/dashboard/domains_/new' id: '/dashboard/domains/new'
path: '/domains/new' path: '/new'
fullPath: '/dashboard/domains/new' fullPath: '/dashboard/domains/new'
preLoaderRoute: typeof DashboardDomainsNewRouteImport preLoaderRoute: typeof DashboardDomainsNewRouteImport
parentRoute: typeof DashboardRoute parentRoute: typeof DashboardDomainsRoute
} }
'/dashboard/domains_/$domainId': { '/dashboard/domains/$domainId': {
id: '/dashboard/domains_/$domainId' id: '/dashboard/domains/$domainId'
path: '/domains/$domainId' path: '/$domainId'
fullPath: '/dashboard/domains/$domainId' fullPath: '/dashboard/domains/$domainId'
preLoaderRoute: typeof DashboardDomainsDomainIdRouteImport preLoaderRoute: typeof DashboardDomainsDomainIdRouteImport
parentRoute: typeof DashboardRoute parentRoute: typeof DashboardDomainsRoute
} }
} }
} }
interface DashboardRouteChildren { interface DashboardDomainsRouteChildren {
DashboardDomainsRoute: typeof DashboardDomainsRoute
DashboardNotesRoute: typeof DashboardNotesRoute
DashboardServersRoute: typeof DashboardServersRoute
DashboardServicesRoute: typeof DashboardServicesRoute
DashboardIndexRoute: typeof DashboardIndexRoute
DashboardDomainsDomainIdRoute: typeof DashboardDomainsDomainIdRoute DashboardDomainsDomainIdRoute: typeof DashboardDomainsDomainIdRoute
DashboardDomainsNewRoute: typeof DashboardDomainsNewRoute DashboardDomainsNewRoute: typeof DashboardDomainsNewRoute
}
const DashboardDomainsRouteChildren: DashboardDomainsRouteChildren = {
DashboardDomainsDomainIdRoute: DashboardDomainsDomainIdRoute,
DashboardDomainsNewRoute: DashboardDomainsNewRoute,
}
const DashboardDomainsRouteWithChildren =
DashboardDomainsRoute._addFileChildren(DashboardDomainsRouteChildren)
interface DashboardNotesRouteChildren {
DashboardNotesNoteIdRoute: typeof DashboardNotesNoteIdRoute DashboardNotesNoteIdRoute: typeof DashboardNotesNoteIdRoute
DashboardNotesNewRoute: typeof DashboardNotesNewRoute DashboardNotesNewRoute: typeof DashboardNotesNewRoute
}
const DashboardNotesRouteChildren: DashboardNotesRouteChildren = {
DashboardNotesNoteIdRoute: DashboardNotesNoteIdRoute,
DashboardNotesNewRoute: DashboardNotesNewRoute,
}
const DashboardNotesRouteWithChildren = DashboardNotesRoute._addFileChildren(
DashboardNotesRouteChildren,
)
interface DashboardServersRouteChildren {
DashboardServersServerIdRoute: typeof DashboardServersServerIdRoute DashboardServersServerIdRoute: typeof DashboardServersServerIdRoute
DashboardServersNewRoute: typeof DashboardServersNewRoute DashboardServersNewRoute: typeof DashboardServersNewRoute
}
const DashboardServersRouteChildren: DashboardServersRouteChildren = {
DashboardServersServerIdRoute: DashboardServersServerIdRoute,
DashboardServersNewRoute: DashboardServersNewRoute,
}
const DashboardServersRouteWithChildren =
DashboardServersRoute._addFileChildren(DashboardServersRouteChildren)
interface DashboardServicesRouteChildren {
DashboardServicesServiceIdRoute: typeof DashboardServicesServiceIdRoute DashboardServicesServiceIdRoute: typeof DashboardServicesServiceIdRoute
DashboardServicesNewRoute: typeof DashboardServicesNewRoute DashboardServicesNewRoute: typeof DashboardServicesNewRoute
} }
const DashboardRouteChildren: DashboardRouteChildren = { const DashboardServicesRouteChildren: DashboardServicesRouteChildren = {
DashboardDomainsRoute: DashboardDomainsRoute,
DashboardNotesRoute: DashboardNotesRoute,
DashboardServersRoute: DashboardServersRoute,
DashboardServicesRoute: DashboardServicesRoute,
DashboardIndexRoute: DashboardIndexRoute,
DashboardDomainsDomainIdRoute: DashboardDomainsDomainIdRoute,
DashboardDomainsNewRoute: DashboardDomainsNewRoute,
DashboardNotesNoteIdRoute: DashboardNotesNoteIdRoute,
DashboardNotesNewRoute: DashboardNotesNewRoute,
DashboardServersServerIdRoute: DashboardServersServerIdRoute,
DashboardServersNewRoute: DashboardServersNewRoute,
DashboardServicesServiceIdRoute: DashboardServicesServiceIdRoute, DashboardServicesServiceIdRoute: DashboardServicesServiceIdRoute,
DashboardServicesNewRoute: DashboardServicesNewRoute, 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( const DashboardRouteWithChildren = DashboardRoute._addFileChildren(
DashboardRouteChildren, DashboardRouteChildren,
) )

View file

@ -1,14 +1,13 @@
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 { Button } from "@minmon/ui/components/button";
import { StatusBadge } from "@minmon/ui/components/status-badge"; import { StatusBadge } from "@minmon/ui/components/status-badge";
import { useForm } from "@tanstack/react-form"; import { useForm } from "@tanstack/react-form";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEffect } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { DetailCard, FormCard, PageSection, QueryStateCard, TextAreaField, TextField } from "@/components/dashboard-ui";
import { queryClient, trpc, trpcClient } from "@/utils/trpc";
export const Route = createFileRoute("/dashboard/domains_/$domainId")({ export const Route = createFileRoute("/dashboard/domains/$domainId")({
component: RouteComponent, component: RouteComponent,
}); });
@ -41,17 +40,8 @@ function RouteComponent() {
remarks: values.remarks || null, remarks: values.remarks || null,
}), }),
onSuccess: async () => { onSuccess: async () => {
try {
await trpcClient.domains.check.mutate({ id: domainId });
toast.success("Domain updated and DNS re-checked");
} catch (error) {
toast.success("Domain updated"); toast.success("Domain updated");
toast.error( await queryClient.invalidateQueries();
error instanceof Error ? `DNS check failed: ${error.message}` : "DNS check failed",
);
}
await queryClient.invalidateQueries({ queryKey: trpc.domains.byId.queryKey({ id: domainId }) });
await queryClient.invalidateQueries({ queryKey: trpc.domains.list.queryKey() });
}, },
onError: (error) => toast.error(error.message), onError: (error) => toast.error(error.message),
}); });
@ -59,48 +49,32 @@ function RouteComponent() {
mutationFn: async () => trpcClient.domains.check.mutate({ id: domainId }), mutationFn: async () => trpcClient.domains.check.mutate({ id: domainId }),
onSuccess: async () => { onSuccess: async () => {
toast.success("DNS check completed"); toast.success("DNS check completed");
await queryClient.invalidateQueries({ queryKey: trpc.domains.byId.queryKey({ id: domainId }) }); await queryClient.invalidateQueries();
await queryClient.invalidateQueries({ queryKey: trpc.domains.list.queryKey() });
}, },
onError: (error) => toast.error(error.message), onError: (error) => toast.error(error.message),
}); });
const form = useForm({ if (domain.isLoading || services.isLoading) {
defaultValues: { return <QueryStateCard title="Loading domain" description="Fetching the selected domain details." />;
serviceId: "",
name: "",
expectedServerIp: "",
remarks: "",
} satisfies DomainFormValues,
onSubmit: async ({ value }) => {
await updateDomain.mutateAsync(value);
},
});
useEffect(() => {
if (!domain.data) {
return;
} }
form.reset({ if (domain.isError || services.isError || !domain.data || !services.data) {
return <QueryStateCard title="Domain unavailable" description="The requested domain could not be loaded." />;
}
const defaultValues: DomainFormValues = {
serviceId: domain.data.serviceId, serviceId: domain.data.serviceId,
name: domain.data.name, name: domain.data.name,
expectedServerIp: domain.data.expectedServerIp, expectedServerIp: domain.data.expectedServerIp,
remarks: domain.data.remarks ?? "", remarks: domain.data.remarks ?? "",
};
const form = useForm({
defaultValues,
onSubmit: async ({ value }) => {
await updateDomain.mutateAsync(value);
},
}); });
}, [domain.data, form]);
if (!domain.data || !services.data) {
if (domain.isError || services.isError) {
return <QueryStateCard title="Domain unavailable" description="The requested domain could not be loaded." />;
}
return <QueryStateCard title="Loading domain" description="Fetching the selected domain details." />;
}
const serviceOptions = services.data.map((item) => ({
value: item.id,
label: `${item.name} (${item.id})`,
}));
return ( return (
<div className="grid gap-6"> <div className="grid gap-6">
@ -140,7 +114,7 @@ function RouteComponent() {
Check DNS Check DNS
</Button> </Button>
<form.Subscribe selector={(state) => ({ isSubmitting: state.isSubmitting })}> <form.Subscribe selector={(state) => ({ isSubmitting: state.isSubmitting })}>
{({ isSubmitting }) => <Button type="submit" loading={isSubmitting}>Save changes</Button>} {({ isSubmitting }) => <Button type="submit">{isSubmitting ? "Saving..." : "Save changes"}</Button>}
</form.Subscribe> </form.Subscribe>
</div> </div>
</> </>
@ -148,15 +122,13 @@ function RouteComponent() {
> >
<form.Field name="serviceId"> <form.Field name="serviceId">
{(field) => ( {(field) => (
<SelectField <TextField
label="Service" label="Service ID"
name={field.name} name={field.name}
value={field.state.value} value={field.state.value}
onBlur={field.handleBlur} onBlur={field.handleBlur}
onValueChange={field.handleChange} onChange={(e) => field.handleChange(e.target.value)}
description="Select the service this domain belongs to." description={`Available: ${services.data.map((item) => `${item.name} (${item.id})`).join(", ")}`}
placeholder="Select a service"
options={serviceOptions}
/> />
)} )}
</form.Field> </form.Field>

View file

@ -3,10 +3,10 @@ import { useForm } from "@tanstack/react-form";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { FormCard, PageSection, QueryStateCard, SelectField, TextAreaField, TextField } from "@/components/dashboard-ui"; import { FormCard, PageSection, QueryStateCard, TextAreaField, TextField } from "@/components/dashboard-ui";
import { queryClient, trpc, trpcClient } from "@/utils/trpc"; import { queryClient, trpc, trpcClient } from "@/utils/trpc";
export const Route = createFileRoute("/dashboard/domains_/new")({ export const Route = createFileRoute("/dashboard/domains/new")({
component: RouteComponent, component: RouteComponent,
}); });
@ -29,16 +29,8 @@ function RouteComponent() {
remarks: values.remarks || null, remarks: values.remarks || null,
}), }),
onSuccess: async (created) => { onSuccess: async (created) => {
try {
await trpcClient.domains.check.mutate({ id: created.id });
toast.success("Domain created and DNS checked");
} catch (error) {
toast.success("Domain created"); toast.success("Domain created");
toast.error( await queryClient.invalidateQueries();
error instanceof Error ? `DNS check failed: ${error.message}` : "DNS check failed",
);
}
await queryClient.invalidateQueries({ queryKey: trpc.domains.list.queryKey() });
navigate({ to: "/dashboard/domains/$domainId", params: { domainId: created.id } }); navigate({ to: "/dashboard/domains/$domainId", params: { domainId: created.id } });
}, },
onError: (error) => toast.error(error.message), onError: (error) => toast.error(error.message),
@ -66,11 +58,6 @@ function RouteComponent() {
return <QueryStateCard title="Service options unavailable" description="Create a service before adding domains." />; 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 ( return (
<PageSection title="Add domain" description="Create a domain or subdomain attached to a service."> <PageSection title="Add domain" description="Create a domain or subdomain attached to a service.">
<form <form
@ -90,22 +77,20 @@ function RouteComponent() {
Cancel Cancel
</Button> </Button>
<form.Subscribe selector={(state) => ({ isSubmitting: state.isSubmitting })}> <form.Subscribe selector={(state) => ({ isSubmitting: state.isSubmitting })}>
{({ isSubmitting }) => <Button type="submit" loading={isSubmitting}>Save domain</Button>} {({ isSubmitting }) => <Button type="submit">{isSubmitting ? "Saving..." : "Save domain"}</Button>}
</form.Subscribe> </form.Subscribe>
</> </>
} }
> >
<form.Field name="serviceId"> <form.Field name="serviceId">
{(field) => ( {(field) => (
<SelectField <TextField
label="Service" label="Service ID"
name={field.name} name={field.name}
value={field.state.value} value={field.state.value}
onBlur={field.handleBlur} onBlur={field.handleBlur}
onValueChange={field.handleChange} onChange={(e) => field.handleChange(e.target.value)}
description="Select the service this domain belongs to." description={`Available: ${services.data.map((item) => `${item.name} (${item.id})`).join(", ")}`}
placeholder="Select a service"
options={serviceOptions}
/> />
)} )}
</form.Field> </form.Field>

View file

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

View file

@ -2,12 +2,11 @@ import { Button } from "@minmon/ui/components/button";
import { useForm } from "@tanstack/react-form"; import { useForm } from "@tanstack/react-form";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEffect } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { DetailCard, FormCard, PageSection, QueryStateCard, SelectField, TextAreaField, TextField } from "@/components/dashboard-ui"; import { DetailCard, FormCard, PageSection, QueryStateCard, TextAreaField, TextField } from "@/components/dashboard-ui";
import { queryClient, trpc, trpcClient } from "@/utils/trpc"; import { queryClient, trpc, trpcClient } from "@/utils/trpc";
export const Route = createFileRoute("/dashboard/notes_/$noteId")({ export const Route = createFileRoute("/dashboard/notes/$noteId")({
component: RouteComponent, component: RouteComponent,
}); });
@ -40,11 +39,19 @@ function RouteComponent() {
onError: (error) => toast.error(error.message), onError: (error) => toast.error(error.message),
}); });
if (note.isLoading || servers.isLoading) {
return <QueryStateCard title="Loading note" description="Fetching the selected note details." />;
}
if (note.isError || servers.isError || !note.data || !servers.data) {
return <QueryStateCard title="Note unavailable" description="The requested note could not be loaded." />;
}
const defaultValues: NoteFormValues = { const defaultValues: NoteFormValues = {
serverId: "", serverId: note.data.serverId,
title: "", title: note.data.title,
category: "", category: note.data.category ?? "",
content: "", content: note.data.content,
}; };
const form = useForm({ const form = useForm({
@ -54,33 +61,6 @@ function RouteComponent() {
}, },
}); });
useEffect(() => {
if (!note.data) {
return;
}
form.reset({
serverId: note.data.serverId,
title: note.data.title,
category: note.data.category ?? "",
content: note.data.content,
});
}, [note.data, form]);
if (note.isLoading || servers.isLoading) {
return <QueryStateCard title="Loading note" description="Fetching the selected note details." />;
}
if (note.isError || servers.isError || !note.data || !servers.data) {
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 ( return (
<div className="grid gap-6"> <div className="grid gap-6">
<PageSection title={note.data.title} description="View and edit this note."> <PageSection title={note.data.title} description="View and edit this note.">
@ -88,7 +68,7 @@ function RouteComponent() {
title="Note summary" title="Note summary"
description="Current note metadata and content preview." description="Current note metadata and content preview."
rows={[ rows={[
{ label: "Server", value: serverMap.get(note.data.serverId) || note.data.serverId }, { label: "Server ID", value: note.data.serverId },
{ label: "Category", value: note.data.category || "-" }, { label: "Category", value: note.data.category || "-" },
{ label: "Updated", value: new Date(note.data.updatedAt).toLocaleString() }, { label: "Updated", value: new Date(note.data.updatedAt).toLocaleString() },
{ label: "Content", value: note.data.content }, { label: "Content", value: note.data.content },
@ -113,22 +93,20 @@ function RouteComponent() {
Back Back
</Button> </Button>
<form.Subscribe selector={(state) => ({ isSubmitting: state.isSubmitting })}> <form.Subscribe selector={(state) => ({ isSubmitting: state.isSubmitting })}>
{({ isSubmitting }) => <Button type="submit" loading={isSubmitting}>Save changes</Button>} {({ isSubmitting }) => <Button type="submit">{isSubmitting ? "Saving..." : "Save changes"}</Button>}
</form.Subscribe> </form.Subscribe>
</> </>
} }
> >
<form.Field name="serverId"> <form.Field name="serverId">
{(field) => ( {(field) => (
<SelectField <TextField
label="Server" label="Server ID"
name={field.name} name={field.name}
value={field.state.value} value={field.state.value}
onBlur={field.handleBlur} onBlur={field.handleBlur}
onValueChange={field.handleChange} onChange={(e) => field.handleChange(e.target.value)}
description="Select the server this note belongs to." description={`Available: ${servers.data.map((item) => `${item.name} (${item.id})`).join(", ")}`}
placeholder="Select a server"
options={serverOptions}
/> />
)} )}
</form.Field> </form.Field>

View file

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

View file

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

View file

@ -2,13 +2,12 @@ import { Button } from "@minmon/ui/components/button";
import { StatusBadge } from "@minmon/ui/components/status-badge"; import { StatusBadge } from "@minmon/ui/components/status-badge";
import { useForm } from "@tanstack/react-form"; import { useForm } from "@tanstack/react-form";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEffect } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { DetailCard, FormCard, PageSection, QueryStateCard, ResourceListCard, SelectField, TextAreaField, TextField } from "@/components/dashboard-ui"; import { DetailCard, FormCard, PageSection, QueryStateCard, TextAreaField, TextField } from "@/components/dashboard-ui";
import { queryClient, trpc, trpcClient } from "@/utils/trpc"; import { queryClient, trpc, trpcClient } from "@/utils/trpc";
export const Route = createFileRoute("/dashboard/servers_/$serverId")({ export const Route = createFileRoute("/dashboard/servers/$serverId")({
component: RouteComponent, component: RouteComponent,
}); });
@ -23,17 +22,10 @@ type ServerFormValues = {
status: "active" | "maintenance" | "inactive"; status: "active" | "maintenance" | "inactive";
}; };
const serverStatusOptions = [
{ value: "active", label: "Active" },
{ value: "maintenance", label: "Maintenance" },
{ value: "inactive", label: "Inactive" },
] as const;
function RouteComponent() { function RouteComponent() {
const { serverId } = Route.useParams(); const { serverId } = Route.useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const server = useQuery(trpc.servers.byId.queryOptions({ id: serverId })); const server = useQuery(trpc.servers.byId.queryOptions({ id: serverId }));
const notes = useQuery(trpc.notes.list.queryOptions({ serverId }));
const updateServer = useMutation({ const updateServer = useMutation({
mutationFn: async (values: ServerFormValues) => mutationFn: async (values: ServerFormValues) =>
@ -53,30 +45,15 @@ function RouteComponent() {
onError: (error) => toast.error(error.message), onError: (error) => toast.error(error.message),
}); });
const defaultValues: ServerFormValues = { if (server.isLoading) {
name: "", return <QueryStateCard title="Loading server" description="Fetching the selected server details." />;
primaryIpAddress: "",
secondaryIpAddress: "",
operatingSystem: "",
location: "",
provider: "",
description: "",
status: "active",
};
const form = useForm({
defaultValues,
onSubmit: async ({ value }) => {
await updateServer.mutateAsync(value);
},
});
useEffect(() => {
if (!server.data) {
return;
} }
form.reset({ if (server.isError || !server.data) {
return <QueryStateCard title="Server unavailable" description="The requested server could not be loaded." />;
}
const defaultValues: ServerFormValues = {
name: server.data.name, name: server.data.name,
primaryIpAddress: server.data.primaryIpAddress, primaryIpAddress: server.data.primaryIpAddress,
secondaryIpAddress: server.data.secondaryIpAddress ?? "", secondaryIpAddress: server.data.secondaryIpAddress ?? "",
@ -85,16 +62,14 @@ function RouteComponent() {
provider: server.data.provider ?? "", provider: server.data.provider ?? "",
description: server.data.description ?? "", description: server.data.description ?? "",
status: server.data.status, status: server.data.status,
};
const form = useForm({
defaultValues,
onSubmit: async ({ value }) => {
await updateServer.mutateAsync(value);
},
}); });
}, [server.data, form]);
if (server.isLoading) {
return <QueryStateCard title="Loading server" description="Fetching the selected server details." />;
}
if (server.isError || !server.data) {
return <QueryStateCard title="Server unavailable" description="The requested server could not be loaded." />;
}
return ( return (
<div className="grid gap-6"> <div className="grid gap-6">
@ -115,54 +90,6 @@ function RouteComponent() {
/> />
</PageSection> </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 <form
className="grid gap-4" className="grid gap-4"
onSubmit={(e) => { onSubmit={(e) => {
@ -180,7 +107,7 @@ function RouteComponent() {
Back Back
</Button> </Button>
<form.Subscribe selector={(state) => ({ isSubmitting: state.isSubmitting })}> <form.Subscribe selector={(state) => ({ isSubmitting: state.isSubmitting })}>
{({ isSubmitting }) => <Button type="submit" loading={isSubmitting}>Save changes</Button>} {({ isSubmitting }) => <Button type="submit">{isSubmitting ? "Saving..." : "Save changes"}</Button>}
</form.Subscribe> </form.Subscribe>
</> </>
} }
@ -192,7 +119,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="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="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="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) => <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> <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>
</div> </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> <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> </FormCard>

View file

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

View file

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

View file

@ -3,12 +3,11 @@ import { StatusBadge } from "@minmon/ui/components/status-badge";
import { useForm } from "@tanstack/react-form"; import { useForm } from "@tanstack/react-form";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEffect } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { DetailCard, FormCard, PageSection, QueryStateCard, SelectField, TextAreaField, TextField } from "@/components/dashboard-ui"; import { DetailCard, FormCard, PageSection, QueryStateCard, TextAreaField, TextField } from "@/components/dashboard-ui";
import { queryClient, trpc, trpcClient } from "@/utils/trpc"; import { queryClient, trpc, trpcClient } from "@/utils/trpc";
export const Route = createFileRoute("/dashboard/services_/$serviceId")({ export const Route = createFileRoute("/dashboard/services/$serviceId")({
component: RouteComponent, component: RouteComponent,
}); });
@ -23,29 +22,6 @@ type ServiceFormValues = {
description: string; 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() { function RouteComponent() {
const { serviceId } = Route.useParams(); const { serviceId } = Route.useParams();
const navigate = useNavigate(); const navigate = useNavigate();
@ -72,41 +48,6 @@ function RouteComponent() {
onError: (error) => toast.error(error.message), 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 updateService.mutateAsync(value);
},
});
useEffect(() => {
if (!service.data) {
return;
}
form.reset({
serverId: service.data.serverId,
name: service.data.name,
type: service.data.type,
internalPort: service.data.internalPort?.toString() ?? "",
externalPort: service.data.externalPort?.toString() ?? "",
protocol: service.data.protocol,
status: service.data.status,
description: service.data.description ?? "",
});
}, [service.data, form]);
if (service.isLoading || servers.isLoading) { if (service.isLoading || servers.isLoading) {
return <QueryStateCard title="Loading service" description="Fetching the selected service details." />; return <QueryStateCard title="Loading service" description="Fetching the selected service details." />;
} }
@ -115,10 +56,23 @@ function RouteComponent() {
return <QueryStateCard title="Service unavailable" description="The requested service could not be loaded." />; return <QueryStateCard title="Service unavailable" description="The requested service could not be loaded." />;
} }
const serverOptions = servers.data.map((item) => ({ const defaultValues: ServiceFormValues = {
value: item.id, serverId: service.data.serverId,
label: `${item.name} (${item.id})`, name: service.data.name,
})); type: service.data.type,
internalPort: service.data.internalPort?.toString() ?? "",
externalPort: service.data.externalPort?.toString() ?? "",
protocol: service.data.protocol,
status: service.data.status,
description: service.data.description ?? "",
};
const form = useForm({
defaultValues,
onSubmit: async ({ value }) => {
await updateService.mutateAsync(value);
},
});
return ( return (
<div className="grid gap-6"> <div className="grid gap-6">
@ -155,32 +109,30 @@ function RouteComponent() {
Back Back
</Button> </Button>
<form.Subscribe selector={(state) => ({ isSubmitting: state.isSubmitting })}> <form.Subscribe selector={(state) => ({ isSubmitting: state.isSubmitting })}>
{({ isSubmitting }) => <Button type="submit" loading={isSubmitting}>Save changes</Button>} {({ isSubmitting }) => <Button type="submit">{isSubmitting ? "Saving..." : "Save changes"}</Button>}
</form.Subscribe> </form.Subscribe>
</> </>
} }
> >
<form.Field name="serverId"> <form.Field name="serverId">
{(field) => ( {(field) => (
<SelectField <TextField
label="Server" label="Server ID"
name={field.name} name={field.name}
value={field.state.value} value={field.state.value}
onBlur={field.handleBlur} onBlur={field.handleBlur}
onValueChange={field.handleChange} onChange={(e) => field.handleChange(e.target.value)}
description="Select the server that owns this service." description={`Available: ${servers.data.map((item) => `${item.name} (${item.id})`).join(", ")}`}
placeholder="Select a server"
options={serverOptions}
/> />
)} )}
</form.Field> </form.Field>
<div className="grid gap-4 md:grid-cols-2"> <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="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) => <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="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="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="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) => <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="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) => <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> <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> </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> <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> </FormCard>

View file

@ -3,10 +3,10 @@ import { useForm } from "@tanstack/react-form";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { FormCard, PageSection, QueryStateCard, SelectField, TextAreaField, TextField } from "@/components/dashboard-ui"; import { FormCard, PageSection, QueryStateCard, TextAreaField, TextField } from "@/components/dashboard-ui";
import { queryClient, trpc, trpcClient } from "@/utils/trpc"; import { queryClient, trpc, trpcClient } from "@/utils/trpc";
export const Route = createFileRoute("/dashboard/services_/new")({ export const Route = createFileRoute("/dashboard/services/new")({
component: RouteComponent, component: RouteComponent,
}); });
@ -21,29 +21,6 @@ type ServiceFormValues = {
description: string; 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() { function RouteComponent() {
const navigate = useNavigate(); const navigate = useNavigate();
const servers = useQuery(trpc.servers.list.queryOptions()); const servers = useQuery(trpc.servers.list.queryOptions());
@ -93,11 +70,6 @@ function RouteComponent() {
return <QueryStateCard title="Server options unavailable" description="Add or load servers before creating a service." />; 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 ( return (
<PageSection title="Add service" description="Create a new service under an existing server."> <PageSection title="Add service" description="Create a new service under an existing server.">
<form <form
@ -117,32 +89,30 @@ function RouteComponent() {
Cancel Cancel
</Button> </Button>
<form.Subscribe selector={(state) => ({ isSubmitting: state.isSubmitting })}> <form.Subscribe selector={(state) => ({ isSubmitting: state.isSubmitting })}>
{({ isSubmitting }) => <Button type="submit" loading={isSubmitting}>Save service</Button>} {({ isSubmitting }) => <Button type="submit">{isSubmitting ? "Saving..." : "Save service"}</Button>}
</form.Subscribe> </form.Subscribe>
</> </>
} }
> >
<form.Field name="serverId"> <form.Field name="serverId">
{(field) => ( {(field) => (
<SelectField <TextField
label="Server" label="Server ID"
name={field.name} name={field.name}
value={field.state.value} value={field.state.value}
onBlur={field.handleBlur} onBlur={field.handleBlur}
onValueChange={field.handleChange} onChange={(e) => field.handleChange(e.target.value)}
description="Select the server that owns this service." description={`Available: ${servers.data.map((item) => `${item.name} (${item.id})`).join(", ")}`}
placeholder="Select a server"
options={serverOptions}
/> />
)} )}
</form.Field> </form.Field>
<div className="grid gap-4 md:grid-cols-2"> <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="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) => <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="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="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="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) => <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="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) => <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> <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> </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> <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> </FormCard>

View file

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

View file

@ -1,14 +1,51 @@
import { createFileRoute, redirect } from "@tanstack/react-router"; import { useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { authClient } from "@/lib/auth-client"; import { trpc } from "@/utils/trpc";
export const Route = createFileRoute("/")({ export const Route = createFileRoute("/")({
beforeLoad: async () => { component: HomeComponent,
const session = await authClient.getSession();
throw redirect({
to: session.data ? "/dashboard" : "/login",
});
},
component: () => null,
}); });
const TITLE_TEXT = `
`;
function HomeComponent() {
const healthCheck = useQuery(trpc.healthCheck.queryOptions());
return (
<div className="container mx-auto max-w-3xl px-4 py-2">
<pre className="overflow-x-auto font-mono text-sm">{TITLE_TEXT}</pre>
<div className="grid gap-6">
<section className="rounded-lg border p-4">
<h2 className="mb-2 font-medium">API Status</h2>
<div className="flex items-center gap-2">
<div
className={`h-2 w-2 rounded-full ${healthCheck.data ? "bg-green-500" : "bg-red-500"}`}
/>
<span className="text-sm text-muted-foreground">
{healthCheck.isLoading
? "Checking..."
: healthCheck.data
? "Connected"
: "Disconnected"}
</span>
</div>
</section>
</div>
</div>
);
}

View file

@ -1,25 +0,0 @@
type AnyError = {
message?: string;
error?: { message?: string; statusText?: string };
};
export function formatErrorMessage(
error: unknown,
fallback = "Something went wrong",
): string {
if (!error) return fallback;
if (typeof error === "string") return error;
const e = error as AnyError;
if (e.error && typeof e.error === "object") {
const inner = e.error.message?.trim() || e.error.statusText?.trim();
if (inner) return inner;
}
if (typeof e.message === "string" && e.message.trim()) {
return e.message.trim();
}
return fallback;
}

View file

@ -100,8 +100,8 @@
"@trpc/client": "catalog:", "@trpc/client": "catalog:",
"@trpc/server": "catalog:", "@trpc/server": "catalog:",
"dotenv": "catalog:", "dotenv": "catalog:",
"drizzle-orm": "catalog:", "drizzle-orm": "^0.45.1",
"effect": "catalog:", "effect": "^3.18.4",
"zod": "catalog:", "zod": "catalog:",
}, },
"devDependencies": { "devDependencies": {
@ -134,7 +134,7 @@
"@minmon/env": "workspace:*", "@minmon/env": "workspace:*",
"better-auth": "catalog:", "better-auth": "catalog:",
"dotenv": "catalog:", "dotenv": "catalog:",
"drizzle-orm": "catalog:", "drizzle-orm": "^0.45.1",
"pg": "^8.17.1", "pg": "^8.17.1",
"zod": "catalog:", "zod": "catalog:",
}, },
@ -191,8 +191,6 @@
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"better-auth": "1.5.5", "better-auth": "1.5.5",
"dotenv": "^17.2.2", "dotenv": "^17.2.2",
"drizzle-orm": "^0.45.1",
"effect": "^3.18.4",
"elysia": "^1.4.28", "elysia": "^1.4.28",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"typescript": "^6", "typescript": "^6",

View file

@ -1,37 +0,0 @@
flowchart LR
subgraph Trigger[Trigger]
A[Create / Update Domain]
B[Manual Check List / Detail]
end
subgraph UI[Web UI]
C[Submit form domain]
D[Call domains.check]
E[Invalidate query + toast]
end
subgraph API[tRPC domainsRouter]
F[Save / load domain]
G[Run domain check]
end
subgraph DNS[DNS Check]
H[Resolve IPv4 A record]
I{Expected IP found?}
end
subgraph Result[Persisted Status]
J[MATCH]
K[MISMATCH]
L[UNRESOLVED]
end
A --> C --> F --> D
B --> D
D --> G --> H --> I
I -- Ya --> J
I -- Tidak --> K
H -- Lookup gagal --> L
J --> E
K --> E
L --> E

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

View file

@ -1,61 +0,0 @@
classDiagram
direction LR
class Server {
+string id
+string name
+string primaryIpAddress
+string secondaryIpAddress
+string operatingSystem
+string location
+string provider
+string description
+server_status status
+Date createdAt
+Date updatedAt
}
class Service {
+string id
+string serverId
+string name
+service_type type
+int internalPort
+int externalPort
+service_protocol protocol
+service_status status
+string description
+Date createdAt
+Date updatedAt
}
class Domain {
+string id
+string serviceId
+string name
+string expectedServerIp
+string lastResolvedIp
+string[] lastResolvedIps
+domain_resolution_status resolutionStatus
+Date lastCheckedAt
+string remarks
+string lastCheckMessage
+Date createdAt
+Date updatedAt
}
class Catatan {
+string id
+string serverId
+string title
+string content
+string category
+Date createdAt
+Date updatedAt
}
Server "1" o-- "0..*" Service : memiliki
Server "1" o-- "0..*" Catatan : memiliki
Service "1" o-- "0..*" Domain : memiliki
note for Domain "resolutionStatus: MATCH, MISMATCH, atau UNRESOLVED"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

View file

@ -1,29 +0,0 @@
graph TD
Admin[Admin]
Auth[Auth + Protected Route]
UI[Dashboard Pages]
Query[TanStack Query]
TRPC[tRPC Client]
Router[Protected Routers]
DomainRouter[domainsRouter]
Checker[DNS Check Module]
DB[(PostgreSQL / Drizzle)]
DNS[node:dns resolve4]
Toast[Toast]
subgraph Client[Client Side]
Auth --> UI --> Query --> TRPC
UI --> Toast
end
subgraph Server[Server Side]
Router --> DB
Router --> DomainRouter
DomainRouter --> Checker
Checker --> DNS
Checker --> DB
end
Admin --> Auth
TRPC --> Router
Router --> TRPC

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

View file

@ -1,29 +0,0 @@
sequenceDiagram
actor Admin
participant UI as Web UI React
participant TRPC as tRPC Client
participant API as domainsRouter
participant DB as PostgreSQL/Drizzle
participant Checker as domain-check module
participant DNS as DNS Resolver
Admin->>UI: Isi form domain dan pilih service
UI->>TRPC: domains.create({ serviceId, name, expectedServerIp? })
TRPC->>API: Kirim request create domain
API->>DB: Validasi service dan server parent
API->>DB: Insert domain, default expected IP dari server parent jika kosong
DB-->>API: Domain berhasil dibuat
API-->>TRPC: Data domain baru
TRPC-->>UI: Data domain baru
UI->>TRPC: domains.check({ id: created.id }) otomatis
TRPC->>API: Kirim request DNS check
API->>DB: Ambil domain berdasarkan id
API->>Checker: runAndPersistDomainCheck(...)
Checker->>DNS: resolve4(domain.name)
DNS-->>Checker: Daftar IPv4 atau error
Checker->>DB: Update resolutionStatus dan hasil check
DB-->>Checker: Domain terbaru
Checker-->>API: { domain, result }
API-->>TRPC: { domain, check }
TRPC-->>UI: Hasil DNS check
UI->>UI: Invalidate domains.list, navigate detail, tampilkan toast

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

View file

@ -1,10 +0,0 @@
stateDiagram-v2
[*] --> UNRESOLVED
state "Checking DNS" as CHECKING
UNRESOLVED --> CHECKING : create/update/manual check
MATCH --> CHECKING : re-check
MISMATCH --> CHECKING : re-check
CHECKING --> MATCH : expected IP ditemukan
CHECKING --> MISMATCH : resolved IP berbeda
CHECKING --> UNRESOLVED : DNS lookup gagal

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

View file

@ -1,25 +0,0 @@
flowchart LR
Admin([Admin])
Login((Sign in))
Protected((Akses Dashboard Terproteksi))
Dashboard((Melihat Dashboard Summary))
Server((CRUD Server))
Service((CRUD Service))
Domain((CRUD Domain))
AutoDns((Auto DNS Check saat Create/Update Domain))
ManualDns((Manual DNS Check dari List/Detail Domain))
Note((CRUD Catatan))
Detail((Melihat Catatan pada Detail Server))
Logout((Sign out))
Admin --> Login
Login --> Protected
Protected --> Dashboard
Protected --> Server
Protected --> Service
Protected --> Domain
Protected --> Note
Domain --> AutoDns
Domain --> ManualDns
Server --> Detail
Admin --> Logout

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

View file

@ -1,568 +0,0 @@
\documentclass[12pt,a4paper]{report}
\usepackage[utf8]{inputenc}
\usepackage[T1]{fontenc}
\usepackage[indonesian]{babel}
\usepackage[a4paper,margin=2.8cm]{geometry}
\usepackage{graphicx}
\usepackage{float}
\usepackage{array}
\usepackage{booktabs}
\usepackage{longtable}
\usepackage{hyperref}
\usepackage{xcolor}
\usepackage{listings}
\usepackage{enumitem}
\usepackage{titlesec}
\usepackage{setspace}
\hypersetup{
colorlinks=true,
linkcolor=blue!60!black,
urlcolor=blue!60!black,
pdftitle={Laporan Proyek Minmon Homelab},
pdfauthor={[Nama Mahasiswa]}
}
\onehalfspacing
\setlist[itemize]{noitemsep, topsep=4pt}
\setlist[enumerate]{noitemsep, topsep=4pt}
\renewcommand{\chaptername}{BAB}
\titleformat{\chapter}[display]
{\bfseries\Large}
{\chaptername\ \thechapter}
{0.5em}
{\Large}
\definecolor{codebg}{RGB}{248,248,248}
\definecolor{bordergray}{RGB}{210,210,210}
\lstdefinestyle{minmoncode}{
backgroundcolor=\color{codebg},
basicstyle=\ttfamily\small,
breaklines=true,
frame=single,
rulecolor=\color{bordergray},
showstringspaces=false,
tabsize=2,
keywordstyle=\color{blue!60!black}\bfseries,
commentstyle=\color{green!40!black},
stringstyle=\color{red!50!black}
}
\lstdefinelanguage{TypeScript}{
keywords={import,from,export,const,return,if,await,async,type,string,number,boolean,null,undefined},
sensitive=true,
morecomment=[l]{//},
morecomment=[s]{/*}{*/},
morestring=[b]",
morestring=[b]'
}
\newcommand{\uipath}[1]{\texttt{#1}}
\newcommand{\uibutton}[1]{\textbf{#1}}
\newcommand{\uiscreenshot}[2]{
\begin{figure}[H]
\centering
\includegraphics[width=0.94\textwidth]{#1}
\caption{#2}
\end{figure}
}
\newcommand{\uidiagram}[2]{
\begin{figure}[H]
\centering
\includegraphics[width=0.94\textwidth,height=0.72\textheight,keepaspectratio]{#1}
\caption{#2}
\end{figure}
}
\begin{document}
\begin{titlepage}
\centering
{\Large \textbf{LAPORAN PROYEK DAN MANUAL BOOK}}\\[1.2cm]
{\Huge \textbf{MINMON}}\\[0.4cm]
{\Large Dashboard Inventaris dan Monitoring Homelab Berbasis Web}\\[1.2cm]
\begin{tabular}{ll}
\textbf{Nama Mahasiswa} & : \makebox[7cm]{\hrulefill}\\
\textbf{NIM} & : \makebox[7cm]{\hrulefill}\\
\textbf{Program Studi} & : \makebox[7cm]{\hrulefill}\\
\textbf{Mata Kuliah} & : \makebox[7cm]{\hrulefill}\\
\textbf{Dosen Pengampu} & : \makebox[7cm]{\hrulefill}\\
\textbf{Institusi} & : \makebox[7cm]{\hrulefill}\\
\textbf{Tahun Akademik} & : \makebox[7cm]{\hrulefill}
\end{tabular}
\vfill
{\large Dokumen ini disusun untuk mendeskripsikan proyek \textit{Minmon} yang digunakan untuk mendata dan memonitor inventaris homelab, khususnya lingkungan kecil yang terdiri dari beberapa server dengan banyak service dan domain terkait.}\\[1cm]
{\large \today}
\end{titlepage}
\tableofcontents
\clearpage
\chapter{Pendahuluan}
\section{Latar Belakang}
Homelab adalah lingkungan laboratorium komputer pribadi yang biasanya dibangun di rumah atau lingkungan kecil untuk belajar, mencoba, dan menjalankan layanan server secara mandiri. Dalam homelab, pengguna dapat mengelola server fisik, mini PC, virtual machine, container, jaringan lokal, serta berbagai aplikasi seperti dashboard, database, media server, reverse proxy, dan automation tools. Homelab modern tidak lagi hanya berisi satu mesin dengan satu layanan. Dalam praktiknya, satu homelab kecil dapat terdiri dari beberapa server, banyak service, serta sejumlah domain atau subdomain yang harus diarahkan ke alamat IP yang benar. Ketika jumlah aset ini bertambah, pencatatan manual menjadi sulit dipelihara karena data server, service, domain, dan catatan operasional tersebar di berbagai tempat.
Minmon dikembangkan sebagai dashboard inventaris dan monitoring ringan berbasis web untuk membantu pemilik homelab mengelola aset tersebut dalam satu aplikasi terpusat. Aplikasi ini tidak ditujukan untuk deployment automation, melainkan untuk kebutuhan dokumentasi, inventaris, relasi data, dan validasi DNS sederhana. Dalam konteks penggunaan pribadi, proyek ini cocok untuk skenario homelab yang terdiri dari beberapa server dengan banyak service dan domain yang perlu dicatat serta dipantau.
Implementasi saat ini menyediakan autentikasi admin, dashboard ringkasan, CRUD untuk server, service, domain, dan catatan, serta fitur pemeriksaan DNS A record. Selain itu, relasi antar entitas juga sudah diterapkan melalui select input agar pengguna tidak perlu memasukkan ID relasi secara manual. Dengan demikian, Minmon dapat berfungsi sebagai pusat data operasional homelab yang lebih rapi, konsisten, dan mudah diperluas.
\section{Tujuan}
Tujuan dari pengembangan proyek ini adalah sebagai berikut:
\begin{itemize}
\item Membangun aplikasi web yang dapat digunakan untuk mengelola inventaris homelab secara terstruktur.
\item Menyediakan fitur CRUD untuk data server, service, domain, dan catatan dalam satu dashboard terproteksi.
\item Menyediakan pemeriksaan DNS sederhana untuk memverifikasi apakah domain mengarah ke IP server yang diharapkan.
\item Mempermudah dokumentasi relasi data, misalnya service yang berjalan pada server tertentu dan catatan yang melekat pada server tertentu.
\item Menjadi contoh implementasi aplikasi web penuh berbasis React, tRPC, Drizzle ORM, PostgreSQL, dan better-auth.
\end{itemize}
\section{Manfaat}
Manfaat dari proyek ini antara lain:
\begin{itemize}
\item Membantu pemilik homelab mendokumentasikan server, service, domain, dan catatan dalam satu tempat.
\item Mengurangi kesalahan input relasi data karena hubungan antar data dipilih melalui field pilihan.
\item Memudahkan pengecekan domain melalui fitur pemeriksaan DNS berbasis A record / IPv4.
\item Memberikan gambaran ringkas kondisi homelab melalui ringkasan dashboard.
\item Menjadi bahan laporan akademik dan contoh produk perangkat lunak yang benar-benar dapat dipakai.
\end{itemize}
\chapter{Metode Pengembangan}
\section{Tahap Pengembangan}
Tahap pengembangan proyek Minmon dapat dijelaskan secara ringkas sebagai berikut:
\begin{enumerate}
\item \textbf{Identifikasi kebutuhan}\\
Menentukan kebutuhan utama untuk inventaris homelab, yaitu pengelolaan data server, service, domain, dan catatan, serta kebutuhan monitoring DNS sederhana.
\item \textbf{Perancangan data dan relasi}\\
Menentukan entitas inti, yaitu server, service, domain, dan catatan. Setiap service terkait ke satu server, setiap domain terkait ke satu service, dan setiap catatan terkait ke satu server.
\item \textbf{Perancangan backend}\\
Mengimplementasikan router tRPC untuk operasi daftar data, detail, tambah, ubah, hapus, serta ringkasan dashboard. Pemeriksaan DNS diimplementasikan sebagai operasi khusus pada data domain.
\item \textbf{Perancangan frontend}\\
Membangun halaman login, ringkasan dashboard, halaman daftar data, halaman tambah data, dan halaman detail/edit untuk setiap entitas. Form relasi dibuat menggunakan pilihan data agar lebih aman dan mudah dipakai.
\item \textbf{Integrasi dan validasi}\\
Menghubungkan frontend ke backend dengan TanStack Query dan tRPC client, lalu memastikan operasi data, pembaruan ulang data, serta tampilan ringkasan berjalan dengan baik.
\item \textbf{Pengujian dan penyempurnaan}\\
Melakukan pemeriksaan tipe, pengecekan alur CRUD, pengecekan DNS, perbaikan kestabilan urutan daftar data, dan penyempurnaan interaksi UI seperti hapus dua langkah dan pemicu DNS dari badge status.
\end{enumerate}
\section{Identitas/Keunikan Produk}
Minmon memiliki identitas produk sebagai dashboard inventaris dan monitoring ringan untuk homelab. Keunikan produk ini dibanding pencatatan manual biasa adalah sebagai berikut:
\begin{itemize}
\item Fokus pada skenario homelab kecil-menengah, yaitu beberapa server dengan banyak service dan domain.
\item Menggabungkan inventaris dan dokumentasi operasional dalam satu aplikasi.
\item Menyediakan relasi data yang jelas antara server, service, domain, dan catatan.
\item Memiliki pemeriksaan DNS A record yang terintegrasi langsung pada alur domain.
\item Menampilkan catatan yang terkait langsung pada halaman detail server.
\item Menggunakan autentikasi admin sehingga dashboard tidak dapat diakses tanpa sesi.
\end{itemize}
Untuk penggunaan pribadi, Minmon cocok sebagai pusat dokumentasi homelab yang tidak hanya menyimpan data inventaris, tetapi juga membantu memastikan domain masih mengarah ke IP yang benar dan memudahkan pelacakan catatan operasional pada setiap server.
\section{Diagram-diagram UML (use case, class, activity, state chart, sequence, collaboration)}
Bagian ini berisi rancangan UML yang disusun berdasarkan implementasi aktual Minmon. Diagram disisipkan sebagai gambar agar alur sistem dapat dibaca langsung di dalam laporan. Struktur diagram mencerminkan aktor admin, modul frontend React, router tRPC, database PostgreSQL melalui Drizzle ORM, dan modul pemeriksaan DNS.
\subsection{Use Case Diagram}
Use case utama dalam sistem ini meliputi:
\begin{itemize}
\item Admin sign in
\item Admin mengakses ringkasan dashboard
\item Admin mengelola server
\item Admin mengelola service
\item Admin mengelola domain
\item Admin menjalankan pemeriksaan DNS dari halaman daftar atau detail domain
\item UI menjalankan pemeriksaan DNS setelah domain dibuat atau diperbarui
\item Admin mengelola catatan
\item Admin melihat catatan pada detail server
\item Admin sign out
\end{itemize}
\uidiagram{diagrams/use-case.png}{Use Case Diagram Minmon}
\subsection{Class Diagram}
Entitas utama pada sistem adalah:
\begin{itemize}
\item \textbf{Server}: id, name, primaryIpAddress, secondaryIpAddress, operatingSystem, location, provider, description, status, createdAt, updatedAt
\item \textbf{Service}: id, serverId, name, type, internalPort, externalPort, protocol, status, description, createdAt, updatedAt
\item \textbf{Domain}: id, serviceId, name, expectedServerIp, lastResolvedIp, lastResolvedIps, resolutionStatus, lastCheckedAt, remarks, lastCheckMessage, createdAt, updatedAt
\item \textbf{Catatan}: id, serverId, title, content, category, createdAt, updatedAt
\end{itemize}
Hubungan utama:
\begin{itemize}
\item Server 1..* Service secara konseptual, tetapi secara database dapat 0..*.
\item Server 1..* Catatan secara konseptual, tetapi secara database dapat 0..*.
\item Service 1..* Domain secara konseptual, tetapi secara database dapat 0..*.
\end{itemize}
\uidiagram{diagrams/class.png}{Class Diagram Minmon}
\subsection{Activity Diagram}
Activity diagram berikut menampilkan alur pemeriksaan DNS yang sesuai dengan implementasi saat ini, yaitu dipicu oleh UI setelah domain ditambahkan/diperbarui atau dijalankan manual dari halaman daftar/detail domain:
\begin{enumerate}
\item Admin membuat, memperbarui, atau memeriksa domain.
\item UI memanggil operasi \texttt{domains.create}, \texttt{domains.update}, atau \texttt{domains.check} sesuai aksi.
\item Setelah create/update berhasil, UI memanggil \texttt{domains.check} untuk menjalankan pemeriksaan DNS.
\item Backend melakukan resolve IPv4 A record.
\item Backend membandingkan hasil resolve dengan \texttt{expectedServerIp}.
\item Backend menyimpan status dan hasil resolve ke database.
\item UI memperbarui ulang data dan menampilkan status baru.
\end{enumerate}
\uidiagram{diagrams/activity.png}{Activity Diagram DNS Check}
\subsection{State Chart Diagram}
State chart yang disarankan adalah state untuk status DNS domain:
\begin{itemize}
\item UNRESOLVED
\item MATCH
\item MISMATCH
\end{itemize}
Transisi utama:
\begin{itemize}
\item DNS berhasil dan IP cocok $\rightarrow$ MATCH
\item DNS berhasil tetapi IP tidak cocok $\rightarrow$ MISMATCH
\item DNS gagal resolve $\rightarrow$ UNRESOLVED
\end{itemize}
\uidiagram{diagrams/state-chart.png}{State Chart Domain DNS}
\subsection{Sequence Diagram}
Sequence diagram yang cocok untuk proyek ini adalah alur tambah domain dan pemeriksaan DNS lanjutan, karena UI langsung memanggil pemeriksaan DNS setelah domain berhasil dibuat:
\begin{itemize}
\item Admin
\item Web UI
\item tRPC client/API
\item Database
\item Domain check module
\item DNS Resolver
\end{itemize}
\uidiagram{diagrams/sequence.png}{Sequence Diagram Create Domain and Check DNS}
\subsection{Collaboration Diagram}
Collaboration diagram menunjukkan interaksi aktual antara route terproteksi, halaman dashboard, TanStack Query, router tRPC, database, dan modul pemeriksaan DNS:
\begin{itemize}
\item Admin
\item Dashboard UI dan route terproteksi
\item better-auth client
\item TanStack Query dan tRPC router
\item Database
\item Modul DNS lookup
\end{itemize}
\uidiagram{diagrams/collaboration.png}{Collaboration Diagram Minmon}
\section{Coding Minimal 80 Baris}
Bagian ini menampilkan kode asli dari file \uipath{packages/api/src/routers/services.ts}. Kode ini dipilih karena berjumlah 82 baris dan mewakili logika inti backend, yaitu protected CRUD untuk entitas service, validasi relasi ke server, penghitungan ringkasan domain, dan operasi database menggunakan Drizzle ORM.
\begin{lstlisting}[style=minmoncode,language=TypeScript,caption={Kode servicesRouter: CRUD Service dan validasi relasi, 82 baris}]
import { db } from "@minmon/db";
import { domain, service } from "@minmon/db";
import { count, desc, eq } from "drizzle-orm";
import { protectedProcedure, router } from "../index";
import {
byIdSchema,
serviceCreateSchema,
serviceListSchema,
serviceUpdateSchema,
} from "./schemas";
import {
createEntityId,
requireServer,
requireService,
stripUndefined,
} from "./utils";
export const servicesRouter = router({
list: protectedProcedure.input(serviceListSchema).query(async ({ input }) => {
if (!input?.serverId) {
return db.select().from(service).orderBy(desc(service.createdAt), desc(service.id));
}
await requireServer(input.serverId);
return db
.select()
.from(service)
.where(eq(service.serverId, input.serverId))
.orderBy(desc(service.createdAt), desc(service.id));
}),
byId: protectedProcedure.input(byIdSchema).query(async ({ input }) => {
const item = await requireService(input.id);
const [domainCount] = await db.select({ count: count() }).from(domain).where(eq(domain.serviceId, input.id));
return {
...item,
summary: {
domains: domainCount?.count ?? 0,
},
};
}),
create: protectedProcedure.input(serviceCreateSchema).mutation(async ({ input }) => {
await requireServer(input.serverId);
const [created] = await db
.insert(service)
.values({
id: createEntityId(),
...input,
})
.returning();
return created;
}),
update: protectedProcedure.input(serviceUpdateSchema).mutation(async ({ input }) => {
const { id, serverId, ...changes } = input;
await requireService(id);
if (serverId) {
await requireServer(serverId);
}
const [updated] = await db
.update(service)
.set(stripUndefined({ serverId, ...changes }))
.where(eq(service.id, id))
.returning();
return updated;
}),
delete: protectedProcedure.input(byIdSchema).mutation(async ({ input }) => {
await requireService(input.id);
await db.delete(service).where(eq(service.id, input.id));
return { id: input.id };
}),
});
\end{lstlisting}
Penjelasan singkat kode di atas:
\begin{itemize}
\item \texttt{list} mengambil daftar service, baik seluruh service maupun service berdasarkan \texttt{serverId}, dengan urutan stabil berdasarkan \texttt{createdAt} dan \texttt{id}.
\item \texttt{byId} mengambil detail service dan menambahkan ringkasan jumlah domain yang terhubung ke service tersebut.
\item \texttt{create} memvalidasi server induk terlebih dahulu sebelum menyimpan service baru.
\item \texttt{update} memastikan service yang diedit ada, lalu memvalidasi server baru jika relasi \texttt{serverId} diubah.
\item \texttt{delete} memastikan data service ada sebelum dihapus dari database.
\end{itemize}
\chapter{Manual Book}
\section{Gambaran Umum Penggunaan}
Bagian ini menjelaskan cara menggunakan aplikasi Minmon berdasarkan implementasi saat ini. Gaya penjelasan dibuat seperti manual book: langkah demi langkah, ringkas, dan fokus pada hasil yang diharapkan.
\section{Login ke Sistem}
\textbf{Path akses:} \uipath{/login}
\begin{enumerate}
\item Jalankan aplikasi web dan server backend dari root proyek menggunakan perintah \texttt{bun run dev}.
\item Buka browser ke alamat \uipath{http://localhost:5173}.
\item Jika belum login, sistem akan mengarahkan ke \uipath{/login}.
\item Masukkan email admin dan password.
\item Tekan tombol \uibutton{Sign In}.
\end{enumerate}
\textbf{Hasil yang diharapkan:} pengguna masuk ke halaman \uipath{/dashboard}.
Tampilan halaman login berisi form email dan password untuk admin. Apabila data benar, sistem membuat session melalui better-auth dan mengarahkan pengguna ke dashboard.
\uiscreenshot{screenshots/manual-login.png}{Halaman Login Minmon}
\section{Membuka Dashboard Summary}
\textbf{Path akses:} \uipath{/dashboard}
Halaman dashboard menampilkan ringkasan:
\begin{itemize}
\item total server,
\item total service,
\item total domain,
\item jumlah DNS match / mismatch / unresolved,
\item service yang inactive/down,
\item catatan terbaru.
\end{itemize}
\textbf{Langkah penggunaan:}
\begin{enumerate}
\item Login sebagai admin.
\item Setelah diarahkan ke dashboard, periksa kartu ringkasan di bagian atas.
\item Gunakan link area untuk membuka halaman Servers, Services, Domains, atau Catatan.
\end{enumerate}
Dashboard summary berfungsi sebagai halaman awal setelah login. Pengguna dapat langsung melihat kondisi umum inventaris sebelum masuk ke halaman CRUD masing-masing modul.
\uiscreenshot{screenshots/manual-dashboard.png}{Dashboard Summary Minmon}
\section{Mengelola Data Server}
\textbf{Path akses:} \uipath{/dashboard/servers}
\subsection{Menambah Server}
\begin{enumerate}
\item Buka menu Servers.
\item Klik tombol \uibutton{Add server}.
\item Isi data server seperti nama, IP utama, lokasi, provider, dan status.
\item Klik \uibutton{Save server}.
\end{enumerate}
\textbf{Hasil yang diharapkan:} data server tersimpan dan pengguna diarahkan ke halaman detail server.
\uiscreenshot{screenshots/manual-servers-list.png}{Halaman Daftar Server}
\uiscreenshot{screenshots/manual-server-create.png}{Form Tambah Server}
\subsection{Melihat Detail Server}
\textbf{Path akses:} \uipath{/dashboard/servers/\$serverId}
Pada halaman ini pengguna dapat:
\begin{itemize}
\item melihat ringkasan server,
\item mengedit data server,
\item melihat catatan yang terkait dengan server tersebut.
\end{itemize}
Halaman detail server menjadi pusat informasi untuk satu server tertentu karena menampilkan data utama server dan catatan yang terhubung dengan server tersebut.
\uiscreenshot{screenshots/manual-server-detail.png}{Halaman Detail Server}
\section{Mengelola Data Service}
\textbf{Path akses:} \uipath{/dashboard/services}
\subsection{Menambah Service}
\begin{enumerate}
\item Buka menu Services.
\item Klik \uibutton{Add service}.
\item Pilih server induk pada field \textit{Server}.
\item Isi nama service, tipe, port, protokol, status, dan deskripsi.
\item Klik \uibutton{Save service}.
\end{enumerate}
\textbf{Hasil yang diharapkan:} service tersimpan dan terhubung ke server yang dipilih.
\uiscreenshot{screenshots/manual-services-list.png}{Halaman Daftar Service}
\uiscreenshot{screenshots/manual-service-create.png}{Form Tambah Service}
\section{Mengelola Data Domain}
\textbf{Path akses:} \uipath{/dashboard/domains}
\subsection{Menambah Domain}
\begin{enumerate}
\item Buka menu Domains.
\item Klik \uibutton{Add domain}.
\item Pilih service induk pada field \textit{Service}.
\item Isi nama domain dan expected IP bila diperlukan.
\item Klik \uibutton{Save domain}.
\end{enumerate}
\textbf{Catatan:} jika expected IP dikosongkan, backend akan menggunakan IP server induk dari service terkait.
\uiscreenshot{screenshots/manual-domain-create.png}{Form Tambah Domain}
\subsection{Menjalankan DNS Check}
\begin{enumerate}
\item Buka halaman daftar domain.
\item Tekan badge status DNS pada baris domain yang ingin dicek.
\item Tunggu proses pemeriksaan selesai.
\item Lihat hasil baru pada kolom status dan IP hasil resolve.
\end{enumerate}
\textbf{Hasil yang diharapkan:} status berubah menjadi \texttt{MATCH}, \texttt{MISMATCH}, atau \texttt{UNRESOLVED}.
Pada halaman daftar domain, badge status DNS juga berfungsi sebagai tombol aksi. Ketika ditekan, sistem menjalankan proses pemeriksaan DNS dan memperbarui status pada baris domain tersebut.
\uiscreenshot{screenshots/manual-domains-list.png}{Halaman Daftar Domain dan Status DNS}
\section{Mengelola Data Catatan}
\textbf{Path akses:} \uipath{/dashboard/notes}
\subsection{Menambah Catatan}
\begin{enumerate}
\item Buka menu Catatan.
\item Klik tombol tambah catatan.
\item Pilih server pada field \textit{Server}.
\item Isi judul, kategori, dan isi catatan.
\item Klik tombol simpan catatan.
\end{enumerate}
\textbf{Hasil yang diharapkan:} catatan tersimpan dan terhubung ke server terpilih.
\uiscreenshot{screenshots/manual-notes-list.png}{Halaman Daftar Catatan}
\uiscreenshot{screenshots/manual-note-create.png}{Form Tambah Catatan}
\subsection{Melihat Catatan pada Detail Server}
\begin{enumerate}
\item Buka halaman detail server.
\item Scroll ke bagian catatan server.
\item Lihat daftar catatan yang hanya terkait dengan server tersebut.
\end{enumerate}
\textbf{Hasil yang diharapkan:} catatan yang tampil hanyalah catatan dengan \texttt{serverId} yang sesuai.
\section{Aksi Hapus Data}
Pada daftar server, service, domain, dan catatan, aksi hapus saat ini menggunakan alur dua langkah:
\begin{enumerate}
\item Klik ikon trash.
\item Tombol berubah menjadi ikon check sebagai konfirmasi.
\item Klik ikon check untuk benar-benar menghapus data.
\end{enumerate}
\textbf{Catatan penting:}
\begin{itemize}
\item Hapus server akan menghapus service, domain, dan catatan terkait melalui cascade delete.
\item Hapus service akan menghapus domain terkait melalui cascade delete.
\end{itemize}
\chapter{Kesimpulan dan Saran}
\section*{Kesimpulan}
\addcontentsline{toc}{section}{Kesimpulan}
Minmon berhasil diimplementasikan sebagai aplikasi web untuk inventaris dan monitoring ringan homelab. Sistem ini sudah mencakup autentikasi admin, ringkasan dashboard, CRUD untuk server, service, domain, dan catatan, serta pemeriksaan DNS A record. Dalam konteks penggunaan homelab pribadi dengan beberapa server dan banyak service/domain, aplikasi ini sudah cukup representatif untuk membantu pencatatan aset, dokumentasi operasional, dan validasi domain.
Keunggulan utama implementasi saat ini adalah struktur relasi yang jelas, form relasi berbasis select, ringkasan dashboard yang langsung informatif, serta adanya catatan yang bisa ditampilkan langsung pada halaman detail server. Selain itu, interaksi UI juga sudah dibuat lebih aman melalui delete dua langkah dan urutan list yang stabil.
\section*{Saran}
\addcontentsline{toc}{section}{Saran}
Saran pengembangan untuk tahap berikutnya antara lain:
\begin{itemize}
\item Menambahkan fitur pencarian, filter, dan pagination pada halaman daftar data.
\item Menambahkan histori hasil pemeriksaan DNS agar perubahan status domain dapat dilacak dari waktu ke waktu.
\item Menambahkan multi-role authentication jika aplikasi ingin dipakai lebih dari satu pengguna.
\item Menambahkan dukungan monitoring lain selain DNS A record, misalnya masa berlaku SSL, ping, atau health endpoint HTTP.
\item Menambahkan histori aktivitas agar perubahan data server, service, domain, dan catatan dapat ditelusuri lebih mudah.
\end{itemize}
\end{document}

View file

@ -4,182 +4,109 @@
### 1.1 Latar Belakang ### 1.1 Latar Belakang
- Pentingnya pendataan server, layanan, domain, dan dokumentasi internal secara terpusat - Importance of structured server documentation and monitoring
- Kebutuhan dashboard web sederhana untuk membantu admin memantau aset server private - Need for a simple admin dashboard for managing private servers and hosted services
- Kebutuhan pemeriksaan DNS dasar agar domain dapat diverifikasi terhadap IP server yang diharapkan
### 1.2 Rumusan Masalah ### 1.2 Rumusan Masalah
- Bagaimana membangun aplikasi web terproteksi untuk mengelola server, service, domain, dan note dalam satu dashboard? - How to manage server data, services, domains, and internal notes in one web app?
- Bagaimana memeriksa apakah domain mengarah ke IP server yang benar secara sederhana? - How to check whether a domain resolves to the expected server IP automatically?
- Bagaimana menampilkan relasi data, misalnya note per server, langsung dari halaman detail?
### 1.3 Batasan Masalah ### 1.3 Batasan Masalah
- Aplikasi berupa dashboard CRUD + monitoring ringan - CRUD + monitoring dashboard only
- Hanya satu aktor admin - No SSH, no Docker orchestration, no real deployment automation
- DNS checking terbatas pada A record / IPv4 - DNS checking limited to A record / IPv4
- Tidak mencakup SSH, deployment automation, Docker orchestration, atau reverse proxy management - Single admin login
- Fokus implementasi pada web app + server app + PostgreSQL
### 1.4 Tujuan ### 1.4 Tujuan
- Membangun dashboard manajemen layanan server private berbasis web - Build a web-based dashboard for private server service management
- Menyediakan CRUD untuk server, service, domain, dan note - Provide simple DNS verification and summary monitoring
- Menyediakan pemeriksaan DNS sederhana dan ringkasan dashboard
### 1.5 Manfaat ### 1.5 Manfaat
- Membantu admin mendokumentasikan dan memantau aset server - Helps admins manage server and service records
- Membantu proses pembelajaran relasi data, CRUD, auth, dan monitoring dasar - Helps students understand CRUD, relationships, and monitoring concepts
- Menjadi contoh implementasi full-stack berbasis React, tRPC, Drizzle, dan PostgreSQL
## BAB 2 - Tinjauan Pustaka ## BAB 2 - Tinjauan Pustaka
### 2.1 Sistem Informasi Berbasis Web ### 2.1 Web-Based Information Systems
### 2.2 Konsep CRUD ### 2.2 CRUD Concept
### 2.3 Konsep Dashboard Monitoring ### 2.3 Monitoring Dashboard Concept
### 2.4 DNS dan Resolusi A Record ### 2.4 DNS and A Record Resolution
### 2.5 Otentikasi pada Aplikasi Web ### 2.5 Technologies Used
### 2.6 Teknologi yang Digunakan
- React - React
- TanStack Router - TanStack Router
- TanStack Query
- tRPC - tRPC
- PostgreSQL - PostgreSQL
- Drizzle ORM - Drizzle ORM
- better-auth - better-auth
- Effect - Effect
- Bun
## BAB 3 - Analisis dan Perancangan Sistem ## BAB 3 - Analisis dan Perancangan Sistem
### 3.1 Analisis Kebutuhan ### 3.1 Analisis Kebutuhan
#### Kebutuhan Fungsional - Functional requirements
- Non-functional requirements
- Admin dapat sign in untuk mengakses dashboard
- Sistem mengarahkan `/` ke `/dashboard` atau `/login` berdasarkan sesi
- Admin dapat mengelola data server
- Admin dapat mengelola data service
- Admin dapat mengelola data domain
- Admin dapat menjalankan DNS check untuk domain
- Admin dapat mengelola note server
- Admin dapat melihat note yang terkait langsung dari halaman detail server
- Sistem menampilkan ringkasan dashboard dan data terbaru
#### Kebutuhan Non-Fungsional
- Antarmuka sederhana dan student-friendly
- Relasi data dipilih melalui select input agar mengurangi kesalahan input ID manual
- Data list ditampilkan dengan urutan stabil
- Aksi hapus memakai konfirmasi dua langkah berbasis ikon
### 3.2 Perancangan Sistem ### 3.2 Perancangan Sistem
- Use case diagram - Use case diagram
- Class diagram - Class diagram
- Activity diagram - Activity diagram
- State chart diagram - State chart
- Sequence diagram - Sequence diagram
- Collaboration diagram - Collaboration diagram
### 3.3 Perancangan Database ### 3.3 Perancangan Database
- Tabel `server` - Server table
- Tabel `service` - Service table
- Tabel `domain` - Domain table
- Tabel `note` - Note table
- Tabel auth bawaan better-auth - Auth tables
- Relasi:
- server -> services
- server -> notes
- service -> domains
- Aturan cascade delete pada relasi utama
### 3.4 Perancangan Antarmuka ### 3.4 Perancangan Antarmuka
- Halaman login (`/login`) - Login page
- Redirect root (`/`) - Dashboard page
- Dashboard summary (`/dashboard`) - Server list/detail/form
- Halaman list/detail/form server - Service list/detail/form
- Halaman list/detail/form service - Domain list/detail/form
- Halaman list/detail/form domain - Note list/detail/form
- Halaman list/detail/form note
- Section note terkait pada detail server
## BAB 4 - Implementasi dan Pengujian ## BAB 4 - Implementasi dan Pengujian
### 4.1 Implementasi Sistem ### 4.1 Implementasi Sistem
#### Backend - Backend implementation
- Frontend implementation
- Router tRPC untuk server, service, domain, note, dan dashboard summary - Authentication implementation
- Validasi relasi parent-child pada create/update - DNS check implementation
- DNS check menggunakan resolver IPv4 A record
- Persist hasil DNS check ke database
#### Frontend
- Protected routes untuk area dashboard
- CRUD forms dengan TanStack Form
- Select input untuk relasi:
- service -> server
- domain -> service
- note -> server
- Resource tables dengan stable row key dan ordering stabil dari backend
- Delete action berbasis ikon dengan dua klik konfirmasi
- DNS status pada domain list sebagai tombol check dengan styling status badge
#### Authentication
- better-auth client + protected route redirect
- Akses tanpa sesi diarahkan ke `/login`
### 4.2 Pengujian ### 4.2 Pengujian
- Pengujian login admin - Login test
- Pengujian CRUD server - CRUD server test
- Pengujian CRUD service - CRUD service test
- Pengujian CRUD domain - CRUD domain test
- Pengujian CRUD note - CRUD note test
- Pengujian select relasi pada form - DNS check test
- Pengujian DNS check domain - Dashboard summary test
- Pengujian note terkait pada halaman detail server
- Pengujian ringkasan dashboard
- Pengujian typecheck web dan API
### 4.3 Hasil Pengujian ### 4.3 Hasil Pengujian
- Tabel atau screenshot untuk setiap skenario berhasil - Tables/screenshots of successful scenarios
- Bukti hasil DNS check (`MATCH`, `MISMATCH`, `UNRESOLVED`)
- Bukti relasi data tampil benar di halaman detail dan list
### 4.4 Evaluasi ### 4.4 Evaluasi
#### Kelebihan - Strengths of the system
- Current limitations
- Struktur data relasional jelas - Suggested future improvements
- UI CRUD sudah konsisten dengan select untuk foreign key utama
- Ringkasan dashboard dan detail page saling terhubung
- DNS checking sudah terintegrasi langsung di alur domain
#### Keterbatasan
- DNS checking hanya untuk A record / IPv4
- Belum ada multi-role auth
- Belum ada fitur pagination, search, atau sorting manual dari UI
- Full workspace typecheck masih terhalang issue pre-existing pada `apps/desktop`
#### Saran Pengembangan
- Tambah filter, search, dan pagination
- Tambah histori DNS check
- Tambah role/permission management
- Tambah monitoring yang lebih luas selain DNS A record

View file

@ -2,52 +2,18 @@
## Project Description ## Project Description
Minmon is a student-friendly web dashboard for managing private servers, services, domains, and internal notes. The current implementation focuses on protected CRUD flows plus lightweight DNS A-record checking. Minmon is a student-friendly web dashboard for managing private servers, their hosted services, related domains, and server notes. It focuses on CRUD operations plus simple DNS monitoring, not deployment automation.
## Current Main Features ## Main Features
- Admin sign-in with better-auth - Admin login with better-auth
- Root route (`/`) redirects automatically: - Dashboard summary for servers, services, domains, DNS status, and inactive services
- logged in -> `/dashboard`
- not logged in -> `/login`
- Dashboard summary for:
- total servers
- total services
- total domains
- DNS match / mismatch / unresolved counts
- inactive or down services
- recent notes
- Server CRUD - Server CRUD
- Service CRUD - Service CRUD
- Domain CRUD - Domain CRUD
- Note CRUD - DNS A-record check with match/mismatch/unresolved result
- Domain DNS check from: - Server note/documentation CRUD
- domain list - Sample seed data for demo use
- domain detail page
- Server detail page includes notes that belong to that server
- Foreign-key form fields use select inputs for related records:
- service -> server
- domain -> service
- note -> server
- Sample seeded admin and demo dataset
## Current Route Structure
- `/` -> auth-based redirect
- `/login` -> sign-in page
- `/dashboard` -> summary page
- `/dashboard/servers` -> server list
- `/dashboard/servers/new` -> create server
- `/dashboard/servers/$serverId` -> server detail + edit + related notes
- `/dashboard/services` -> service list
- `/dashboard/services/new` -> create service
- `/dashboard/services/$serviceId` -> service detail + edit
- `/dashboard/domains` -> domain list
- `/dashboard/domains/new` -> create domain
- `/dashboard/domains/$domainId` -> domain detail + edit
- `/dashboard/notes` -> note list
- `/dashboard/notes/new` -> create note
- `/dashboard/notes/$noteId` -> note detail + edit
## Local Setup ## Local Setup
@ -69,30 +35,19 @@ CORS_ORIGIN=http://localhost:5173
NODE_ENV=development NODE_ENV=development
``` ```
3. Prepare web environment at `apps/web/.env` 3. Start PostgreSQL if needed
```env
VITE_SERVER_URL=http://localhost:3000
```
4. Ensure the web app can reach the server
- The web client uses the better-auth client with `env.VITE_SERVER_URL`
- Make sure the web environment points to the running API server
5. Start PostgreSQL if needed
```bash ```bash
bun run db:start bun run db:start
``` ```
6. Apply schema 4. Apply schema
```bash ```bash
bun run db:push bun run db:push
``` ```
7. Seed sample data 5. Seed sample data
```bash ```bash
bun run --filter @minmon/db db:seed bun run --filter @minmon/db db:seed
@ -103,44 +58,17 @@ Seeded admin login:
- Email: `admin@minmon.local` - Email: `admin@minmon.local`
- Password: `admin12345` - Password: `admin12345`
8. Run the apps 6. Run the apps
```bash ```bash
bun run dev bun run dev
``` ```
9. Open the app 7. Open the app
- Web: `http://localhost:5173` - Web: `http://localhost:5173`
- API: `http://localhost:3000` - API: `http://localhost:3000`
## Current Behavior Notes
### List Ordering
- Main CRUD list pages are ordered by:
- `createdAt DESC`
- then `id DESC`
- This keeps row ordering deterministic after refetch.
### Delete Actions
- List pages use a two-click destructive icon flow:
- first click: trash icon
- second click: check icon confirms delete
- Confirm state resets on blur and timeout.
### Domain DNS Check
- Domain list uses the DNS status badge itself as the check trigger.
- Domain detail page also provides a DNS check action.
- DNS checking resolves IPv4 A records only.
### Relationship Behavior
- Deleting a server removes related services, domains, and notes through cascade rules in the database.
- Deleting a service removes related domains through cascade rules.
## Helpful Commands ## Helpful Commands
```bash ```bash
@ -149,12 +77,9 @@ bun run db:push
bun run db:studio bun run db:studio
bun run --filter web check-types bun run --filter web check-types
bun run --filter server check-types bun run --filter server check-types
bun x tsc --noEmit -p packages/api/tsconfig.json
``` ```
## Notes ## Notes
- Full workspace typecheck may still fail because of a pre-existing `apps/desktop` typing issue for `three`. - Full workspace typecheck may still fail because of a pre-existing `apps/desktop` typing issue for `three`.
- The verified implementation path is the web app + server app + database push + database seed. - The dashboard app itself, server app, DB push, and DB seed are verified.
- DNS checking is limited to A record / IPv4.
- This project is not a deployment platform and does not include SSH, Docker orchestration, or reverse proxy automation.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

View file

@ -8,16 +8,14 @@
### Main Use Cases ### Main Use Cases
- Sign in - Login
- Access protected dashboard
- View dashboard summary - View dashboard summary
- Manage servers - Manage servers
- Manage services - Manage services
- Manage domains - Manage domains
- Check DNS for a domain - Check DNS for domain
- Manage notes - Manage server notes
- View notes related to a specific server - Logout
- Sign out
## 2. Class Diagram ## 2. Class Diagram
@ -81,30 +79,19 @@
- Server 1..\* Service - Server 1..\* Service
- Server 1..\* Note - Server 1..\* Note
- Service 1..\* Domain - Service 1..\* Domain
- Service \*..1 Server
- Domain \*..1 Service
- Note \*..1 Server
### Implementation Notes
- Server detail page displays related notes for the selected server.
- Service forms select an existing server.
- Domain forms select an existing service.
- Note forms select an existing server.
## 3. Activity Diagram ## 3. Activity Diagram
Suggested activity: **Check DNS from Domain List** Suggested activity: **Check DNS**
Flow: Flow:
- Admin opens domain list - Admin opens domain list/detail
- Admin clicks the DNS status badge - Admin clicks Check DNS
- System resolves IPv4 A record(s) - System resolves A record
- System compares resolved IPs with `expectedServerIp` - System compares resolved IPs with expected server IP
- System stores check result in the domain record - System stores result
- System refreshes the list - System shows Match / Mismatch / Unresolved
- System shows `MATCH`, `MISMATCH`, or `UNRESOLVED`
## 4. State Chart Diagram ## 4. State Chart Diagram
@ -112,15 +99,15 @@ Suggested state chart: **Domain DNS Status**
States: States:
- UNRESOLVED - Unresolved
- MATCH - Match
- MISMATCH - Mismatch
Transitions: Transitions:
- DNS check success and expected IP found -> MATCH - Check DNS success with expected IP found -> Match
- DNS check success and expected IP not found -> MISMATCH - Check DNS success with expected IP missing -> Mismatch
- DNS resolution failure -> UNRESOLVED - Check DNS failure -> Unresolved
## 5. Sequence Diagram ## 5. Sequence Diagram
@ -136,41 +123,24 @@ Objects:
Flow: Flow:
- Admin opens create domain page - Admin submits domain form
- UI loads available services - UI sends create request
- Admin selects a service and submits the form - API validates and stores domain
- API validates the service relationship - Admin triggers DNS check
- API stores the domain - UI calls check mutation
- Admin clicks DNS status / check action - API resolves DNS
- UI calls `domains.check` - API updates database
- API resolves DNS A records - UI displays result
- API updates domain check fields in the database
- UI refetches and displays the new status
## 6. Collaboration Diagram ## 6. Collaboration Diagram
Show collaboration between: Show interaction between:
- Admin - Admin
- Dashboard UI - Dashboard UI
- better-auth client / auth middleware - Auth module
- tRPC routers - API router
- Database - Database
- DNS lookup module - DNS lookup module
Suggested emphasis: Focus on how each module collaborates during CRUD and DNS checking.
- Protected route access
- CRUD coordination between UI and API
- Parent-child relationship selection in forms
- DNS checking and persistence flow
- Server detail page collaboration with note listing
## 7. UI/Interaction Notes for Documentation
Useful current UI details you can reflect in diagrams or captions:
- `/` redirects to `/dashboard` or `/login` based on session state
- Main list pages are ordered by `createdAt DESC`, then `id DESC`
- Delete actions use a two-click icon confirmation flow
- Domain DNS check is triggered from the DNS status badge in the list view

View file

@ -14,8 +14,6 @@
"elysia": "^1.4.28", "elysia": "^1.4.28",
"@trpc/server": "^11.16.0", "@trpc/server": "^11.16.0",
"better-auth": "1.5.5", "better-auth": "1.5.5",
"drizzle-orm": "^0.45.1",
"effect": "^3.18.4",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"@trpc/client": "^11.16.0", "@trpc/client": "^11.16.0",
"@types/react-dom": "^19.2.3" "@types/react-dom": "^19.2.3"

View file

@ -17,8 +17,8 @@
"@trpc/client": "catalog:", "@trpc/client": "catalog:",
"@trpc/server": "catalog:", "@trpc/server": "catalog:",
"dotenv": "catalog:", "dotenv": "catalog:",
"drizzle-orm": "catalog:", "drizzle-orm": "^0.45.1",
"effect": "catalog:", "effect": "^3.18.4",
"zod": "catalog:" "zod": "catalog:"
}, },
"devDependencies": { "devDependencies": {

View file

@ -21,7 +21,7 @@ import {
export const domainsRouter = router({ export const domainsRouter = router({
list: protectedProcedure.input(domainListSchema).query(async ({ input }) => { list: protectedProcedure.input(domainListSchema).query(async ({ input }) => {
if (!input?.serviceId) { if (!input?.serviceId) {
return db.select().from(domain).orderBy(desc(domain.createdAt), desc(domain.id)); return db.select().from(domain).orderBy(desc(domain.updatedAt));
} }
await requireService(input.serviceId); await requireService(input.serviceId);
@ -30,7 +30,7 @@ export const domainsRouter = router({
.select() .select()
.from(domain) .from(domain)
.where(eq(domain.serviceId, input.serviceId)) .where(eq(domain.serviceId, input.serviceId))
.orderBy(desc(domain.createdAt), desc(domain.id)); .orderBy(desc(domain.updatedAt));
}), }),
byId: protectedProcedure.input(byIdSchema).query(async ({ input }) => { byId: protectedProcedure.input(byIdSchema).query(async ({ input }) => {

View file

@ -14,7 +14,7 @@ import {
export const notesRouter = router({ export const notesRouter = router({
list: protectedProcedure.input(noteListSchema).query(async ({ input }) => { list: protectedProcedure.input(noteListSchema).query(async ({ input }) => {
if (!input?.serverId) { if (!input?.serverId) {
return db.select().from(note).orderBy(desc(note.createdAt), desc(note.id)); return db.select().from(note).orderBy(desc(note.updatedAt));
} }
await requireServer(input.serverId); await requireServer(input.serverId);
@ -23,7 +23,7 @@ export const notesRouter = router({
.select() .select()
.from(note) .from(note)
.where(eq(note.serverId, input.serverId)) .where(eq(note.serverId, input.serverId))
.orderBy(desc(note.createdAt), desc(note.id)); .orderBy(desc(note.updatedAt));
}), }),
byId: protectedProcedure.input(byIdSchema).query(async ({ input }) => { byId: protectedProcedure.input(byIdSchema).query(async ({ input }) => {

View file

@ -8,7 +8,7 @@ import { createEntityId, requireServer, stripUndefined } from "./utils";
export const serversRouter = router({ export const serversRouter = router({
list: protectedProcedure.query(async () => { list: protectedProcedure.query(async () => {
return db.select().from(server).orderBy(desc(server.createdAt), desc(server.id)); return db.select().from(server).orderBy(desc(server.updatedAt));
}), }),
byId: protectedProcedure.input(byIdSchema).query(async ({ input }) => { byId: protectedProcedure.input(byIdSchema).query(async ({ input }) => {

View file

@ -19,7 +19,7 @@ import {
export const servicesRouter = router({ export const servicesRouter = router({
list: protectedProcedure.input(serviceListSchema).query(async ({ input }) => { list: protectedProcedure.input(serviceListSchema).query(async ({ input }) => {
if (!input?.serverId) { if (!input?.serverId) {
return db.select().from(service).orderBy(desc(service.createdAt), desc(service.id)); return db.select().from(service).orderBy(desc(service.updatedAt));
} }
await requireServer(input.serverId); await requireServer(input.serverId);
@ -28,7 +28,7 @@ export const servicesRouter = router({
.select() .select()
.from(service) .from(service)
.where(eq(service.serverId, input.serverId)) .where(eq(service.serverId, input.serverId))
.orderBy(desc(service.createdAt), desc(service.id)); .orderBy(desc(service.updatedAt));
}), }),
byId: protectedProcedure.input(byIdSchema).query(async ({ input }) => { byId: protectedProcedure.input(byIdSchema).query(async ({ input }) => {

View file

@ -24,7 +24,7 @@
"better-auth": "catalog:", "better-auth": "catalog:",
"@minmon/env": "workspace:*", "@minmon/env": "workspace:*",
"dotenv": "catalog:", "dotenv": "catalog:",
"drizzle-orm": "catalog:", "drizzle-orm": "^0.45.1",
"pg": "^8.17.1", "pg": "^8.17.1",
"zod": "catalog:" "zod": "catalog:"
}, },

View file

@ -1,10 +1,9 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"; import { Button as ButtonPrimitive } from "@base-ui/react/button";
import { cn } from "@minmon/ui/lib/utils"; import { cn } from "@minmon/ui/lib/utils";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import { Loader2Icon } from "lucide-react";
const buttonVariants = cva( const buttonVariants = cva(
"group/button relative inline-flex shrink-0 items-center justify-center rounded-none border border-transparent bg-clip-padding text-xs font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 active:scale-[0.98] disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "group/button inline-flex shrink-0 items-center justify-center rounded-none border border-transparent bg-clip-padding text-xs font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{ {
variants: { variants: {
variant: { variant: {
@ -42,40 +41,14 @@ function Button({
className, className,
variant = "default", variant = "default",
size = "default", size = "default",
loading = false,
disabled,
children,
...props ...props
}: ButtonPrimitive.Props & }: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
VariantProps<typeof buttonVariants> & {
loading?: boolean;
}) {
return ( return (
<ButtonPrimitive <ButtonPrimitive
data-slot="button" data-slot="button"
data-loading={loading ? "" : undefined}
aria-busy={loading || undefined}
disabled={disabled || loading}
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
{...props} {...props}
> />
{loading ? (
<span
aria-hidden="true"
className="absolute inset-0 inline-flex items-center justify-center"
>
<Loader2Icon className="size-4 animate-spin" />
</span>
) : null}
<span
className={cn(
"inline-flex items-center justify-center gap-1.5",
loading && "invisible",
)}
>
{children}
</span>
</ButtonPrimitive>
); );
} }

View file

@ -1,132 +0,0 @@
"use client";
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { cn } from "@minmon/ui/lib/utils";
import { XIcon } from "lucide-react";
import * as React from "react";
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root {...props} />;
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogBackdrop({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-backdrop"
className={cn(
"fixed inset-0 z-50 bg-black/50 backdrop-blur-[1px] duration-150 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showClose = true,
...props
}: DialogPrimitive.Popup.Props & { showClose?: boolean }) {
return (
<DialogPortal>
<DialogBackdrop />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-card p-5 shadow-lg ring-1 ring-foreground/10 outline-none duration-150 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 sm:rounded-none",
className,
)}
{...props}
>
{children}
{showClose ? (
<DialogPrimitive.Close
aria-label="Close"
className="absolute right-3 top-3 inline-flex size-7 items-center justify-center rounded-none text-muted-foreground transition-colors outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 disabled:pointer-events-none"
>
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
) : null}
</DialogPrimitive.Popup>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-1.5 text-left", className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"text-base font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-xs text-muted-foreground", className)}
{...props}
/>
);
}
export {
Dialog,
DialogTrigger,
DialogPortal,
DialogClose,
DialogBackdrop,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View file

@ -1,199 +0,0 @@
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@minmon/ui/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-none border border-input bg-transparent py-2 pr-2 pl-2.5 text-xs whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-none *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-none bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-2 py-2 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-none py-2 pr-8 pl-2 text-xs outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View file

@ -1,135 +0,0 @@
"use client";
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { cn } from "@minmon/ui/lib/utils";
import { XIcon } from "lucide-react";
import * as React from "react";
function Sheet({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root {...props} />;
}
function SheetTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetBackdrop({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="sheet-backdrop"
className={cn(
"fixed inset-0 z-50 bg-black/50 backdrop-blur-[1px] duration-200 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className,
)}
{...props}
/>
);
}
type Side = "top" | "right" | "bottom" | "left";
const sideClasses: Record<Side, string> = {
left: "inset-y-0 left-0 h-full w-3/4 max-w-sm border-r data-open:slide-in-from-left data-closed:slide-out-to-left",
right:
"inset-y-0 right-0 h-full w-3/4 max-w-sm border-l data-open:slide-in-from-right data-closed:slide-out-to-right",
top: "inset-x-0 top-0 w-full border-b data-open:slide-in-from-top data-closed:slide-out-to-top",
bottom:
"inset-x-0 bottom-0 w-full border-t data-open:slide-in-from-bottom data-closed:slide-out-to-bottom",
};
function SheetContent({
className,
children,
side = "right",
showClose = true,
...props
}: DialogPrimitive.Popup.Props & {
side?: Side;
showClose?: boolean;
}) {
return (
<SheetPortal>
<SheetBackdrop />
<DialogPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-card p-5 shadow-lg outline-none duration-200 data-open:animate-in data-closed:animate-out",
sideClasses[side],
className,
)}
{...props}
>
{children}
{showClose ? (
<DialogPrimitive.Close
aria-label="Close"
className="absolute right-3 top-3 inline-flex size-7 items-center justify-center rounded-none text-muted-foreground transition-colors outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50"
>
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
) : null}
</DialogPrimitive.Popup>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 text-left", className)}
{...props}
/>
);
}
function SheetTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="sheet-title"
className={cn(
"text-base font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
);
}
function SheetDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="sheet-description"
className={cn("text-xs text-muted-foreground", className)}
{...props}
/>
);
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetPortal,
SheetBackdrop,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
};

View file

@ -4,9 +4,6 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="skeleton" data-slot="skeleton"
role="status"
aria-label="Loading"
aria-busy="true"
className={cn("animate-pulse rounded-none bg-muted", className)} className={cn("animate-pulse rounded-none bg-muted", className)}
{...props} {...props}
/> />

View file

@ -21,10 +21,9 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return <tr className={cn("border-b transition-colors hover:bg-muted/30", className)} {...props} />; return <tr className={cn("border-b transition-colors hover:bg-muted/30", className)} {...props} />;
} }
function TableHead({ className, scope = "col", ...props }: React.ComponentProps<"th">) { function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return ( return (
<th <th
scope={scope}
className={cn( className={cn(
"h-10 px-3 text-left align-middle text-[11px] font-medium uppercase tracking-[0.16em] text-muted-foreground", "h-10 px-3 text-left align-middle text-[11px] font-medium uppercase tracking-[0.16em] text-muted-foreground",
className, className,

View file

@ -7,118 +7,76 @@
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
:root { :root {
--background: oklch(1.0000 0 0); --background: oklch(1 0 0);
--foreground: oklch(0.2686 0 0); --foreground: oklch(0.145 0 0);
--card: oklch(1.0000 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.2686 0 0); --card-foreground: oklch(0.145 0 0);
--popover: oklch(1.0000 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.2686 0 0); --popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.7686 0.1647 70.0804); --primary: oklch(0.205 0 0);
--primary-foreground: oklch(0 0 0); --primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.9670 0.0029 264.5419); --secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.4461 0.0263 256.8018); --secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.9846 0.0017 247.8389); --muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.5510 0.0234 264.3637); --muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.9869 0.0214 95.2774); --accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.4732 0.1247 46.2007); --accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.6368 0.2078 25.3313); --destructive: oklch(0.58 0.22 27);
--border: oklch(0.9276 0.0058 264.5313); --border: oklch(0.922 0 0);
--input: oklch(0.9276 0.0058 264.5313); --input: oklch(0.922 0 0);
--ring: oklch(0.7686 0.1647 70.0804); --ring: oklch(0.708 0 0);
--chart-1: oklch(0.7686 0.1647 70.0804); --chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.6658 0.1574 58.3183); --chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.5553 0.1455 48.9975); --chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.4732 0.1247 46.2007); --chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.4137 0.1054 45.9038); --chart-5: oklch(0.424 0.199 265.638);
--radius: 0.375rem; --radius: 0.625rem;
--sidebar: oklch(0.9846 0.0017 247.8389); --sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.2686 0 0); --sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.7686 0.1647 70.0804); --sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(1.0000 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.9869 0.0214 95.2774); --sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.4732 0.1247 46.2007); --sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.9276 0.0058 264.5313); --sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.7686 0.1647 70.0804); --sidebar-ring: oklch(0.708 0 0);
--destructive-foreground: oklch(1.0000 0 0);
--font-sans: Inter, sans-serif;
--font-serif: Source Serif 4, serif;
--font-mono: JetBrains Mono, monospace;
--shadow-color: hsl(0 0% 0%);
--shadow-opacity: 0.1;
--shadow-blur: 8px;
--shadow-spread: -1px;
--shadow-offset-x: 0px;
--shadow-offset-y: 4px;
--letter-spacing: 0em;
--spacing: 0.25rem;
--shadow-2xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05);
--shadow-xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05);
--shadow-sm: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 1px 2px -2px hsl(0 0% 0% / 0.10);
--shadow: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 1px 2px -2px hsl(0 0% 0% / 0.10);
--shadow-md: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 2px 4px -2px hsl(0 0% 0% / 0.10);
--shadow-lg: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 4px 6px -2px hsl(0 0% 0% / 0.10);
--shadow-xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 8px 10px -2px hsl(0 0% 0% / 0.10);
--shadow-2xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.25);
--tracking-normal: 0em;
} }
.dark { .dark {
--background: oklch(0.2046 0 0); --background: oklch(0.145 0 0);
--foreground: oklch(0.9219 0 0); --foreground: oklch(0.985 0 0);
--card: oklch(0.2686 0 0); --card: oklch(0.205 0 0);
--card-foreground: oklch(0.9219 0 0); --card-foreground: oklch(0.985 0 0);
--popover: oklch(0.2686 0 0); --popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.9219 0 0); --popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.7686 0.1647 70.0804); --primary: oklch(0.87 0 0);
--primary-foreground: oklch(0 0 0); --primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.2686 0 0); --secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.9219 0 0); --secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.2393 0 0); --muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.7155 0 0); --muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.4732 0.1247 46.2007); --accent: oklch(0.371 0 0);
--accent-foreground: oklch(0.9243 0.1151 95.7459); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.6368 0.2078 25.3313); --destructive: oklch(0.704 0.191 22.216);
--border: oklch(0.3715 0 0); --border: oklch(1 0 0 / 10%);
--input: oklch(0.3715 0 0); --input: oklch(1 0 0 / 15%);
--ring: oklch(0.7686 0.1647 70.0804); --ring: oklch(0.556 0 0);
--chart-1: oklch(0.8369 0.1644 84.4286); --chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.6658 0.1574 58.3183); --chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.4732 0.1247 46.2007); --chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.5553 0.1455 48.9975); --chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.4732 0.1247 46.2007); --chart-5: oklch(0.424 0.199 265.638);
--sidebar: oklch(0.1684 0 0); --sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.9219 0 0); --sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.7686 0.1647 70.0804); --sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(1.0000 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.4732 0.1247 46.2007); --sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.9243 0.1151 95.7459); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.3715 0 0); --sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.7686 0.1647 70.0804); --sidebar-ring: oklch(0.556 0 0);
--destructive-foreground: oklch(1.0000 0 0);
--radius: 0.375rem;
--font-sans: Inter, sans-serif;
--font-serif: Source Serif 4, serif;
--font-mono: JetBrains Mono, monospace;
--shadow-color: hsl(0 0% 0%);
--shadow-opacity: 0.1;
--shadow-blur: 8px;
--shadow-spread: -1px;
--shadow-offset-x: 0px;
--shadow-offset-y: 4px;
--letter-spacing: 0em;
--spacing: 0.25rem;
--shadow-2xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05);
--shadow-xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05);
--shadow-sm: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 1px 2px -2px hsl(0 0% 0% / 0.10);
--shadow: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 1px 2px -2px hsl(0 0% 0% / 0.10);
--shadow-md: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 2px 4px -2px hsl(0 0% 0% / 0.10);
--shadow-lg: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 4px 6px -2px hsl(0 0% 0% / 0.10);
--shadow-xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 8px 10px -2px hsl(0 0% 0% / 0.10);
--shadow-2xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.25);
} }
@theme inline { @theme inline {
--font-sans: Inter, sans-serif; --font-sans: "Inter Variable", sans-serif;
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@ -157,32 +115,6 @@
--radius-2xl: calc(var(--radius) + 8px); --radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px); --radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px); --radius-4xl: calc(var(--radius) + 16px);
--font-mono: JetBrains Mono, monospace;
--font-serif: Source Serif 4, serif;
--radius: 0.375rem;
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
--tracking-normal: var(--tracking-normal);
--shadow-2xl: var(--shadow-2xl);
--shadow-xl: var(--shadow-xl);
--shadow-lg: var(--shadow-lg);
--shadow-md: var(--shadow-md);
--shadow: var(--shadow);
--shadow-sm: var(--shadow-sm);
--shadow-xs: var(--shadow-xs);
--shadow-2xs: var(--shadow-2xs);
--spacing: var(--spacing);
--letter-spacing: var(--letter-spacing);
--shadow-offset-y: var(--shadow-offset-y);
--shadow-offset-x: var(--shadow-offset-x);
--shadow-spread: var(--shadow-spread);
--shadow-blur: var(--shadow-blur);
--shadow-opacity: var(--shadow-opacity);
--color-shadow-color: var(--shadow-color);
--color-destructive-foreground: var(--destructive-foreground);
} }
@layer base { @layer base {
@ -191,7 +123,6 @@
} }
body { body {
@apply font-sans bg-background text-foreground; @apply font-sans bg-background text-foreground;
letter-spacing: var(--tracking-normal);
} }
html { html {
@apply font-sans; @apply font-sans;