docs: finalize report assets and polish dashboard UI

This commit is contained in:
Syahdan 2026-05-07 00:35:17 +07:00
parent 441e709193
commit a6edf804b4
42 changed files with 1084 additions and 406 deletions

View file

@ -1,7 +1,23 @@
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 { Activity, BookText, Globe, LayoutGrid, Server, Wrench } from "lucide-react"; import {
Activity,
BookText,
Globe,
LayoutGrid,
MenuIcon,
Server,
Wrench,
} from "lucide-react";
import { useEffect, useState } from "react";
const dashboardLinks = [ const dashboardLinks = [
{ {
@ -36,6 +52,48 @@ 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,
@ -47,25 +105,70 @@ 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>
<h1 className="text-2xl font-semibold tracking-tight">Operations overview for demos and day-to-day admin tasks</h1> </div>
<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 states, currently used to monitor my homelab setup. Use this area to review monitored assets, inspect detail
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">Signed in as</p> <p className="text-[11px] uppercase tracking-[0.16em] text-muted-foreground">
<p className="mt-1 text-sm font-medium">{userName ?? "Administrator"}</p> Signed in as
<p className="text-xs text-muted-foreground">{userEmail ?? "admin@minmon.local"}</p> </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">
@ -82,35 +185,14 @@ 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="h-fit border bg-card"> <aside className="hidden h-fit border bg-card lg:block">
<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>
<nav className="grid gap-1 p-2"> <NavItems pathname={pathname} />
{dashboardLinks.map(({ to, label, description, icon: Icon }) => {
const active = to === "/dashboard" ? pathname === to : 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

@ -178,44 +178,108 @@ 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 ? <p className="text-xs text-muted-foreground">{description}</p> : null} {description ? (
<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>
); );
} }
export function TextField(props: React.ComponentProps<typeof Input> & { label: string; description?: string }) { function describedBy(...ids: (string | undefined)[]): string | undefined {
const { label, description, id, ...inputProps } = props; const filtered = ids.filter((id): id is string => Boolean(id));
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={id ?? inputProps.name ?? label} description={description}> <FieldGroup label={label} htmlFor={fieldId} description={description} errors={errors}>
<Input id={id ?? inputProps.name ?? label} {...inputProps} /> <Input
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> & { label: string; description?: string }, props: React.ComponentProps<typeof Textarea> & {
label: string;
description?: string;
errors?: FieldError[];
},
) { ) {
const { label, description, id, ...textareaProps } = props; const { label, description, errors, 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={id ?? textareaProps.name ?? label} description={description}> <FieldGroup label={label} htmlFor={fieldId} description={description} errors={errors}>
<Textarea id={id ?? textareaProps.name ?? label} {...textareaProps} /> <Textarea
id={fieldId}
aria-invalid={hasError || undefined}
aria-describedby={describedBy(descriptionId, errorId)}
{...textareaProps}
/>
</FieldGroup> </FieldGroup>
); );
} }
@ -223,6 +287,7 @@ export function TextAreaField(
export function SelectField({ export function SelectField({
label, label,
description, description,
errors,
id, id,
name, name,
value, value,
@ -233,6 +298,7 @@ export function SelectField({
}: { }: {
label: string; label: string;
description?: string; description?: string;
errors?: FieldError[];
id?: string; id?: string;
name?: string; name?: string;
value: string; value: string;
@ -243,11 +309,21 @@ export function SelectField({
}) { }) {
const fieldId = id ?? name ?? label; const fieldId = id ?? name ?? label;
const selectedOption = options.find((option) => option.value === value); 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 ( return (
<FieldGroup label={label} htmlFor={fieldId} description={description}> <FieldGroup label={label} htmlFor={fieldId} description={description} errors={errors}>
<Select name={name} value={value} onValueChange={(nextValue) => onValueChange(nextValue ?? "")}> <Select name={name} value={value} onValueChange={(nextValue) => onValueChange(nextValue ?? "")}>
<SelectTrigger id={fieldId} className="w-full" onBlur={onBlur}> <SelectTrigger
id={fieldId}
className="w-full"
onBlur={onBlur}
aria-invalid={hasError || undefined}
aria-describedby={describedBy(descriptionId, errorId)}
>
<SelectValue placeholder={placeholder ?? "Select an option"}> <SelectValue placeholder={placeholder ?? "Select an option"}>
{selectedOption?.label} {selectedOption?.label}
</SelectValue> </SelectValue>
@ -299,7 +375,7 @@ export function ConfirmDeleteIconButton({
<div className="flex justify-center"> <div className="flex justify-center">
<Button <Button
type="button" type="button"
variant={isConfirming ? "destructive" : "destructive"} variant={isConfirming ? "destructive" : "ghost"}
size="icon" size="icon"
className="size-8" className="size-8"
disabled={disabled} disabled={disabled}
@ -326,15 +402,22 @@ 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-8 text-center " + (className ?? "")}> <div className={"border px-4 py-10 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

@ -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";
@ -35,7 +35,7 @@ export default function SignInForm() {
toast.success("Sign in successful"); toast.success("Sign in successful");
}, },
onError: (error) => { onError: (error) => {
toast.error(error.error.message || error.error.statusText); toast.error(formatErrorMessage(error, "Could not sign in"));
}, },
}, },
); );
@ -70,59 +70,51 @@ 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) => (
<div className="space-y-2"> <TextField
<Label htmlFor={field.name}>Email</Label> label="Email"
<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) => (
<div className="space-y-2"> <TextField
<Label htmlFor={field.name}>Password</Label> label="Password"
<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 type="submit" className="w-full" disabled={!canSubmit || isSubmitting}> <Button
{isSubmitting ? "Submitting..." : "Sign In"} type="submit"
className="w-full"
disabled={!canSubmit}
loading={isSubmitting}
>
Sign In
</Button> </Button>
)} )}
</form.Subscribe> </form.Subscribe>

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(error.error.message || error.error.statusText); toast.error(formatErrorMessage(error, "Could not sign up"));
}, },
}, },
); );
@ -66,92 +66,73 @@ 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) => (
<div className="space-y-2"> <TextField
<Label htmlFor={field.name}>Name</Label> label="Name"
<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) => (
<div className="space-y-2"> <TextField
<Label htmlFor={field.name}>Email</Label> label="Email"
<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) => (
<div className="space-y-2"> <TextField
<Label htmlFor={field.name}>Password</Label> label="Password"
<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 type="submit" className="w-full" disabled={!canSubmit || isSubmitting}> <Button
{isSubmitting ? "Submitting..." : "Sign Up"} type="submit"
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 <Button variant="link" onClick={onSwitchToSignIn}>
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

@ -41,8 +41,17 @@ 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");
await queryClient.invalidateQueries(); toast.error(
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),
}); });
@ -50,7 +59,8 @@ 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(); 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),
}); });
@ -80,13 +90,12 @@ function RouteComponent() {
}); });
}, [domain.data, form]); }, [domain.data, form]);
if (domain.isLoading || services.isLoading) { if (!domain.data || !services.data) {
return <QueryStateCard title="Loading domain" description="Fetching the selected domain details." />; if (domain.isError || services.isError) {
}
if (domain.isError || services.isError || !domain.data || !services.data) {
return <QueryStateCard title="Domain unavailable" description="The requested domain could not be loaded." />; 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) => ({ const serviceOptions = services.data.map((item) => ({
value: item.id, value: item.id,
@ -131,7 +140,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">{isSubmitting ? "Saving..." : "Save changes"}</Button>} {({ isSubmitting }) => <Button type="submit" loading={isSubmitting}>Save changes</Button>}
</form.Subscribe> </form.Subscribe>
</div> </div>
</> </>

View file

@ -29,8 +29,16 @@ 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");
await queryClient.invalidateQueries(); toast.error(
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),
@ -82,7 +90,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">{isSubmitting ? "Saving..." : "Save domain"}</Button>} {({ isSubmitting }) => <Button type="submit" loading={isSubmitting}>Save domain</Button>}
</form.Subscribe> </form.Subscribe>
</> </>
} }

View file

@ -113,7 +113,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">{isSubmitting ? "Saving..." : "Save changes"}</Button>} {({ isSubmitting }) => <Button type="submit" loading={isSubmitting}>Save changes</Button>}
</form.Subscribe> </form.Subscribe>
</> </>
} }

View file

@ -82,7 +82,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">{isSubmitting ? "Saving..." : "Save note"}</Button>} {({ isSubmitting }) => <Button type="submit" loading={isSubmitting}>Save note</Button>}
</form.Subscribe> </form.Subscribe>
</> </>
} }

View file

@ -180,7 +180,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">{isSubmitting ? "Saving..." : "Save changes"}</Button>} {({ isSubmitting }) => <Button type="submit" loading={isSubmitting}>Save changes</Button>}
</form.Subscribe> </form.Subscribe>
</> </>
} }

View file

@ -86,7 +86,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">{isSubmitting ? "Saving..." : "Save server"}</Button>} {({ isSubmitting }) => <Button type="submit" loading={isSubmitting}>Save server</Button>}
</form.Subscribe> </form.Subscribe>
</> </>
} }

View file

@ -155,7 +155,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">{isSubmitting ? "Saving..." : "Save changes"}</Button>} {({ isSubmitting }) => <Button type="submit" loading={isSubmitting}>Save changes</Button>}
</form.Subscribe> </form.Subscribe>
</> </>
} }

View file

@ -117,7 +117,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">{isSubmitting ? "Saving..." : "Save service"}</Button>} {({ isSubmitting }) => <Button type="submit" loading={isSubmitting}>Save service</Button>}
</form.Subscribe> </form.Subscribe>
</> </>
} }

View file

@ -0,0 +1,25 @@
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

@ -0,0 +1,37 @@
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

BIN
docs/diagrams/activity.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

61
docs/diagrams/class.mmd Normal file
View file

@ -0,0 +1,61 @@
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"

BIN
docs/diagrams/class.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

View file

@ -0,0 +1,29 @@
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.

After

Width:  |  Height:  |  Size: 48 KiB

View file

@ -0,0 +1,29 @@
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

BIN
docs/diagrams/sequence.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

View file

@ -0,0 +1,10 @@
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.

After

Width:  |  Height:  |  Size: 33 KiB

View file

@ -0,0 +1,25 @@
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

BIN
docs/diagrams/use-case.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

View file

@ -12,7 +12,6 @@
\usepackage{hyperref} \usepackage{hyperref}
\usepackage{xcolor} \usepackage{xcolor}
\usepackage{listings} \usepackage{listings}
\usepackage[most]{tcolorbox}
\usepackage{enumitem} \usepackage{enumitem}
\usepackage{titlesec} \usepackage{titlesec}
\usepackage{setspace} \usepackage{setspace}
@ -38,7 +37,6 @@
\definecolor{codebg}{RGB}{248,248,248} \definecolor{codebg}{RGB}{248,248,248}
\definecolor{bordergray}{RGB}{210,210,210} \definecolor{bordergray}{RGB}{210,210,210}
\definecolor{titleblue}{RGB}{25,66,122}
\lstdefinestyle{minmoncode}{ \lstdefinestyle{minmoncode}{
backgroundcolor=\color{codebg}, backgroundcolor=\color{codebg},
@ -53,37 +51,29 @@
stringstyle=\color{red!50!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{\uipath}[1]{\texttt{#1}}
\newcommand{\uibutton}[1]{\textbf{#1}} \newcommand{\uibutton}[1]{\textbf{#1}}
\newcommand{\uiscreenshot}[2]{
\newtcolorbox{infobox}[1][]{
colback=blue!3,
colframe=titleblue,
boxrule=0.6pt,
arc=0pt,
left=8pt,
right=8pt,
top=8pt,
bottom=8pt,
#1
}
\newtcolorbox{warningbox}[1][]{
colback=yellow!8,
colframe=orange!70!black,
boxrule=0.6pt,
arc=0pt,
left=8pt,
right=8pt,
top=8pt,
bottom=8pt,
#1
}
\newcommand{\placeholderfigure}[2]{
\begin{figure}[H] \begin{figure}[H]
\centering \centering
\fbox{\parbox[c][6cm][c]{0.82\textwidth}{\centering\textit{Placeholder gambar/diagram}\\[0.5em]#1}} \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} \caption{#2}
\end{figure} \end{figure}
} }
@ -96,19 +86,19 @@
{\Huge \textbf{MINMON}}\\[0.4cm] {\Huge \textbf{MINMON}}\\[0.4cm]
{\Large Dashboard Inventaris dan Monitoring Homelab Berbasis Web}\\[1.2cm] {\Large Dashboard Inventaris dan Monitoring Homelab Berbasis Web}\\[1.2cm]
\begin{infobox}[width=0.9\textwidth] \begin{tabular}{ll}
\textbf{Nama Mahasiswa} : [Isi Nama Anda]\\ \textbf{Nama Mahasiswa} & : \makebox[7cm]{\hrulefill}\\
\textbf{NIM} : [Isi NIM Anda]\\ \textbf{NIM} & : \makebox[7cm]{\hrulefill}\\
\textbf{Program Studi} : [Isi Program Studi]\\ \textbf{Program Studi} & : \makebox[7cm]{\hrulefill}\\
\textbf{Mata Kuliah} : [Isi Mata Kuliah]\\ \textbf{Mata Kuliah} & : \makebox[7cm]{\hrulefill}\\
\textbf{Dosen Pengampu} : [Isi Nama Dosen]\\ \textbf{Dosen Pengampu} & : \makebox[7cm]{\hrulefill}\\
\textbf{Institusi} : [Isi Nama Universitas / Sekolah]\\ \textbf{Institusi} & : \makebox[7cm]{\hrulefill}\\
\textbf{Tahun Akademik} : [Isi Tahun Akademik] \textbf{Tahun Akademik} & : \makebox[7cm]{\hrulefill}
\end{infobox} \end{tabular}
\vfill \vfill
{\large Dokumen ini disusun untuk mendeskripsikan proyek \textit{Minmon} yang digunakan untuk mendata dan memonitor inventaris homelab, khususnya lingkungan kecil yang umumnya terdiri dari 2 server utama dengan banyak service dan domain terkait.}\\[1cm] {\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} {\large \today}
\end{titlepage} \end{titlepage}
@ -119,31 +109,31 @@
\chapter{Pendahuluan} \chapter{Pendahuluan}
\section{Latar Belakang} \section{Latar Belakang}
Homelab modern tidak lagi hanya berisi satu mesin dengan satu layanan. Dalam praktiknya, satu homelab kecil dapat terdiri dari beberapa server, banyak service seperti reverse proxy, dashboard, media server, database, automation tools, 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. 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 biasanya terdiri dari 2 server utama dengan banyak service dan domain yang perlu dicatat serta dipantau. 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 note, 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. 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} \section{Tujuan}
Tujuan dari pengembangan proyek ini adalah sebagai berikut: Tujuan dari pengembangan proyek ini adalah sebagai berikut:
\begin{itemize} \begin{itemize}
\item Membangun aplikasi web yang dapat digunakan untuk mengelola inventaris homelab secara terstruktur. \item Membangun aplikasi web yang dapat digunakan untuk mengelola inventaris homelab secara terstruktur.
\item Menyediakan fitur CRUD untuk data server, service, domain, dan note dalam satu dashboard terproteksi. \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 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 note yang melekat pada server tertentu. \item Mempermudah dokumentasi relasi data, misalnya service yang berjalan pada server tertentu dan catatan yang melekat pada server tertentu.
\item Menjadi contoh implementasi full-stack berbasis React, tRPC, Drizzle ORM, PostgreSQL, dan better-auth. \item Menjadi contoh implementasi aplikasi web penuh berbasis React, tRPC, Drizzle ORM, PostgreSQL, dan better-auth.
\end{itemize} \end{itemize}
\section{Manfaat} \section{Manfaat}
Manfaat dari proyek ini antara lain: Manfaat dari proyek ini antara lain:
\begin{itemize} \begin{itemize}
\item Membantu pemilik homelab mendokumentasikan server, service, domain, dan note dalam satu tempat. \item Membantu pemilik homelab mendokumentasikan server, service, domain, dan catatan dalam satu tempat.
\item Mengurangi kesalahan input relasi data karena pilihan parent-child dibuat melalui select input. \item Mengurangi kesalahan input relasi data karena hubungan antar data dipilih melalui field pilihan.
\item Memudahkan pengecekan domain melalui fitur DNS check berbasis A record / IPv4. \item Memudahkan pengecekan domain melalui fitur pemeriksaan DNS berbasis A record / IPv4.
\item Memberikan gambaran ringkas kondisi homelab melalui dashboard summary. \item Memberikan gambaran ringkas kondisi homelab melalui ringkasan dashboard.
\item Menjadi bahan laporan akademik dan contoh produk perangkat lunak yang benar-benar dapat dipakai. \item Menjadi bahan laporan akademik dan contoh produk perangkat lunak yang benar-benar dapat dipakai.
\end{itemize} \end{itemize}
@ -154,59 +144,58 @@ Tahap pengembangan proyek Minmon dapat dijelaskan secara ringkas sebagai berikut
\begin{enumerate} \begin{enumerate}
\item \textbf{Identifikasi kebutuhan}\\ \item \textbf{Identifikasi kebutuhan}\\
Menentukan kebutuhan utama untuk inventaris homelab, yaitu pengelolaan data server, service, domain, dan note, serta kebutuhan monitoring DNS sederhana. 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}\\ \item \textbf{Perancangan data dan relasi}\\
Menentukan entitas inti: \texttt{server}, \texttt{service}, \texttt{domain}, dan \texttt{note}. Setiap service terkait ke satu server, setiap domain terkait ke satu service, dan setiap note terkait ke satu server. 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}\\ \item \textbf{Perancangan backend}\\
Mengimplementasikan router tRPC untuk operasi list, detail, create, update, delete, serta ringkasan dashboard. Pemeriksaan DNS diimplementasikan sebagai mutation khusus pada domain. 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}\\ \item \textbf{Perancangan frontend}\\
Membangun halaman login, dashboard summary, halaman list, halaman create, dan halaman detail/edit untuk setiap entitas. Form relasi dibuat menggunakan select agar lebih aman dan mudah dipakai. 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}\\ \item \textbf{Integrasi dan validasi}\\
Menghubungkan frontend ke backend dengan TanStack Query dan tRPC client, lalu memastikan mutasi, invalidasi query, serta tampilan ringkasan berjalan dengan baik. 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}\\ \item \textbf{Pengujian dan penyempurnaan}\\
Melakukan typecheck, pengecekan alur CRUD, pengecekan DNS, perbaikan kestabilan urutan list, dan penyempurnaan interaksi UI seperti delete dua langkah dan trigger DNS dari status badge. 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} \end{enumerate}
\section{Identitas/Keunikan Produk} \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: Minmon memiliki identitas produk sebagai dashboard inventaris dan monitoring ringan untuk homelab. Keunikan produk ini dibanding pencatatan manual biasa adalah sebagai berikut:
\begin{itemize} \begin{itemize}
\item Fokus pada skenario homelab kecil-menengah, misalnya 2 server dengan banyak service dan domain. \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 Menggabungkan inventaris dan dokumentasi operasional dalam satu aplikasi.
\item Menyediakan relasi data yang jelas antara server, service, domain, dan note. \item Menyediakan relasi data yang jelas antara server, service, domain, dan catatan.
\item Memiliki DNS A-record checking yang terintegrasi langsung pada alur domain. \item Memiliki pemeriksaan DNS A record yang terintegrasi langsung pada alur domain.
\item Menampilkan note yang terkait langsung pada halaman detail server. \item Menampilkan catatan yang terkait langsung pada halaman detail server.
\item Menggunakan autentikasi admin sehingga dashboard tidak dapat diakses tanpa sesi. \item Menggunakan autentikasi admin sehingga dashboard tidak dapat diakses tanpa sesi.
\end{itemize} \end{itemize}
\begin{infobox}[title=Keunikan Praktis Produk] 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.
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 note operasional pada setiap server.
\end{infobox}
\section{Diagram-diagram UML (use case, class, activity, state chart, sequence, collaboration)} \section{Diagram-diagram UML (use case, class, activity, state chart, sequence, collaboration)}
Bagian ini dapat diisi dengan diagram UML final. Struktur dan isi diagram sebaiknya disesuaikan dengan implementasi saat ini. 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} \subsection{Use Case Diagram}
Use case utama dalam sistem ini meliputi: Use case utama dalam sistem ini meliputi:
\begin{itemize} \begin{itemize}
\item Admin sign in \item Admin sign in
\item Admin mengakses dashboard summary \item Admin mengakses ringkasan dashboard
\item Admin mengelola server \item Admin mengelola server
\item Admin mengelola service \item Admin mengelola service
\item Admin mengelola domain \item Admin mengelola domain
\item Admin menjalankan DNS check \item Admin menjalankan pemeriksaan DNS dari halaman daftar atau detail domain
\item Admin mengelola note \item UI menjalankan pemeriksaan DNS setelah domain dibuat atau diperbarui
\item Admin melihat note pada detail server \item Admin mengelola catatan
\item Admin melihat catatan pada detail server
\item Admin sign out \item Admin sign out
\end{itemize} \end{itemize}
\placeholderfigure{Tambahkan diagram use case hasil export UML Anda di sini.}{Use Case Diagram Minmon} \uidiagram{diagrams/use-case.png}{Use Case Diagram Minmon}
\subsection{Class Diagram} \subsection{Class Diagram}
Entitas utama pada sistem adalah: Entitas utama pada sistem adalah:
@ -215,33 +204,33 @@ Entitas utama pada sistem adalah:
\item \textbf{Server}: id, name, primaryIpAddress, secondaryIpAddress, operatingSystem, location, provider, description, status, createdAt, updatedAt \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{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{Domain}: id, serviceId, name, expectedServerIp, lastResolvedIp, lastResolvedIps, resolutionStatus, lastCheckedAt, remarks, lastCheckMessage, createdAt, updatedAt
\item \textbf{Note}: id, serverId, title, content, category, createdAt, updatedAt \item \textbf{Catatan}: id, serverId, title, content, category, createdAt, updatedAt
\end{itemize} \end{itemize}
Hubungan utama: Hubungan utama:
\begin{itemize} \begin{itemize}
\item Server 1..* Service \item Server 1..* Service secara konseptual, tetapi secara database dapat 0..*.
\item Server 1..* Note \item Server 1..* Catatan secara konseptual, tetapi secara database dapat 0..*.
\item Service 1..* Domain \item Service 1..* Domain secara konseptual, tetapi secara database dapat 0..*.
\end{itemize} \end{itemize}
\placeholderfigure{Tambahkan diagram class hasil export UML Anda di sini.}{Class Diagram Minmon} \uidiagram{diagrams/class.png}{Class Diagram Minmon}
\subsection{Activity Diagram} \subsection{Activity Diagram}
Activity diagram yang disarankan adalah alur \textit{Check DNS from Domain List}: 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} \begin{enumerate}
\item Admin membuka halaman domain list. \item Admin membuat, memperbarui, atau memeriksa domain.
\item Admin menekan badge status DNS. \item UI memanggil operasi \texttt{domains.create}, \texttt{domains.update}, atau \texttt{domains.check} sesuai aksi.
\item UI memanggil mutation \texttt{domains.check}. \item Setelah create/update berhasil, UI memanggil \texttt{domains.check} untuk menjalankan pemeriksaan DNS.
\item Backend melakukan resolve IPv4 A record. \item Backend melakukan resolve IPv4 A record.
\item Backend membandingkan hasil resolve dengan \texttt{expectedServerIp}. \item Backend membandingkan hasil resolve dengan \texttt{expectedServerIp}.
\item Backend menyimpan hasil ke database. \item Backend menyimpan status dan hasil resolve ke database.
\item UI me-refresh data dan menampilkan status baru. \item UI memperbarui ulang data dan menampilkan status baru.
\end{enumerate} \end{enumerate}
\placeholderfigure{Tambahkan activity diagram di sini.}{Activity Diagram DNS Check} \uidiagram{diagrams/activity.png}{Activity Diagram DNS Check}
\subsection{State Chart Diagram} \subsection{State Chart Diagram}
State chart yang disarankan adalah state untuk status DNS domain: State chart yang disarankan adalah state untuk status DNS domain:
@ -260,134 +249,143 @@ Transisi utama:
\item DNS gagal resolve $\rightarrow$ UNRESOLVED \item DNS gagal resolve $\rightarrow$ UNRESOLVED
\end{itemize} \end{itemize}
\placeholderfigure{Tambahkan state chart diagram di sini.}{State Chart Domain DNS} \uidiagram{diagrams/state-chart.png}{State Chart Domain DNS}
\subsection{Sequence Diagram} \subsection{Sequence Diagram}
Sequence diagram yang cocok untuk proyek ini adalah alur \textit{Create Domain and Check DNS}: 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} \begin{itemize}
\item Admin \item Admin
\item Web UI \item Web UI
\item tRPC API \item tRPC client/API
\item Database \item Database
\item Domain check module
\item DNS Resolver \item DNS Resolver
\end{itemize} \end{itemize}
\placeholderfigure{Tambahkan sequence diagram di sini.}{Sequence Diagram Create Domain and Check DNS} \uidiagram{diagrams/sequence.png}{Sequence Diagram Create Domain and Check DNS}
\subsection{Collaboration Diagram} \subsection{Collaboration Diagram}
Collaboration diagram dapat menunjukkan interaksi antara: Collaboration diagram menunjukkan interaksi aktual antara route terproteksi, halaman dashboard, TanStack Query, router tRPC, database, dan modul pemeriksaan DNS:
\begin{itemize} \begin{itemize}
\item Admin \item Admin
\item Dashboard UI \item Dashboard UI dan route terproteksi
\item better-auth client \item better-auth client
\item tRPC router \item TanStack Query dan tRPC router
\item Database \item Database
\item DNS lookup module \item Modul DNS lookup
\end{itemize} \end{itemize}
\placeholderfigure{Tambahkan collaboration diagram di sini.}{Collaboration Diagram Minmon} \uidiagram{diagrams/collaboration.png}{Collaboration Diagram Minmon}
\section{Potongan Code Fitur-Fitur Utama} \section{Coding Minimal 80 Baris}
Bagian ini menampilkan beberapa potongan kode yang mewakili fitur inti dari implementasi saat ini. 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.
\subsection{Redirect Root Berdasarkan Session} \begin{lstlisting}[style=minmoncode,language=TypeScript,caption={Kode servicesRouter: CRUD Service dan validasi relasi, 82 baris}]
Potongan berikut menunjukkan bahwa route \uipath{/} akan mengarahkan pengguna ke \uipath{/dashboard} atau \uipath{/login} berdasarkan session yang didapat dari auth client. import { db } from "@minmon/db";
import { domain, service } from "@minmon/db";
import { count, desc, eq } from "drizzle-orm";
\begin{lstlisting}[style=minmoncode,language=TypeScript,caption={Root redirect berdasarkan session}] import { protectedProcedure, router } from "../index";
export const Route = createFileRoute("/")({ import {
beforeLoad: async () => { byIdSchema,
const session = await authClient.getSession(); serviceCreateSchema,
serviceListSchema,
serviceUpdateSchema,
} from "./schemas";
import {
createEntityId,
requireServer,
requireService,
stripUndefined,
} from "./utils";
throw redirect({ export const servicesRouter = router({
to: session.data ? "/dashboard" : "/login", list: protectedProcedure.input(serviceListSchema).query(async ({ input }) => {
}); if (!input?.serverId) {
}, return db.select().from(service).orderBy(desc(service.createdAt), desc(service.id));
component: () => null, }
});
\end{lstlisting}
\subsection{Ordering List yang Stabil} await requireServer(input.serverId);
List utama pada CRUD saat ini diurutkan berdasarkan \texttt{createdAt DESC} lalu \texttt{id DESC} agar urutan tetap deterministik ketika data di-refresh.
\begin{lstlisting}[style=minmoncode,language=TypeScript,caption={Stable ordering pada list router}]
return db return db
.select() .select()
.from(domain) .from(service)
.orderBy(desc(domain.createdAt), desc(domain.id)); .where(eq(service.serverId, input.serverId))
\end{lstlisting} .orderBy(desc(service.createdAt), desc(service.id));
\subsection{Pemeriksaan DNS A Record}
Pemeriksaan DNS dilakukan dengan resolve IPv4 A record dan membandingkannya dengan IP yang diharapkan.
\begin{lstlisting}[style=minmoncode,language=TypeScript,caption={Logika DNS check}]
export function checkDomainDns(target: DomainCheckTarget) {
return Effect.match(resolveIpv4ARecords(target.name), {
onSuccess: (resolvedIps: string[]): DomainCheckResult => ({
status: resolvedIps.includes(target.expectedServerIp)
? "MATCH"
: "MISMATCH",
resolvedIps,
checkedAt: new Date(),
message: null,
}), }),
onFailure: (error: Error): DomainCheckResult => ({
status: "UNRESOLVED", byId: protectedProcedure.input(byIdSchema).query(async ({ input }) => {
resolvedIps: [], const item = await requireService(input.id);
checkedAt: new Date(), const [domainCount] = await db.select({ count: count() }).from(domain).where(eq(domain.serviceId, input.id));
message: error.message,
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} \end{lstlisting}
\subsection{Select Input untuk Relasi Data} Penjelasan singkat kode di atas:
Form relasi tidak lagi menggunakan input teks biasa untuk foreign key utama, tetapi menggunakan select agar lebih aman. \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}.
\begin{lstlisting}[style=minmoncode,language=TypeScript,caption={Contoh select relasi note -> server}] \item \texttt{byId} mengambil detail service dan menambahkan ringkasan jumlah domain yang terhubung ke service tersebut.
<form.Field name="serverId"> \item \texttt{create} memvalidasi server induk terlebih dahulu sebelum menyimpan service baru.
{(field) => ( \item \texttt{update} memastikan service yang diedit ada, lalu memvalidasi server baru jika relasi \texttt{serverId} diubah.
<SelectField \item \texttt{delete} memastikan data service ada sebelum dihapus dari database.
label="Server" \end{itemize}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onValueChange={field.handleChange}
placeholder="Select a server"
options={serverOptions}
/>
)}
</form.Field>
\end{lstlisting}
\subsection{Delete Dua Langkah dengan Ikon}
Untuk mencegah salah hapus, aksi delete pada list menggunakan dua tahap klik: ikon trash lalu ikon check.
\begin{lstlisting}[style=minmoncode,language=TypeScript,caption={Confirm delete icon button}]
if (isConfirming) {
onConfirm();
setIsConfirming(false);
return;
}
setIsConfirming(true);
\end{lstlisting}
\chapter{Manual Book} \chapter{Manual Book}
\section{Gambaran Umum Penggunaan} \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. 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.
\begin{warningbox}[title=Data yang Masih Perlu Anda Lengkapi]
Untuk versi final laporan, Anda sebaiknya menambahkan screenshot nyata dari aplikasi Anda sendiri, termasuk nama server homelab, contoh service, dan domain yang benar-benar Anda gunakan.
\end{warningbox}
\section{Login ke Sistem} \section{Login ke Sistem}
\textbf{Path akses:} \uipath{/login} \textbf{Path akses:} \uipath{/login}
\begin{enumerate} \begin{enumerate}
\item Jalankan aplikasi web dan server. \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 Buka browser ke alamat \uipath{http://localhost:5173}.
\item Jika belum login, sistem akan mengarahkan ke \uipath{/login}. \item Jika belum login, sistem akan mengarahkan ke \uipath{/login}.
\item Masukkan email admin dan password. \item Masukkan email admin dan password.
@ -396,7 +394,9 @@ Untuk versi final laporan, Anda sebaiknya menambahkan screenshot nyata dari apli
\textbf{Hasil yang diharapkan:} pengguna masuk ke halaman \uipath{/dashboard}. \textbf{Hasil yang diharapkan:} pengguna masuk ke halaman \uipath{/dashboard}.
\placeholderfigure{Tambahkan screenshot halaman login di sini.}{Halaman Login Minmon} 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} \section{Membuka Dashboard Summary}
\textbf{Path akses:} \uipath{/dashboard} \textbf{Path akses:} \uipath{/dashboard}
@ -409,17 +409,19 @@ Halaman dashboard menampilkan ringkasan:
\item total domain, \item total domain,
\item jumlah DNS match / mismatch / unresolved, \item jumlah DNS match / mismatch / unresolved,
\item service yang inactive/down, \item service yang inactive/down,
\item recent notes. \item catatan terbaru.
\end{itemize} \end{itemize}
\textbf{Langkah penggunaan:} \textbf{Langkah penggunaan:}
\begin{enumerate} \begin{enumerate}
\item Login sebagai admin. \item Login sebagai admin.
\item Setelah redirect ke dashboard, periksa kartu ringkasan di bagian atas. \item Setelah diarahkan ke dashboard, periksa kartu ringkasan di bagian atas.
\item Gunakan link area untuk membuka halaman Servers, Services, Domains, atau Notes. \item Gunakan link area untuk membuka halaman Servers, Services, Domains, atau Catatan.
\end{enumerate} \end{enumerate}
\placeholderfigure{Tambahkan screenshot dashboard summary di sini.}{Dashboard Summary} 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} \section{Mengelola Data Server}
\textbf{Path akses:} \uipath{/dashboard/servers} \textbf{Path akses:} \uipath{/dashboard/servers}
@ -434,6 +436,10 @@ Halaman dashboard menampilkan ringkasan:
\textbf{Hasil yang diharapkan:} data server tersimpan dan pengguna diarahkan ke halaman detail server. \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} \subsection{Melihat Detail Server}
\textbf{Path akses:} \uipath{/dashboard/servers/\$serverId} \textbf{Path akses:} \uipath{/dashboard/servers/\$serverId}
@ -442,10 +448,12 @@ Pada halaman ini pengguna dapat:
\begin{itemize} \begin{itemize}
\item melihat ringkasan server, \item melihat ringkasan server,
\item mengedit data server, \item mengedit data server,
\item melihat note yang terkait dengan server tersebut. \item melihat catatan yang terkait dengan server tersebut.
\end{itemize} \end{itemize}
\placeholderfigure{Tambahkan screenshot detail server di sini.}{Halaman Detail Server} 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} \section{Mengelola Data Service}
\textbf{Path akses:} \uipath{/dashboard/services} \textbf{Path akses:} \uipath{/dashboard/services}
@ -454,13 +462,17 @@ Pada halaman ini pengguna dapat:
\begin{enumerate} \begin{enumerate}
\item Buka menu Services. \item Buka menu Services.
\item Klik \uibutton{Add service}. \item Klik \uibutton{Add service}.
\item Pilih server parent pada field \textit{Server}. \item Pilih server induk pada field \textit{Server}.
\item Isi nama service, type, port, protocol, status, dan deskripsi. \item Isi nama service, tipe, port, protokol, status, dan deskripsi.
\item Klik \uibutton{Save service}. \item Klik \uibutton{Save service}.
\end{enumerate} \end{enumerate}
\textbf{Hasil yang diharapkan:} service tersimpan dan terhubung ke server yang dipilih. \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} \section{Mengelola Data Domain}
\textbf{Path akses:} \uipath{/dashboard/domains} \textbf{Path akses:} \uipath{/dashboard/domains}
@ -468,50 +480,58 @@ Pada halaman ini pengguna dapat:
\begin{enumerate} \begin{enumerate}
\item Buka menu Domains. \item Buka menu Domains.
\item Klik \uibutton{Add domain}. \item Klik \uibutton{Add domain}.
\item Pilih service parent pada field \textit{Service}. \item Pilih service induk pada field \textit{Service}.
\item Isi nama domain dan expected IP bila diperlukan. \item Isi nama domain dan expected IP bila diperlukan.
\item Klik \uibutton{Save domain}. \item Klik \uibutton{Save domain}.
\end{enumerate} \end{enumerate}
\textbf{Catatan:} jika expected IP dikosongkan, backend akan menggunakan IP server parent dari service terkait. \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} \subsection{Menjalankan DNS Check}
\begin{enumerate} \begin{enumerate}
\item Buka halaman domain list. \item Buka halaman daftar domain.
\item Tekan badge status DNS pada row domain yang ingin dicek. \item Tekan badge status DNS pada baris domain yang ingin dicek.
\item Tunggu proses mutation selesai. \item Tunggu proses pemeriksaan selesai.
\item Lihat hasil baru pada kolom status dan IP hasil resolve. \item Lihat hasil baru pada kolom status dan IP hasil resolve.
\end{enumerate} \end{enumerate}
\textbf{Hasil yang diharapkan:} status berubah menjadi \texttt{MATCH}, \texttt{MISMATCH}, atau \texttt{UNRESOLVED}. \textbf{Hasil yang diharapkan:} status berubah menjadi \texttt{MATCH}, \texttt{MISMATCH}, atau \texttt{UNRESOLVED}.
\placeholderfigure{Tambahkan screenshot domain list dan DNS check di sini.}{Domain List dan DNS Check} 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.
\section{Mengelola Data Note} \uiscreenshot{screenshots/manual-domains-list.png}{Halaman Daftar Domain dan Status DNS}
\section{Mengelola Data Catatan}
\textbf{Path akses:} \uipath{/dashboard/notes} \textbf{Path akses:} \uipath{/dashboard/notes}
\subsection{Menambah Note} \subsection{Menambah Catatan}
\begin{enumerate} \begin{enumerate}
\item Buka menu Notes. \item Buka menu Catatan.
\item Klik \uibutton{New note}. \item Klik tombol tambah catatan.
\item Pilih server pada field \textit{Server}. \item Pilih server pada field \textit{Server}.
\item Isi title, category, dan content. \item Isi judul, kategori, dan isi catatan.
\item Klik \uibutton{Save note}. \item Klik tombol simpan catatan.
\end{enumerate} \end{enumerate}
\textbf{Hasil yang diharapkan:} note tersimpan dan terhubung ke server terpilih. \textbf{Hasil yang diharapkan:} catatan tersimpan dan terhubung ke server terpilih.
\subsection{Melihat Note pada Detail Server} \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} \begin{enumerate}
\item Buka halaman detail server. \item Buka halaman detail server.
\item Scroll ke bagian \textit{Server notes}. \item Scroll ke bagian catatan server.
\item Lihat daftar note yang hanya terkait dengan server tersebut. \item Lihat daftar catatan yang hanya terkait dengan server tersebut.
\end{enumerate} \end{enumerate}
\textbf{Hasil yang diharapkan:} note yang tampil hanyalah note dengan \texttt{serverId} yang sesuai. \textbf{Hasil yang diharapkan:} catatan yang tampil hanyalah catatan dengan \texttt{serverId} yang sesuai.
\section{Aksi Hapus Data} \section{Aksi Hapus Data}
Pada list server, service, domain, dan note, aksi hapus saat ini menggunakan alur dua langkah: Pada daftar server, service, domain, dan catatan, aksi hapus saat ini menggunakan alur dua langkah:
\begin{enumerate} \begin{enumerate}
\item Klik ikon trash. \item Klik ikon trash.
@ -521,7 +541,7 @@ Pada list server, service, domain, dan note, aksi hapus saat ini menggunakan alu
\textbf{Catatan penting:} \textbf{Catatan penting:}
\begin{itemize} \begin{itemize}
\item Hapus server akan menghapus service, domain, dan note terkait melalui cascade delete. \item Hapus server akan menghapus service, domain, dan catatan terkait melalui cascade delete.
\item Hapus service akan menghapus domain terkait melalui cascade delete. \item Hapus service akan menghapus domain terkait melalui cascade delete.
\end{itemize} \end{itemize}
@ -529,31 +549,20 @@ Pada list server, service, domain, dan note, aksi hapus saat ini menggunakan alu
\section*{Kesimpulan} \section*{Kesimpulan}
\addcontentsline{toc}{section}{Kesimpulan} \addcontentsline{toc}{section}{Kesimpulan}
Minmon berhasil diimplementasikan sebagai aplikasi web untuk inventaris dan monitoring ringan homelab. Sistem ini sudah mencakup autentikasi admin, dashboard summary, CRUD untuk server, service, domain, dan note, serta DNS A-record checking. Dalam konteks penggunaan homelab pribadi dengan sekitar 2 server dan banyak service/domain, aplikasi ini sudah cukup representatif untuk membantu pencatatan aset, dokumentasi operasional, dan validasi domain. 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 note 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. 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} \section*{Saran}
\addcontentsline{toc}{section}{Saran} \addcontentsline{toc}{section}{Saran}
Saran pengembangan untuk tahap berikutnya antara lain: Saran pengembangan untuk tahap berikutnya antara lain:
\begin{itemize} \begin{itemize}
\item Menambahkan fitur search, filter, dan pagination pada halaman list. \item Menambahkan fitur pencarian, filter, dan pagination pada halaman daftar data.
\item Menambahkan histori hasil DNS check agar perubahan status domain dapat dilacak dari waktu ke waktu. \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 multi-role authentication jika aplikasi ingin dipakai lebih dari satu pengguna.
\item Menambahkan dukungan monitoring lain selain DNS A record, misalnya SSL expiry, ping, atau health endpoint HTTP. \item Menambahkan dukungan monitoring lain selain DNS A record, misalnya masa berlaku SSL, ping, atau health endpoint HTTP.
\item Menambahkan screenshot final dan diagram UML final agar dokumen ini siap dikumpulkan sebagai laporan akademik lengkap. \item Menambahkan histori aktivitas agar perubahan data server, service, domain, dan catatan dapat ditelusuri lebih mudah.
\end{itemize} \end{itemize}
\begin{infobox}[title=Data yang Sebaiknya Anda Kirim Jika Ingin Saya Finalkan]
Saya masih bisa finalkan dokumen ini lebih jauh jika Anda mengirim:
\begin{itemize}
\item nama, NIM, kampus, mata kuliah, dan dosen,
\item nama asli 2 server homelab Anda,
\item contoh service/domain nyata yang ingin dimasukkan,
\item screenshot UI,
\item atau file diagram UML final.
\end{itemize}
\end{infobox}
\end{document} \end{document}

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

View file

@ -1,9 +1,10 @@
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 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", "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",
{ {
variants: { variants: {
variant: { variant: {
@ -41,14 +42,40 @@ function Button({
className, className,
variant = "default", variant = "default",
size = "default", size = "default",
loading = false,
disabled,
children,
...props ...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) { }: ButtonPrimitive.Props &
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

@ -0,0 +1,132 @@
"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

@ -0,0 +1,135 @@
"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,6 +4,9 @@ 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,9 +21,10 @@ 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, ...props }: React.ComponentProps<"th">) { function TableHead({ className, scope = "col", ...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,