docs: add report draft and dashboard form refinements
This commit is contained in:
parent
4e0cf0140f
commit
441e709193
26 changed files with 1700 additions and 505 deletions
|
|
@ -58,7 +58,7 @@ export function DashboardShell({
|
||||||
</p>
|
</p>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Operations overview for demos and day-to-day admin tasks</h1>
|
<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, and stage CRUD forms that can connect to tRPC routers as they are added.
|
Use this area to review monitored assets, inspect detail states, currently used to monitor my homelab setup.
|
||||||
</p>
|
</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">
|
||||||
|
|
@ -91,7 +91,7 @@ export function DashboardShell({
|
||||||
</div>
|
</div>
|
||||||
<nav className="grid gap-1 p-2">
|
<nav className="grid gap-1 p-2">
|
||||||
{dashboardLinks.map(({ to, label, description, icon: Icon }) => {
|
{dashboardLinks.map(({ to, label, description, icon: Icon }) => {
|
||||||
const active = pathname === to || pathname.startsWith(`${to}/`);
|
const active = to === "/dashboard" ? pathname === to : pathname === to || pathname.startsWith(`${to}/`);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,13 @@ 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,
|
||||||
|
|
@ -21,8 +28,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 { ChevronRight } from "lucide-react";
|
import { Check, ChevronRight, Trash2 } from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
|
|
||||||
type BadgeVariant = "neutral" | "success" | "warning" | "destructive" | "info";
|
type BadgeVariant = "neutral" | "success" | "warning" | "destructive" | "info";
|
||||||
|
|
||||||
|
|
@ -66,7 +73,7 @@ export function SummaryCard({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ResourceListCard<TItem>({
|
export function ResourceListCard<TItem extends { id: string }>({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
columns,
|
columns,
|
||||||
|
|
@ -82,7 +89,7 @@ export function ResourceListCard<TItem>({
|
||||||
emptyDescription: string;
|
emptyDescription: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Card className="gap-0">
|
<Card className="py-0 pt-4 gap-0">
|
||||||
<CardHeader className="border-b">
|
<CardHeader className="border-b">
|
||||||
<CardTitle>{title}</CardTitle>
|
<CardTitle>{title}</CardTitle>
|
||||||
<CardDescription>{description}</CardDescription>
|
<CardDescription>{description}</CardDescription>
|
||||||
|
|
@ -101,8 +108,8 @@ export function ResourceListCard<TItem>({
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{items.map((item, index) => (
|
{items.map((item) => (
|
||||||
<TableRow key={index}>
|
<TableRow key={item.id}>
|
||||||
{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)}
|
||||||
|
|
@ -213,6 +220,108 @@ export function TextAreaField(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SelectField({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
onBlur,
|
||||||
|
placeholder,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
value: string;
|
||||||
|
onValueChange: (value: string) => void;
|
||||||
|
onBlur?: React.FocusEventHandler<HTMLButtonElement>;
|
||||||
|
placeholder?: string;
|
||||||
|
options: { value: string; label: string }[];
|
||||||
|
}) {
|
||||||
|
const fieldId = id ?? name ?? label;
|
||||||
|
const selectedOption = options.find((option) => option.value === value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FieldGroup label={label} htmlFor={fieldId} description={description}>
|
||||||
|
<Select name={name} value={value} onValueChange={(nextValue) => onValueChange(nextValue ?? "")}>
|
||||||
|
<SelectTrigger id={fieldId} className="w-full" onBlur={onBlur}>
|
||||||
|
<SelectValue placeholder={placeholder ?? "Select an option"}>
|
||||||
|
{selectedOption?.label}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FieldGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmDeleteIconButton({
|
||||||
|
itemLabel,
|
||||||
|
onConfirm,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
itemLabel: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const [isConfirming, setIsConfirming] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isConfirming) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
setIsConfirming(false);
|
||||||
|
}, 4000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
|
}, [isConfirming]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (disabled) {
|
||||||
|
setIsConfirming(false);
|
||||||
|
}
|
||||||
|
}, [disabled]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={isConfirming ? "destructive" : "destructive"}
|
||||||
|
size="icon"
|
||||||
|
className="size-8"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={isConfirming ? `Confirm delete ${itemLabel}` : `Delete ${itemLabel}`}
|
||||||
|
onBlur={() => setIsConfirming(false)}
|
||||||
|
onClick={() => {
|
||||||
|
if (isConfirming) {
|
||||||
|
onConfirm();
|
||||||
|
setIsConfirming(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsConfirming(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isConfirming ? <Check /> : <Trash2 />}
|
||||||
|
<span className="sr-only">{isConfirming ? `Confirm delete ${itemLabel}` : `Delete ${itemLabel}`}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function EmptyState({
|
export function EmptyState({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { Button } from "@minmon/ui/components/button";
|
||||||
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 { useForm } from "@tanstack/react-form";
|
import { useForm } from "@tanstack/react-form";
|
||||||
import { Link, 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";
|
||||||
|
|
||||||
|
|
@ -53,10 +53,11 @@ export default function SignInForm() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto mt-10 grid w-full max-w-md gap-6 border bg-card p-6">
|
<div className="mx-auto flex min-h-[calc(100dvh-8rem)] w-full max-w-5xl items-center justify-center px-4 py-10">
|
||||||
|
<div className="grid w-full max-w-md gap-6 border bg-card p-6">
|
||||||
<div className="space-y-2 text-center">
|
<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 to minmon</h1>
|
<h1 className="text-3xl font-bold tracking-tight">Sign in</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>
|
||||||
|
|
@ -129,9 +130,7 @@ 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>
|
||||||
<Link to="/dashboard" className="text-primary underline-offset-4 hover:underline">
|
</div>
|
||||||
Open dashboard after sign in
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 DashboardServicesServiceIdRouteImport } from './routes/dashboard.services_.$serviceId'
|
import { Route as DashboardServicesServiceIdRouteImport } from './routes/dashboard.services_.$serviceId'
|
||||||
import { Route as DashboardServicesNewRouteImport } from './routes/dashboard.services.new'
|
import { Route as DashboardServersNewRouteImport } from './routes/dashboard.servers_.new'
|
||||||
import { Route as DashboardServersServerIdRouteImport } from './routes/dashboard.servers_.$serverId'
|
import { Route as DashboardServersServerIdRouteImport } from './routes/dashboard.servers_.$serverId'
|
||||||
import { Route as DashboardServersNewRouteImport } from './routes/dashboard.servers.new'
|
import { Route as DashboardNotesNewRouteImport } from './routes/dashboard.notes_.new'
|
||||||
import { Route as DashboardNotesNoteIdRouteImport } from './routes/dashboard.notes_.$noteId'
|
import { Route as DashboardNotesNoteIdRouteImport } from './routes/dashboard.notes_.$noteId'
|
||||||
import { Route as DashboardNotesNewRouteImport } from './routes/dashboard.notes.new'
|
import { Route as DashboardDomainsNewRouteImport } from './routes/dashboard.domains_.new'
|
||||||
import { Route as DashboardDomainsDomainIdRouteImport } from './routes/dashboard.domains_.$domainId'
|
import { Route as DashboardDomainsDomainIdRouteImport } from './routes/dashboard.domains_.$domainId'
|
||||||
import { Route as DashboardDomainsNewRouteImport } from './routes/dashboard.domains.new'
|
|
||||||
|
|
||||||
const LoginRoute = LoginRouteImport.update({
|
const LoginRoute = LoginRouteImport.update({
|
||||||
id: '/login',
|
id: '/login',
|
||||||
|
|
@ -66,16 +66,21 @@ const DashboardDomainsRoute = DashboardDomainsRouteImport.update({
|
||||||
path: '/domains',
|
path: '/domains',
|
||||||
getParentRoute: () => DashboardRoute,
|
getParentRoute: () => DashboardRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const DashboardServicesNewRoute = DashboardServicesNewRouteImport.update({
|
||||||
|
id: '/services_/new',
|
||||||
|
path: '/services/new',
|
||||||
|
getParentRoute: () => DashboardRoute,
|
||||||
|
} as any)
|
||||||
const DashboardServicesServiceIdRoute =
|
const DashboardServicesServiceIdRoute =
|
||||||
DashboardServicesServiceIdRouteImport.update({
|
DashboardServicesServiceIdRouteImport.update({
|
||||||
id: '/services_/$serviceId',
|
id: '/services_/$serviceId',
|
||||||
path: '/services/$serviceId',
|
path: '/services/$serviceId',
|
||||||
getParentRoute: () => DashboardRoute,
|
getParentRoute: () => DashboardRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const DashboardServicesNewRoute = DashboardServicesNewRouteImport.update({
|
const DashboardServersNewRoute = DashboardServersNewRouteImport.update({
|
||||||
id: '/new',
|
id: '/servers_/new',
|
||||||
path: '/new',
|
path: '/servers/new',
|
||||||
getParentRoute: () => DashboardServicesRoute,
|
getParentRoute: () => DashboardRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const DashboardServersServerIdRoute =
|
const DashboardServersServerIdRoute =
|
||||||
DashboardServersServerIdRouteImport.update({
|
DashboardServersServerIdRouteImport.update({
|
||||||
|
|
@ -83,20 +88,20 @@ const DashboardServersServerIdRoute =
|
||||||
path: '/servers/$serverId',
|
path: '/servers/$serverId',
|
||||||
getParentRoute: () => DashboardRoute,
|
getParentRoute: () => DashboardRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const DashboardServersNewRoute = DashboardServersNewRouteImport.update({
|
const DashboardNotesNewRoute = DashboardNotesNewRouteImport.update({
|
||||||
id: '/new',
|
id: '/notes_/new',
|
||||||
path: '/new',
|
path: '/notes/new',
|
||||||
getParentRoute: () => DashboardServersRoute,
|
getParentRoute: () => DashboardRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const DashboardNotesNoteIdRoute = DashboardNotesNoteIdRouteImport.update({
|
const DashboardNotesNoteIdRoute = DashboardNotesNoteIdRouteImport.update({
|
||||||
id: '/notes_/$noteId',
|
id: '/notes_/$noteId',
|
||||||
path: '/notes/$noteId',
|
path: '/notes/$noteId',
|
||||||
getParentRoute: () => DashboardRoute,
|
getParentRoute: () => DashboardRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const DashboardNotesNewRoute = DashboardNotesNewRouteImport.update({
|
const DashboardDomainsNewRoute = DashboardDomainsNewRouteImport.update({
|
||||||
id: '/new',
|
id: '/domains_/new',
|
||||||
path: '/new',
|
path: '/domains/new',
|
||||||
getParentRoute: () => DashboardNotesRoute,
|
getParentRoute: () => DashboardRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const DashboardDomainsDomainIdRoute =
|
const DashboardDomainsDomainIdRoute =
|
||||||
DashboardDomainsDomainIdRouteImport.update({
|
DashboardDomainsDomainIdRouteImport.update({
|
||||||
|
|
@ -104,65 +109,60 @@ const DashboardDomainsDomainIdRoute =
|
||||||
path: '/domains/$domainId',
|
path: '/domains/$domainId',
|
||||||
getParentRoute: () => DashboardRoute,
|
getParentRoute: () => DashboardRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const DashboardDomainsNewRoute = DashboardDomainsNewRouteImport.update({
|
|
||||||
id: '/new',
|
|
||||||
path: '/new',
|
|
||||||
getParentRoute: () => DashboardDomainsRoute,
|
|
||||||
} 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 DashboardDomainsRouteWithChildren
|
'/dashboard/domains': typeof DashboardDomainsRoute
|
||||||
'/dashboard/notes': typeof DashboardNotesRouteWithChildren
|
'/dashboard/notes': typeof DashboardNotesRoute
|
||||||
'/dashboard/servers': typeof DashboardServersRouteWithChildren
|
'/dashboard/servers': typeof DashboardServersRoute
|
||||||
'/dashboard/services': typeof DashboardServicesRouteWithChildren
|
'/dashboard/services': typeof DashboardServicesRoute
|
||||||
'/dashboard/': typeof DashboardIndexRoute
|
'/dashboard/': typeof DashboardIndexRoute
|
||||||
'/dashboard/domains/new': typeof DashboardDomainsNewRoute
|
|
||||||
'/dashboard/domains/$domainId': typeof DashboardDomainsDomainIdRoute
|
'/dashboard/domains/$domainId': typeof DashboardDomainsDomainIdRoute
|
||||||
'/dashboard/notes/new': typeof DashboardNotesNewRoute
|
'/dashboard/domains/new': typeof DashboardDomainsNewRoute
|
||||||
'/dashboard/notes/$noteId': typeof DashboardNotesNoteIdRoute
|
'/dashboard/notes/$noteId': typeof DashboardNotesNoteIdRoute
|
||||||
'/dashboard/servers/new': typeof DashboardServersNewRoute
|
'/dashboard/notes/new': typeof DashboardNotesNewRoute
|
||||||
'/dashboard/servers/$serverId': typeof DashboardServersServerIdRoute
|
'/dashboard/servers/$serverId': typeof DashboardServersServerIdRoute
|
||||||
'/dashboard/services/new': typeof DashboardServicesNewRoute
|
'/dashboard/servers/new': typeof DashboardServersNewRoute
|
||||||
'/dashboard/services/$serviceId': typeof DashboardServicesServiceIdRoute
|
'/dashboard/services/$serviceId': typeof DashboardServicesServiceIdRoute
|
||||||
|
'/dashboard/services/new': typeof DashboardServicesNewRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/dashboard/domains': typeof DashboardDomainsRouteWithChildren
|
'/dashboard/domains': typeof DashboardDomainsRoute
|
||||||
'/dashboard/notes': typeof DashboardNotesRouteWithChildren
|
'/dashboard/notes': typeof DashboardNotesRoute
|
||||||
'/dashboard/servers': typeof DashboardServersRouteWithChildren
|
'/dashboard/servers': typeof DashboardServersRoute
|
||||||
'/dashboard/services': typeof DashboardServicesRouteWithChildren
|
'/dashboard/services': typeof DashboardServicesRoute
|
||||||
'/dashboard': typeof DashboardIndexRoute
|
'/dashboard': typeof DashboardIndexRoute
|
||||||
'/dashboard/domains/new': typeof DashboardDomainsNewRoute
|
|
||||||
'/dashboard/domains/$domainId': typeof DashboardDomainsDomainIdRoute
|
'/dashboard/domains/$domainId': typeof DashboardDomainsDomainIdRoute
|
||||||
'/dashboard/notes/new': typeof DashboardNotesNewRoute
|
'/dashboard/domains/new': typeof DashboardDomainsNewRoute
|
||||||
'/dashboard/notes/$noteId': typeof DashboardNotesNoteIdRoute
|
'/dashboard/notes/$noteId': typeof DashboardNotesNoteIdRoute
|
||||||
'/dashboard/servers/new': typeof DashboardServersNewRoute
|
'/dashboard/notes/new': typeof DashboardNotesNewRoute
|
||||||
'/dashboard/servers/$serverId': typeof DashboardServersServerIdRoute
|
'/dashboard/servers/$serverId': typeof DashboardServersServerIdRoute
|
||||||
'/dashboard/services/new': typeof DashboardServicesNewRoute
|
'/dashboard/servers/new': typeof DashboardServersNewRoute
|
||||||
'/dashboard/services/$serviceId': typeof DashboardServicesServiceIdRoute
|
'/dashboard/services/$serviceId': typeof DashboardServicesServiceIdRoute
|
||||||
|
'/dashboard/services/new': typeof DashboardServicesNewRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/dashboard': typeof DashboardRouteWithChildren
|
'/dashboard': typeof DashboardRouteWithChildren
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/dashboard/domains': typeof DashboardDomainsRouteWithChildren
|
'/dashboard/domains': typeof DashboardDomainsRoute
|
||||||
'/dashboard/notes': typeof DashboardNotesRouteWithChildren
|
'/dashboard/notes': typeof DashboardNotesRoute
|
||||||
'/dashboard/servers': typeof DashboardServersRouteWithChildren
|
'/dashboard/servers': typeof DashboardServersRoute
|
||||||
'/dashboard/services': typeof DashboardServicesRouteWithChildren
|
'/dashboard/services': typeof DashboardServicesRoute
|
||||||
'/dashboard/': typeof DashboardIndexRoute
|
'/dashboard/': typeof DashboardIndexRoute
|
||||||
'/dashboard/domains/new': typeof DashboardDomainsNewRoute
|
|
||||||
'/dashboard/domains_/$domainId': typeof DashboardDomainsDomainIdRoute
|
'/dashboard/domains_/$domainId': typeof DashboardDomainsDomainIdRoute
|
||||||
'/dashboard/notes/new': typeof DashboardNotesNewRoute
|
'/dashboard/domains_/new': typeof DashboardDomainsNewRoute
|
||||||
'/dashboard/notes_/$noteId': typeof DashboardNotesNoteIdRoute
|
'/dashboard/notes_/$noteId': typeof DashboardNotesNoteIdRoute
|
||||||
'/dashboard/servers/new': typeof DashboardServersNewRoute
|
'/dashboard/notes_/new': typeof DashboardNotesNewRoute
|
||||||
'/dashboard/servers_/$serverId': typeof DashboardServersServerIdRoute
|
'/dashboard/servers_/$serverId': typeof DashboardServersServerIdRoute
|
||||||
'/dashboard/services/new': typeof DashboardServicesNewRoute
|
'/dashboard/servers_/new': typeof DashboardServersNewRoute
|
||||||
'/dashboard/services_/$serviceId': typeof DashboardServicesServiceIdRoute
|
'/dashboard/services_/$serviceId': typeof DashboardServicesServiceIdRoute
|
||||||
|
'/dashboard/services_/new': typeof DashboardServicesNewRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
|
|
@ -175,14 +175,14 @@ export interface FileRouteTypes {
|
||||||
| '/dashboard/servers'
|
| '/dashboard/servers'
|
||||||
| '/dashboard/services'
|
| '/dashboard/services'
|
||||||
| '/dashboard/'
|
| '/dashboard/'
|
||||||
| '/dashboard/domains/new'
|
|
||||||
| '/dashboard/domains/$domainId'
|
| '/dashboard/domains/$domainId'
|
||||||
| '/dashboard/notes/new'
|
| '/dashboard/domains/new'
|
||||||
| '/dashboard/notes/$noteId'
|
| '/dashboard/notes/$noteId'
|
||||||
| '/dashboard/servers/new'
|
| '/dashboard/notes/new'
|
||||||
| '/dashboard/servers/$serverId'
|
| '/dashboard/servers/$serverId'
|
||||||
| '/dashboard/services/new'
|
| '/dashboard/servers/new'
|
||||||
| '/dashboard/services/$serviceId'
|
| '/dashboard/services/$serviceId'
|
||||||
|
| '/dashboard/services/new'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
|
|
@ -192,14 +192,14 @@ export interface FileRouteTypes {
|
||||||
| '/dashboard/servers'
|
| '/dashboard/servers'
|
||||||
| '/dashboard/services'
|
| '/dashboard/services'
|
||||||
| '/dashboard'
|
| '/dashboard'
|
||||||
| '/dashboard/domains/new'
|
|
||||||
| '/dashboard/domains/$domainId'
|
| '/dashboard/domains/$domainId'
|
||||||
| '/dashboard/notes/new'
|
| '/dashboard/domains/new'
|
||||||
| '/dashboard/notes/$noteId'
|
| '/dashboard/notes/$noteId'
|
||||||
| '/dashboard/servers/new'
|
| '/dashboard/notes/new'
|
||||||
| '/dashboard/servers/$serverId'
|
| '/dashboard/servers/$serverId'
|
||||||
| '/dashboard/services/new'
|
| '/dashboard/servers/new'
|
||||||
| '/dashboard/services/$serviceId'
|
| '/dashboard/services/$serviceId'
|
||||||
|
| '/dashboard/services/new'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
|
|
@ -210,14 +210,14 @@ export interface FileRouteTypes {
|
||||||
| '/dashboard/servers'
|
| '/dashboard/servers'
|
||||||
| '/dashboard/services'
|
| '/dashboard/services'
|
||||||
| '/dashboard/'
|
| '/dashboard/'
|
||||||
| '/dashboard/domains/new'
|
|
||||||
| '/dashboard/domains_/$domainId'
|
| '/dashboard/domains_/$domainId'
|
||||||
| '/dashboard/notes/new'
|
| '/dashboard/domains_/new'
|
||||||
| '/dashboard/notes_/$noteId'
|
| '/dashboard/notes_/$noteId'
|
||||||
| '/dashboard/servers/new'
|
| '/dashboard/notes_/new'
|
||||||
| '/dashboard/servers_/$serverId'
|
| '/dashboard/servers_/$serverId'
|
||||||
| '/dashboard/services/new'
|
| '/dashboard/servers_/new'
|
||||||
| '/dashboard/services_/$serviceId'
|
| '/dashboard/services_/$serviceId'
|
||||||
|
| '/dashboard/services_/new'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
|
|
@ -284,6 +284,13 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof DashboardDomainsRouteImport
|
preLoaderRoute: typeof DashboardDomainsRouteImport
|
||||||
parentRoute: typeof DashboardRoute
|
parentRoute: typeof DashboardRoute
|
||||||
}
|
}
|
||||||
|
'/dashboard/services_/new': {
|
||||||
|
id: '/dashboard/services_/new'
|
||||||
|
path: '/services/new'
|
||||||
|
fullPath: '/dashboard/services/new'
|
||||||
|
preLoaderRoute: typeof DashboardServicesNewRouteImport
|
||||||
|
parentRoute: typeof DashboardRoute
|
||||||
|
}
|
||||||
'/dashboard/services_/$serviceId': {
|
'/dashboard/services_/$serviceId': {
|
||||||
id: '/dashboard/services_/$serviceId'
|
id: '/dashboard/services_/$serviceId'
|
||||||
path: '/services/$serviceId'
|
path: '/services/$serviceId'
|
||||||
|
|
@ -291,12 +298,12 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof DashboardServicesServiceIdRouteImport
|
preLoaderRoute: typeof DashboardServicesServiceIdRouteImport
|
||||||
parentRoute: typeof DashboardRoute
|
parentRoute: typeof DashboardRoute
|
||||||
}
|
}
|
||||||
'/dashboard/services/new': {
|
'/dashboard/servers_/new': {
|
||||||
id: '/dashboard/services/new'
|
id: '/dashboard/servers_/new'
|
||||||
path: '/new'
|
path: '/servers/new'
|
||||||
fullPath: '/dashboard/services/new'
|
fullPath: '/dashboard/servers/new'
|
||||||
preLoaderRoute: typeof DashboardServicesNewRouteImport
|
preLoaderRoute: typeof DashboardServersNewRouteImport
|
||||||
parentRoute: typeof DashboardServicesRoute
|
parentRoute: typeof DashboardRoute
|
||||||
}
|
}
|
||||||
'/dashboard/servers_/$serverId': {
|
'/dashboard/servers_/$serverId': {
|
||||||
id: '/dashboard/servers_/$serverId'
|
id: '/dashboard/servers_/$serverId'
|
||||||
|
|
@ -305,12 +312,12 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof DashboardServersServerIdRouteImport
|
preLoaderRoute: typeof DashboardServersServerIdRouteImport
|
||||||
parentRoute: typeof DashboardRoute
|
parentRoute: typeof DashboardRoute
|
||||||
}
|
}
|
||||||
'/dashboard/servers/new': {
|
'/dashboard/notes_/new': {
|
||||||
id: '/dashboard/servers/new'
|
id: '/dashboard/notes_/new'
|
||||||
path: '/new'
|
path: '/notes/new'
|
||||||
fullPath: '/dashboard/servers/new'
|
fullPath: '/dashboard/notes/new'
|
||||||
preLoaderRoute: typeof DashboardServersNewRouteImport
|
preLoaderRoute: typeof DashboardNotesNewRouteImport
|
||||||
parentRoute: typeof DashboardServersRoute
|
parentRoute: typeof DashboardRoute
|
||||||
}
|
}
|
||||||
'/dashboard/notes_/$noteId': {
|
'/dashboard/notes_/$noteId': {
|
||||||
id: '/dashboard/notes_/$noteId'
|
id: '/dashboard/notes_/$noteId'
|
||||||
|
|
@ -319,12 +326,12 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof DashboardNotesNoteIdRouteImport
|
preLoaderRoute: typeof DashboardNotesNoteIdRouteImport
|
||||||
parentRoute: typeof DashboardRoute
|
parentRoute: typeof DashboardRoute
|
||||||
}
|
}
|
||||||
'/dashboard/notes/new': {
|
'/dashboard/domains_/new': {
|
||||||
id: '/dashboard/notes/new'
|
id: '/dashboard/domains_/new'
|
||||||
path: '/new'
|
path: '/domains/new'
|
||||||
fullPath: '/dashboard/notes/new'
|
fullPath: '/dashboard/domains/new'
|
||||||
preLoaderRoute: typeof DashboardNotesNewRouteImport
|
preLoaderRoute: typeof DashboardDomainsNewRouteImport
|
||||||
parentRoute: typeof DashboardNotesRoute
|
parentRoute: typeof DashboardRoute
|
||||||
}
|
}
|
||||||
'/dashboard/domains_/$domainId': {
|
'/dashboard/domains_/$domainId': {
|
||||||
id: '/dashboard/domains_/$domainId'
|
id: '/dashboard/domains_/$domainId'
|
||||||
|
|
@ -333,83 +340,39 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof DashboardDomainsDomainIdRouteImport
|
preLoaderRoute: typeof DashboardDomainsDomainIdRouteImport
|
||||||
parentRoute: typeof DashboardRoute
|
parentRoute: typeof DashboardRoute
|
||||||
}
|
}
|
||||||
'/dashboard/domains/new': {
|
|
||||||
id: '/dashboard/domains/new'
|
|
||||||
path: '/new'
|
|
||||||
fullPath: '/dashboard/domains/new'
|
|
||||||
preLoaderRoute: typeof DashboardDomainsNewRouteImport
|
|
||||||
parentRoute: typeof DashboardDomainsRoute
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DashboardDomainsRouteChildren {
|
interface DashboardRouteChildren {
|
||||||
|
DashboardDomainsRoute: typeof DashboardDomainsRoute
|
||||||
|
DashboardNotesRoute: typeof DashboardNotesRoute
|
||||||
|
DashboardServersRoute: typeof DashboardServersRoute
|
||||||
|
DashboardServicesRoute: typeof DashboardServicesRoute
|
||||||
|
DashboardIndexRoute: typeof DashboardIndexRoute
|
||||||
|
DashboardDomainsDomainIdRoute: typeof DashboardDomainsDomainIdRoute
|
||||||
DashboardDomainsNewRoute: typeof DashboardDomainsNewRoute
|
DashboardDomainsNewRoute: typeof DashboardDomainsNewRoute
|
||||||
}
|
DashboardNotesNoteIdRoute: typeof DashboardNotesNoteIdRoute
|
||||||
|
|
||||||
const DashboardDomainsRouteChildren: DashboardDomainsRouteChildren = {
|
|
||||||
DashboardDomainsNewRoute: DashboardDomainsNewRoute,
|
|
||||||
}
|
|
||||||
|
|
||||||
const DashboardDomainsRouteWithChildren =
|
|
||||||
DashboardDomainsRoute._addFileChildren(DashboardDomainsRouteChildren)
|
|
||||||
|
|
||||||
interface DashboardNotesRouteChildren {
|
|
||||||
DashboardNotesNewRoute: typeof DashboardNotesNewRoute
|
DashboardNotesNewRoute: typeof DashboardNotesNewRoute
|
||||||
}
|
DashboardServersServerIdRoute: typeof DashboardServersServerIdRoute
|
||||||
|
|
||||||
const DashboardNotesRouteChildren: DashboardNotesRouteChildren = {
|
|
||||||
DashboardNotesNewRoute: DashboardNotesNewRoute,
|
|
||||||
}
|
|
||||||
|
|
||||||
const DashboardNotesRouteWithChildren = DashboardNotesRoute._addFileChildren(
|
|
||||||
DashboardNotesRouteChildren,
|
|
||||||
)
|
|
||||||
|
|
||||||
interface DashboardServersRouteChildren {
|
|
||||||
DashboardServersNewRoute: typeof DashboardServersNewRoute
|
DashboardServersNewRoute: typeof DashboardServersNewRoute
|
||||||
}
|
DashboardServicesServiceIdRoute: typeof DashboardServicesServiceIdRoute
|
||||||
|
|
||||||
const DashboardServersRouteChildren: DashboardServersRouteChildren = {
|
|
||||||
DashboardServersNewRoute: DashboardServersNewRoute,
|
|
||||||
}
|
|
||||||
|
|
||||||
const DashboardServersRouteWithChildren =
|
|
||||||
DashboardServersRoute._addFileChildren(DashboardServersRouteChildren)
|
|
||||||
|
|
||||||
interface DashboardServicesRouteChildren {
|
|
||||||
DashboardServicesNewRoute: typeof DashboardServicesNewRoute
|
DashboardServicesNewRoute: typeof DashboardServicesNewRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const DashboardServicesRouteChildren: DashboardServicesRouteChildren = {
|
|
||||||
DashboardServicesNewRoute: DashboardServicesNewRoute,
|
|
||||||
}
|
|
||||||
|
|
||||||
const DashboardServicesRouteWithChildren =
|
|
||||||
DashboardServicesRoute._addFileChildren(DashboardServicesRouteChildren)
|
|
||||||
|
|
||||||
interface DashboardRouteChildren {
|
|
||||||
DashboardDomainsRoute: typeof DashboardDomainsRouteWithChildren
|
|
||||||
DashboardNotesRoute: typeof DashboardNotesRouteWithChildren
|
|
||||||
DashboardServersRoute: typeof DashboardServersRouteWithChildren
|
|
||||||
DashboardServicesRoute: typeof DashboardServicesRouteWithChildren
|
|
||||||
DashboardIndexRoute: typeof DashboardIndexRoute
|
|
||||||
DashboardDomainsDomainIdRoute: typeof DashboardDomainsDomainIdRoute
|
|
||||||
DashboardNotesNoteIdRoute: typeof DashboardNotesNoteIdRoute
|
|
||||||
DashboardServersServerIdRoute: typeof DashboardServersServerIdRoute
|
|
||||||
DashboardServicesServiceIdRoute: typeof DashboardServicesServiceIdRoute
|
|
||||||
}
|
|
||||||
|
|
||||||
const DashboardRouteChildren: DashboardRouteChildren = {
|
const DashboardRouteChildren: DashboardRouteChildren = {
|
||||||
DashboardDomainsRoute: DashboardDomainsRouteWithChildren,
|
DashboardDomainsRoute: DashboardDomainsRoute,
|
||||||
DashboardNotesRoute: DashboardNotesRouteWithChildren,
|
DashboardNotesRoute: DashboardNotesRoute,
|
||||||
DashboardServersRoute: DashboardServersRouteWithChildren,
|
DashboardServersRoute: DashboardServersRoute,
|
||||||
DashboardServicesRoute: DashboardServicesRouteWithChildren,
|
DashboardServicesRoute: DashboardServicesRoute,
|
||||||
DashboardIndexRoute: DashboardIndexRoute,
|
DashboardIndexRoute: DashboardIndexRoute,
|
||||||
DashboardDomainsDomainIdRoute: DashboardDomainsDomainIdRoute,
|
DashboardDomainsDomainIdRoute: DashboardDomainsDomainIdRoute,
|
||||||
|
DashboardDomainsNewRoute: DashboardDomainsNewRoute,
|
||||||
DashboardNotesNoteIdRoute: DashboardNotesNoteIdRoute,
|
DashboardNotesNoteIdRoute: DashboardNotesNoteIdRoute,
|
||||||
|
DashboardNotesNewRoute: DashboardNotesNewRoute,
|
||||||
DashboardServersServerIdRoute: DashboardServersServerIdRoute,
|
DashboardServersServerIdRoute: DashboardServersServerIdRoute,
|
||||||
|
DashboardServersNewRoute: DashboardServersNewRoute,
|
||||||
DashboardServicesServiceIdRoute: DashboardServicesServiceIdRoute,
|
DashboardServicesServiceIdRoute: DashboardServicesServiceIdRoute,
|
||||||
|
DashboardServicesNewRoute: DashboardServicesNewRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
const DashboardRouteWithChildren = DashboardRoute._addFileChildren(
|
const DashboardRouteWithChildren = DashboardRoute._addFileChildren(
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { Button } from "@minmon/ui/components/button";
|
import { Button } from "@minmon/ui/components/button";
|
||||||
import { StatusBadge } from "@minmon/ui/components/status-badge";
|
import { statusBadgeVariants } from "@minmon/ui/components/status-badge";
|
||||||
|
import { cn } from "@minmon/ui/lib/utils";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { 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 { PageSection, QueryStateCard, ResourceListCard } from "@/components/dashboard-ui";
|
import { ConfirmDeleteIconButton, 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")({
|
||||||
|
|
@ -93,38 +94,29 @@ 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]) => (
|
||||||
<StatusBadge variant={dnsStatusVariant(item.resolutionStatus)}>{item.resolutionStatus}</StatusBadge>
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={checkDns.isPending}
|
||||||
|
className={cn(
|
||||||
|
statusBadgeVariants({ variant: dnsStatusVariant(item.resolutionStatus) }),
|
||||||
|
"cursor-pointer transition-opacity hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-60",
|
||||||
|
)}
|
||||||
|
onClick={() => checkDns.mutate(item.id)}
|
||||||
|
>
|
||||||
|
{item.resolutionStatus}
|
||||||
|
</button>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "actions",
|
key: "actions",
|
||||||
header: "Actions",
|
header: "Actions",
|
||||||
className: "w-44",
|
className: "w-16",
|
||||||
render: (item: (typeof domains.data)[number]) => (
|
render: (item: (typeof domains.data)[number]) => (
|
||||||
<div className="flex flex-col items-start gap-2">
|
<ConfirmDeleteIconButton
|
||||||
<Button
|
itemLabel={`domain ${item.name}`}
|
||||||
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}
|
||||||
onClick={() => {
|
onConfirm={() => removeDomain.mutate(item.id)}
|
||||||
if (confirm(`Delete domain \"${item.name}\"?`)) {
|
/>
|
||||||
removeDomain.mutate(item.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { DetailCard, FormCard, PageSection, QueryStateCard, TextAreaField, TextField } from "@/components/dashboard-ui";
|
import { DetailCard, FormCard, PageSection, QueryStateCard, SelectField, TextAreaField, TextField } from "@/components/dashboard-ui";
|
||||||
import { queryClient, trpc, trpcClient } from "@/utils/trpc";
|
import { 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";
|
||||||
|
|
@ -88,6 +88,11 @@ function RouteComponent() {
|
||||||
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." />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
<PageSection title={domain.data.name} description="View DNS check results and edit this domain record.">
|
<PageSection title={domain.data.name} description="View DNS check results and edit this domain record.">
|
||||||
|
|
@ -134,13 +139,15 @@ function RouteComponent() {
|
||||||
>
|
>
|
||||||
<form.Field name="serviceId">
|
<form.Field name="serviceId">
|
||||||
{(field) => (
|
{(field) => (
|
||||||
<TextField
|
<SelectField
|
||||||
label="Service ID"
|
label="Service"
|
||||||
name={field.name}
|
name={field.name}
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
onBlur={field.handleBlur}
|
onBlur={field.handleBlur}
|
||||||
onChange={(e) => field.handleChange(e.target.value)}
|
onValueChange={field.handleChange}
|
||||||
description={`Available: ${services.data.map((item) => `${item.name} (${item.id})`).join(", ")}`}
|
description="Select the service this domain belongs to."
|
||||||
|
placeholder="Select a service"
|
||||||
|
options={serviceOptions}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
|
|
|
||||||
|
|
@ -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, TextAreaField, TextField } from "@/components/dashboard-ui";
|
import { FormCard, PageSection, QueryStateCard, SelectField, 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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -58,6 +58,11 @@ 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
|
||||||
|
|
@ -84,13 +89,15 @@ function RouteComponent() {
|
||||||
>
|
>
|
||||||
<form.Field name="serviceId">
|
<form.Field name="serviceId">
|
||||||
{(field) => (
|
{(field) => (
|
||||||
<TextField
|
<SelectField
|
||||||
label="Service ID"
|
label="Service"
|
||||||
name={field.name}
|
name={field.name}
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
onBlur={field.handleBlur}
|
onBlur={field.handleBlur}
|
||||||
onChange={(e) => field.handleChange(e.target.value)}
|
onValueChange={field.handleChange}
|
||||||
description={`Available: ${services.data.map((item) => `${item.name} (${item.id})`).join(", ")}`}
|
description="Select the service this domain belongs to."
|
||||||
|
placeholder="Select a service"
|
||||||
|
options={serviceOptions}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
|
|
@ -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 { PageSection, QueryStateCard, ResourceListCard } from "@/components/dashboard-ui";
|
import { ConfirmDeleteIconButton, 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,21 +76,13 @@ function RouteComponent() {
|
||||||
{
|
{
|
||||||
key: "actions",
|
key: "actions",
|
||||||
header: "Actions",
|
header: "Actions",
|
||||||
className: "w-28",
|
className: "w-16",
|
||||||
render: (item: (typeof notes.data)[number]) => (
|
render: (item: (typeof notes.data)[number]) => (
|
||||||
<Button
|
<ConfirmDeleteIconButton
|
||||||
type="button"
|
itemLabel={`note ${item.title}`}
|
||||||
variant="ghost"
|
|
||||||
className="h-auto px-0 text-red-600 hover:text-red-700"
|
|
||||||
disabled={removeNote.isPending}
|
disabled={removeNote.isPending}
|
||||||
onClick={() => {
|
onConfirm={() => removeNote.mutate(item.id)}
|
||||||
if (confirm(`Delete note \"${item.title}\"?`)) {
|
/>
|
||||||
removeNote.mutate(item.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ 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 { useEffect } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { DetailCard, FormCard, PageSection, QueryStateCard, TextAreaField, TextField } from "@/components/dashboard-ui";
|
import { DetailCard, FormCard, PageSection, QueryStateCard, SelectField, TextAreaField, TextField } from "@/components/dashboard-ui";
|
||||||
import { queryClient, trpc, trpcClient } from "@/utils/trpc";
|
import { queryClient, trpc, trpcClient } from "@/utils/trpc";
|
||||||
|
|
||||||
export const Route = createFileRoute("/dashboard/notes_/$noteId")({
|
export const Route = createFileRoute("/dashboard/notes_/$noteId")({
|
||||||
|
|
@ -75,6 +75,12 @@ function RouteComponent() {
|
||||||
return <QueryStateCard title="Note unavailable" description="The requested note could not be loaded." />;
|
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.">
|
||||||
|
|
@ -82,7 +88,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 ID", value: note.data.serverId },
|
{ label: "Server", value: serverMap.get(note.data.serverId) || 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 },
|
||||||
|
|
@ -114,13 +120,15 @@ function RouteComponent() {
|
||||||
>
|
>
|
||||||
<form.Field name="serverId">
|
<form.Field name="serverId">
|
||||||
{(field) => (
|
{(field) => (
|
||||||
<TextField
|
<SelectField
|
||||||
label="Server ID"
|
label="Server"
|
||||||
name={field.name}
|
name={field.name}
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
onBlur={field.handleBlur}
|
onBlur={field.handleBlur}
|
||||||
onChange={(e) => field.handleChange(e.target.value)}
|
onValueChange={field.handleChange}
|
||||||
description={`Available: ${servers.data.map((item) => `${item.name} (${item.id})`).join(", ")}`}
|
description="Select the server this note belongs to."
|
||||||
|
placeholder="Select a server"
|
||||||
|
options={serverOptions}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
|
|
|
||||||
|
|
@ -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, TextAreaField, TextField } from "@/components/dashboard-ui";
|
import { FormCard, PageSection, QueryStateCard, SelectField, 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,6 +58,11 @@ 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
|
||||||
|
|
@ -84,13 +89,15 @@ function RouteComponent() {
|
||||||
>
|
>
|
||||||
<form.Field name="serverId">
|
<form.Field name="serverId">
|
||||||
{(field) => (
|
{(field) => (
|
||||||
<TextField
|
<SelectField
|
||||||
label="Server ID"
|
label="Server"
|
||||||
name={field.name}
|
name={field.name}
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
onBlur={field.handleBlur}
|
onBlur={field.handleBlur}
|
||||||
onChange={(e) => field.handleChange(e.target.value)}
|
onValueChange={field.handleChange}
|
||||||
description={`Available: ${servers.data.map((item) => `${item.name} (${item.id})`).join(", ")}`}
|
description="Select the server this note belongs to."
|
||||||
|
placeholder="Select a server"
|
||||||
|
options={serverOptions}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
|
|
@ -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 { PageSection, QueryStateCard, ResourceListCard } from "@/components/dashboard-ui";
|
import { ConfirmDeleteIconButton, 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,21 +99,13 @@ function RouteComponent() {
|
||||||
{
|
{
|
||||||
key: "actions",
|
key: "actions",
|
||||||
header: "Actions",
|
header: "Actions",
|
||||||
className: "w-28",
|
className: "w-16",
|
||||||
render: (item: (typeof servers.data)[number]) => (
|
render: (item: (typeof servers.data)[number]) => (
|
||||||
<Button
|
<ConfirmDeleteIconButton
|
||||||
type="button"
|
itemLabel={`server ${item.name}`}
|
||||||
variant="ghost"
|
|
||||||
className="h-auto px-0 text-red-600 hover:text-red-700"
|
|
||||||
disabled={removeServer.isPending}
|
disabled={removeServer.isPending}
|
||||||
onClick={() => {
|
onConfirm={() => removeServer.mutate(item.id)}
|
||||||
if (confirm(`Delete server \"${item.name}\" and all related services, domains, and notes?`)) {
|
/>
|
||||||
removeServer.mutate(item.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,10 @@ 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, Link, useNavigate } from "@tanstack/react-router";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { DetailCard, FormCard, PageSection, QueryStateCard, TextAreaField, TextField } from "@/components/dashboard-ui";
|
import { DetailCard, FormCard, PageSection, QueryStateCard, ResourceListCard, SelectField, TextAreaField, TextField } from "@/components/dashboard-ui";
|
||||||
import { queryClient, trpc, trpcClient } from "@/utils/trpc";
|
import { queryClient, trpc, trpcClient } from "@/utils/trpc";
|
||||||
|
|
||||||
export const Route = createFileRoute("/dashboard/servers_/$serverId")({
|
export const Route = createFileRoute("/dashboard/servers_/$serverId")({
|
||||||
|
|
@ -23,10 +23,17 @@ 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) =>
|
||||||
|
|
@ -108,6 +115,54 @@ 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) => {
|
||||||
|
|
@ -137,7 +192,7 @@ function RouteComponent() {
|
||||||
<form.Field name="operatingSystem">{(field) => <TextField label="Operating system" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} />}</form.Field>
|
<form.Field name="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) => <TextField label="Status" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value as "active" | "maintenance" | "inactive")} />}</form.Field>
|
<form.Field name="status">{(field) => <SelectField label="Status" name={field.name} value={field.state.value} onBlur={field.handleBlur} onValueChange={(value) => field.handleChange(value as ServerFormValues["status"])} placeholder="Select server status" options={[...serverStatusOptions]} />}</form.Field>
|
||||||
</div>
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -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, TextAreaField, TextField } from "@/components/dashboard-ui";
|
import { FormCard, PageSection, SelectField, 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,6 +21,12 @@ 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({
|
||||||
|
|
@ -160,13 +166,14 @@ function RouteComponent() {
|
||||||
</form.Field>
|
</form.Field>
|
||||||
<form.Field name="status">
|
<form.Field name="status">
|
||||||
{(field) => (
|
{(field) => (
|
||||||
<TextField
|
<SelectField
|
||||||
label="Status"
|
label="Status"
|
||||||
name={field.name}
|
name={field.name}
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
onBlur={field.handleBlur}
|
onBlur={field.handleBlur}
|
||||||
onChange={(e) => field.handleChange(e.target.value as "active" | "maintenance" | "inactive")}
|
onValueChange={(value) => field.handleChange(value as ServerFormValues["status"])}
|
||||||
placeholder="active"
|
placeholder="Select server status"
|
||||||
|
options={[...serverStatusOptions]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
|
|
@ -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 { PageSection, QueryStateCard, ResourceListCard } from "@/components/dashboard-ui";
|
import { ConfirmDeleteIconButton, 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,21 +96,13 @@ function RouteComponent() {
|
||||||
{
|
{
|
||||||
key: "actions",
|
key: "actions",
|
||||||
header: "Actions",
|
header: "Actions",
|
||||||
className: "w-28",
|
className: "w-16",
|
||||||
render: (item: (typeof services.data)[number]) => (
|
render: (item: (typeof services.data)[number]) => (
|
||||||
<Button
|
<ConfirmDeleteIconButton
|
||||||
type="button"
|
itemLabel={`service ${item.name}`}
|
||||||
variant="ghost"
|
|
||||||
className="h-auto px-0 text-red-600 hover:text-red-700"
|
|
||||||
disabled={removeService.isPending}
|
disabled={removeService.isPending}
|
||||||
onClick={() => {
|
onConfirm={() => removeService.mutate(item.id)}
|
||||||
if (confirm(`Delete service \"${item.name}\" and its related domains?`)) {
|
/>
|
||||||
removeService.mutate(item.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ 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 { useEffect } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { DetailCard, FormCard, PageSection, QueryStateCard, TextAreaField, TextField } from "@/components/dashboard-ui";
|
import { DetailCard, FormCard, PageSection, QueryStateCard, SelectField, TextAreaField, TextField } from "@/components/dashboard-ui";
|
||||||
import { queryClient, trpc, trpcClient } from "@/utils/trpc";
|
import { queryClient, trpc, trpcClient } from "@/utils/trpc";
|
||||||
|
|
||||||
export const Route = createFileRoute("/dashboard/services_/$serviceId")({
|
export const Route = createFileRoute("/dashboard/services_/$serviceId")({
|
||||||
|
|
@ -23,6 +23,29 @@ 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();
|
||||||
|
|
@ -92,6 +115,11 @@ 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) => ({
|
||||||
|
value: item.id,
|
||||||
|
label: `${item.name} (${item.id})`,
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-6">
|
<div className="grid gap-6">
|
||||||
<PageSection title={service.data.name} description="View and edit the selected service.">
|
<PageSection title={service.data.name} description="View and edit the selected service.">
|
||||||
|
|
@ -134,23 +162,25 @@ function RouteComponent() {
|
||||||
>
|
>
|
||||||
<form.Field name="serverId">
|
<form.Field name="serverId">
|
||||||
{(field) => (
|
{(field) => (
|
||||||
<TextField
|
<SelectField
|
||||||
label="Server ID"
|
label="Server"
|
||||||
name={field.name}
|
name={field.name}
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
onBlur={field.handleBlur}
|
onBlur={field.handleBlur}
|
||||||
onChange={(e) => field.handleChange(e.target.value)}
|
onValueChange={field.handleChange}
|
||||||
description={`Available: ${servers.data.map((item) => `${item.name} (${item.id})`).join(", ")}`}
|
description="Select the server that owns this service."
|
||||||
|
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) => <TextField label="Service type" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value as ServiceFormValues["type"])} />}</form.Field>
|
<form.Field name="type">{(field) => <SelectField label="Service type" name={field.name} value={field.state.value} onBlur={field.handleBlur} onValueChange={(value) => field.handleChange(value as ServiceFormValues["type"])} placeholder="Select service type" options={[...serviceTypeOptions]} />}</form.Field>
|
||||||
<form.Field name="internalPort">{(field) => <TextField label="Internal port" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} />}</form.Field>
|
<form.Field name="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) => <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="protocol">{(field) => <SelectField label="Protocol" name={field.name} value={field.state.value} onBlur={field.handleBlur} onValueChange={(value) => field.handleChange(value as ServiceFormValues["protocol"])} placeholder="Select protocol" options={[...serviceProtocolOptions]} />}</form.Field>
|
||||||
<form.Field name="status">{(field) => <TextField label="Status" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value as ServiceFormValues["status"])} />}</form.Field>
|
<form.Field name="status">{(field) => <SelectField label="Status" name={field.name} value={field.state.value} onBlur={field.handleBlur} onValueChange={(value) => field.handleChange(value as ServiceFormValues["status"])} placeholder="Select service status" options={[...serviceStatusOptions]} />}</form.Field>
|
||||||
</div>
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -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, TextAreaField, TextField } from "@/components/dashboard-ui";
|
import { FormCard, PageSection, QueryStateCard, SelectField, 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,6 +21,29 @@ 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());
|
||||||
|
|
@ -70,6 +93,11 @@ 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
|
||||||
|
|
@ -96,23 +124,25 @@ function RouteComponent() {
|
||||||
>
|
>
|
||||||
<form.Field name="serverId">
|
<form.Field name="serverId">
|
||||||
{(field) => (
|
{(field) => (
|
||||||
<TextField
|
<SelectField
|
||||||
label="Server ID"
|
label="Server"
|
||||||
name={field.name}
|
name={field.name}
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
onBlur={field.handleBlur}
|
onBlur={field.handleBlur}
|
||||||
onChange={(e) => field.handleChange(e.target.value)}
|
onValueChange={field.handleChange}
|
||||||
description={`Available: ${servers.data.map((item) => `${item.name} (${item.id})`).join(", ")}`}
|
description="Select the server that owns this service."
|
||||||
|
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) => <TextField label="Service type" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value as ServiceFormValues["type"])} />}</form.Field>
|
<form.Field name="type">{(field) => <SelectField label="Service type" name={field.name} value={field.state.value} onBlur={field.handleBlur} onValueChange={(value) => field.handleChange(value as ServiceFormValues["type"])} placeholder="Select service type" options={[...serviceTypeOptions]} />}</form.Field>
|
||||||
<form.Field name="internalPort">{(field) => <TextField label="Internal port" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} />}</form.Field>
|
<form.Field name="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) => <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="protocol">{(field) => <SelectField label="Protocol" name={field.name} value={field.state.value} onBlur={field.handleBlur} onValueChange={(value) => field.handleChange(value as ServiceFormValues["protocol"])} placeholder="Select protocol" options={[...serviceProtocolOptions]} />}</form.Field>
|
||||||
<form.Field name="status">{(field) => <TextField label="Status" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value as ServiceFormValues["status"])} />}</form.Field>
|
<form.Field name="status">{(field) => <SelectField label="Status" name={field.name} value={field.state.value} onBlur={field.handleBlur} onValueChange={(value) => field.handleChange(value as ServiceFormValues["status"])} placeholder="Select service status" options={[...serviceStatusOptions]} />}</form.Field>
|
||||||
</div>
|
</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>
|
||||||
559
docs/minmon-homelab-report.tex
Normal file
559
docs/minmon-homelab-report.tex
Normal file
|
|
@ -0,0 +1,559 @@
|
||||||
|
\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[most]{tcolorbox}
|
||||||
|
\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}
|
||||||
|
\definecolor{titleblue}{RGB}{25,66,122}
|
||||||
|
|
||||||
|
\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}
|
||||||
|
}
|
||||||
|
|
||||||
|
\newcommand{\uipath}[1]{\texttt{#1}}
|
||||||
|
\newcommand{\uibutton}[1]{\textbf{#1}}
|
||||||
|
|
||||||
|
\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]
|
||||||
|
\centering
|
||||||
|
\fbox{\parbox[c][6cm][c]{0.82\textwidth}{\centering\textit{Placeholder gambar/diagram}\\[0.5em]#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{infobox}[width=0.9\textwidth]
|
||||||
|
\textbf{Nama Mahasiswa} : [Isi Nama Anda]\\
|
||||||
|
\textbf{NIM} : [Isi NIM Anda]\\
|
||||||
|
\textbf{Program Studi} : [Isi Program Studi]\\
|
||||||
|
\textbf{Mata Kuliah} : [Isi Mata Kuliah]\\
|
||||||
|
\textbf{Dosen Pengampu} : [Isi Nama Dosen]\\
|
||||||
|
\textbf{Institusi} : [Isi Nama Universitas / Sekolah]\\
|
||||||
|
\textbf{Tahun Akademik} : [Isi Tahun Akademik]
|
||||||
|
\end{infobox}
|
||||||
|
|
||||||
|
\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 \today}
|
||||||
|
\end{titlepage}
|
||||||
|
|
||||||
|
\tableofcontents
|
||||||
|
\clearpage
|
||||||
|
|
||||||
|
\chapter{Pendahuluan}
|
||||||
|
|
||||||
|
\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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
\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 note 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 note yang melekat pada server tertentu.
|
||||||
|
\item Menjadi contoh implementasi full-stack 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 note dalam satu tempat.
|
||||||
|
\item Mengurangi kesalahan input relasi data karena pilihan parent-child dibuat melalui select input.
|
||||||
|
\item Memudahkan pengecekan domain melalui fitur DNS check berbasis A record / IPv4.
|
||||||
|
\item Memberikan gambaran ringkas kondisi homelab melalui dashboard summary.
|
||||||
|
\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 note, serta kebutuhan monitoring DNS sederhana.
|
||||||
|
|
||||||
|
\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.
|
||||||
|
|
||||||
|
\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.
|
||||||
|
|
||||||
|
\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.
|
||||||
|
|
||||||
|
\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.
|
||||||
|
|
||||||
|
\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.
|
||||||
|
\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, misalnya 2 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 note.
|
||||||
|
\item Memiliki DNS A-record checking yang terintegrasi langsung pada alur domain.
|
||||||
|
\item Menampilkan note yang terkait langsung pada halaman detail server.
|
||||||
|
\item Menggunakan autentikasi admin sehingga dashboard tidak dapat diakses tanpa sesi.
|
||||||
|
\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 note operasional pada setiap server.
|
||||||
|
\end{infobox}
|
||||||
|
|
||||||
|
\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.
|
||||||
|
|
||||||
|
\subsection{Use Case Diagram}
|
||||||
|
Use case utama dalam sistem ini meliputi:
|
||||||
|
|
||||||
|
\begin{itemize}
|
||||||
|
\item Admin sign in
|
||||||
|
\item Admin mengakses dashboard summary
|
||||||
|
\item Admin mengelola server
|
||||||
|
\item Admin mengelola service
|
||||||
|
\item Admin mengelola domain
|
||||||
|
\item Admin menjalankan DNS check
|
||||||
|
\item Admin mengelola note
|
||||||
|
\item Admin melihat note pada detail server
|
||||||
|
\item Admin sign out
|
||||||
|
\end{itemize}
|
||||||
|
|
||||||
|
\placeholderfigure{Tambahkan diagram use case hasil export UML Anda di sini.}{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{Note}: id, serverId, title, content, category, createdAt, updatedAt
|
||||||
|
\end{itemize}
|
||||||
|
|
||||||
|
Hubungan utama:
|
||||||
|
|
||||||
|
\begin{itemize}
|
||||||
|
\item Server 1..* Service
|
||||||
|
\item Server 1..* Note
|
||||||
|
\item Service 1..* Domain
|
||||||
|
\end{itemize}
|
||||||
|
|
||||||
|
\placeholderfigure{Tambahkan diagram class hasil export UML Anda di sini.}{Class Diagram Minmon}
|
||||||
|
|
||||||
|
\subsection{Activity Diagram}
|
||||||
|
Activity diagram yang disarankan adalah alur \textit{Check DNS from Domain List}:
|
||||||
|
|
||||||
|
\begin{enumerate}
|
||||||
|
\item Admin membuka halaman domain list.
|
||||||
|
\item Admin menekan badge status DNS.
|
||||||
|
\item UI memanggil mutation \texttt{domains.check}.
|
||||||
|
\item Backend melakukan resolve IPv4 A record.
|
||||||
|
\item Backend membandingkan hasil resolve dengan \texttt{expectedServerIp}.
|
||||||
|
\item Backend menyimpan hasil ke database.
|
||||||
|
\item UI me-refresh data dan menampilkan status baru.
|
||||||
|
\end{enumerate}
|
||||||
|
|
||||||
|
\placeholderfigure{Tambahkan activity diagram di sini.}{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}
|
||||||
|
|
||||||
|
\placeholderfigure{Tambahkan state chart diagram di sini.}{State Chart Domain DNS}
|
||||||
|
|
||||||
|
\subsection{Sequence Diagram}
|
||||||
|
Sequence diagram yang cocok untuk proyek ini adalah alur \textit{Create Domain and Check DNS}:
|
||||||
|
|
||||||
|
\begin{itemize}
|
||||||
|
\item Admin
|
||||||
|
\item Web UI
|
||||||
|
\item tRPC API
|
||||||
|
\item Database
|
||||||
|
\item DNS Resolver
|
||||||
|
\end{itemize}
|
||||||
|
|
||||||
|
\placeholderfigure{Tambahkan sequence diagram di sini.}{Sequence Diagram Create Domain and Check DNS}
|
||||||
|
|
||||||
|
\subsection{Collaboration Diagram}
|
||||||
|
Collaboration diagram dapat menunjukkan interaksi antara:
|
||||||
|
|
||||||
|
\begin{itemize}
|
||||||
|
\item Admin
|
||||||
|
\item Dashboard UI
|
||||||
|
\item better-auth client
|
||||||
|
\item tRPC router
|
||||||
|
\item Database
|
||||||
|
\item DNS lookup module
|
||||||
|
\end{itemize}
|
||||||
|
|
||||||
|
\placeholderfigure{Tambahkan collaboration diagram di sini.}{Collaboration Diagram Minmon}
|
||||||
|
|
||||||
|
\section{Potongan Code Fitur-Fitur Utama}
|
||||||
|
Bagian ini menampilkan beberapa potongan kode yang mewakili fitur inti dari implementasi saat ini.
|
||||||
|
|
||||||
|
\subsection{Redirect Root Berdasarkan Session}
|
||||||
|
Potongan berikut menunjukkan bahwa route \uipath{/} akan mengarahkan pengguna ke \uipath{/dashboard} atau \uipath{/login} berdasarkan session yang didapat dari auth client.
|
||||||
|
|
||||||
|
\begin{lstlisting}[style=minmoncode,language=TypeScript,caption={Root redirect berdasarkan session}]
|
||||||
|
export const Route = createFileRoute("/")({
|
||||||
|
beforeLoad: async () => {
|
||||||
|
const session = await authClient.getSession();
|
||||||
|
|
||||||
|
throw redirect({
|
||||||
|
to: session.data ? "/dashboard" : "/login",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
component: () => null,
|
||||||
|
});
|
||||||
|
\end{lstlisting}
|
||||||
|
|
||||||
|
\subsection{Ordering List yang Stabil}
|
||||||
|
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
|
||||||
|
.select()
|
||||||
|
.from(domain)
|
||||||
|
.orderBy(desc(domain.createdAt), desc(domain.id));
|
||||||
|
\end{lstlisting}
|
||||||
|
|
||||||
|
\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",
|
||||||
|
resolvedIps: [],
|
||||||
|
checkedAt: new Date(),
|
||||||
|
message: error.message,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
\end{lstlisting}
|
||||||
|
|
||||||
|
\subsection{Select Input untuk Relasi Data}
|
||||||
|
Form relasi tidak lagi menggunakan input teks biasa untuk foreign key utama, tetapi menggunakan select agar lebih aman.
|
||||||
|
|
||||||
|
\begin{lstlisting}[style=minmoncode,language=TypeScript,caption={Contoh select relasi note -> server}]
|
||||||
|
<form.Field name="serverId">
|
||||||
|
{(field) => (
|
||||||
|
<SelectField
|
||||||
|
label="Server"
|
||||||
|
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}
|
||||||
|
|
||||||
|
\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.
|
||||||
|
|
||||||
|
\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}
|
||||||
|
\textbf{Path akses:} \uipath{/login}
|
||||||
|
|
||||||
|
\begin{enumerate}
|
||||||
|
\item Jalankan aplikasi web dan server.
|
||||||
|
\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}.
|
||||||
|
|
||||||
|
\placeholderfigure{Tambahkan screenshot halaman login di sini.}{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 recent notes.
|
||||||
|
\end{itemize}
|
||||||
|
|
||||||
|
\textbf{Langkah penggunaan:}
|
||||||
|
\begin{enumerate}
|
||||||
|
\item Login sebagai admin.
|
||||||
|
\item Setelah redirect ke dashboard, periksa kartu ringkasan di bagian atas.
|
||||||
|
\item Gunakan link area untuk membuka halaman Servers, Services, Domains, atau Notes.
|
||||||
|
\end{enumerate}
|
||||||
|
|
||||||
|
\placeholderfigure{Tambahkan screenshot dashboard summary di sini.}{Dashboard Summary}
|
||||||
|
|
||||||
|
\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.
|
||||||
|
|
||||||
|
\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 note yang terkait dengan server tersebut.
|
||||||
|
\end{itemize}
|
||||||
|
|
||||||
|
\placeholderfigure{Tambahkan screenshot detail server di sini.}{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 parent pada field \textit{Server}.
|
||||||
|
\item Isi nama service, type, port, protocol, status, dan deskripsi.
|
||||||
|
\item Klik \uibutton{Save service}.
|
||||||
|
\end{enumerate}
|
||||||
|
|
||||||
|
\textbf{Hasil yang diharapkan:} service tersimpan dan terhubung ke server yang dipilih.
|
||||||
|
|
||||||
|
\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 parent 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 parent dari service terkait.
|
||||||
|
|
||||||
|
\subsection{Menjalankan DNS Check}
|
||||||
|
\begin{enumerate}
|
||||||
|
\item Buka halaman domain list.
|
||||||
|
\item Tekan badge status DNS pada row domain yang ingin dicek.
|
||||||
|
\item Tunggu proses mutation 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}.
|
||||||
|
|
||||||
|
\placeholderfigure{Tambahkan screenshot domain list dan DNS check di sini.}{Domain List dan DNS Check}
|
||||||
|
|
||||||
|
\section{Mengelola Data Note}
|
||||||
|
\textbf{Path akses:} \uipath{/dashboard/notes}
|
||||||
|
|
||||||
|
\subsection{Menambah Note}
|
||||||
|
\begin{enumerate}
|
||||||
|
\item Buka menu Notes.
|
||||||
|
\item Klik \uibutton{New note}.
|
||||||
|
\item Pilih server pada field \textit{Server}.
|
||||||
|
\item Isi title, category, dan content.
|
||||||
|
\item Klik \uibutton{Save note}.
|
||||||
|
\end{enumerate}
|
||||||
|
|
||||||
|
\textbf{Hasil yang diharapkan:} note tersimpan dan terhubung ke server terpilih.
|
||||||
|
|
||||||
|
\subsection{Melihat Note pada Detail Server}
|
||||||
|
\begin{enumerate}
|
||||||
|
\item Buka halaman detail server.
|
||||||
|
\item Scroll ke bagian \textit{Server notes}.
|
||||||
|
\item Lihat daftar note yang hanya terkait dengan server tersebut.
|
||||||
|
\end{enumerate}
|
||||||
|
|
||||||
|
\textbf{Hasil yang diharapkan:} note yang tampil hanyalah note dengan \texttt{serverId} yang sesuai.
|
||||||
|
|
||||||
|
\section{Aksi Hapus Data}
|
||||||
|
Pada list server, service, domain, dan note, 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 note 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, 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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
\section*{Saran}
|
||||||
|
\addcontentsline{toc}{section}{Saran}
|
||||||
|
Saran pengembangan untuk tahap berikutnya antara lain:
|
||||||
|
|
||||||
|
\begin{itemize}
|
||||||
|
\item Menambahkan fitur search, filter, dan pagination pada halaman list.
|
||||||
|
\item Menambahkan histori hasil DNS check 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 SSL expiry, ping, atau health endpoint HTTP.
|
||||||
|
\item Menambahkan screenshot final dan diagram UML final agar dokumen ini siap dikumpulkan sebagai laporan akademik lengkap.
|
||||||
|
\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}
|
||||||
|
|
@ -4,109 +4,182 @@
|
||||||
|
|
||||||
### 1.1 Latar Belakang
|
### 1.1 Latar Belakang
|
||||||
|
|
||||||
- Importance of structured server documentation and monitoring
|
- Pentingnya pendataan server, layanan, domain, dan dokumentasi internal secara terpusat
|
||||||
- Need for a simple admin dashboard for managing private servers and hosted services
|
- Kebutuhan dashboard web sederhana untuk membantu admin memantau aset server private
|
||||||
|
- Kebutuhan pemeriksaan DNS dasar agar domain dapat diverifikasi terhadap IP server yang diharapkan
|
||||||
|
|
||||||
### 1.2 Rumusan Masalah
|
### 1.2 Rumusan Masalah
|
||||||
|
|
||||||
- How to manage server data, services, domains, and internal notes in one web app?
|
- Bagaimana membangun aplikasi web terproteksi untuk mengelola server, service, domain, dan note dalam satu dashboard?
|
||||||
- How to check whether a domain resolves to the expected server IP automatically?
|
- Bagaimana memeriksa apakah domain mengarah ke IP server yang benar secara sederhana?
|
||||||
|
- Bagaimana menampilkan relasi data, misalnya note per server, langsung dari halaman detail?
|
||||||
|
|
||||||
### 1.3 Batasan Masalah
|
### 1.3 Batasan Masalah
|
||||||
|
|
||||||
- CRUD + monitoring dashboard only
|
- Aplikasi berupa dashboard CRUD + monitoring ringan
|
||||||
- No SSH, no Docker orchestration, no real deployment automation
|
- Hanya satu aktor admin
|
||||||
- DNS checking limited to A record / IPv4
|
- DNS checking terbatas pada A record / IPv4
|
||||||
- Single admin login
|
- Tidak mencakup SSH, deployment automation, Docker orchestration, atau reverse proxy management
|
||||||
|
- Fokus implementasi pada web app + server app + PostgreSQL
|
||||||
|
|
||||||
### 1.4 Tujuan
|
### 1.4 Tujuan
|
||||||
|
|
||||||
- Build a web-based dashboard for private server service management
|
- Membangun dashboard manajemen layanan server private berbasis web
|
||||||
- Provide simple DNS verification and summary monitoring
|
- Menyediakan CRUD untuk server, service, domain, dan note
|
||||||
|
- Menyediakan pemeriksaan DNS sederhana dan ringkasan dashboard
|
||||||
|
|
||||||
### 1.5 Manfaat
|
### 1.5 Manfaat
|
||||||
|
|
||||||
- Helps admins manage server and service records
|
- Membantu admin mendokumentasikan dan memantau aset server
|
||||||
- Helps students understand CRUD, relationships, and monitoring concepts
|
- Membantu proses pembelajaran relasi data, CRUD, auth, dan monitoring dasar
|
||||||
|
- Menjadi contoh implementasi full-stack berbasis React, tRPC, Drizzle, dan PostgreSQL
|
||||||
|
|
||||||
## BAB 2 - Tinjauan Pustaka
|
## BAB 2 - Tinjauan Pustaka
|
||||||
|
|
||||||
### 2.1 Web-Based Information Systems
|
### 2.1 Sistem Informasi Berbasis Web
|
||||||
|
|
||||||
### 2.2 CRUD Concept
|
### 2.2 Konsep CRUD
|
||||||
|
|
||||||
### 2.3 Monitoring Dashboard Concept
|
### 2.3 Konsep Dashboard Monitoring
|
||||||
|
|
||||||
### 2.4 DNS and A Record Resolution
|
### 2.4 DNS dan Resolusi A Record
|
||||||
|
|
||||||
### 2.5 Technologies Used
|
### 2.5 Otentikasi pada Aplikasi Web
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
- Functional requirements
|
#### Kebutuhan Fungsional
|
||||||
- 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
|
- State chart diagram
|
||||||
- Sequence diagram
|
- Sequence diagram
|
||||||
- Collaboration diagram
|
- Collaboration diagram
|
||||||
|
|
||||||
### 3.3 Perancangan Database
|
### 3.3 Perancangan Database
|
||||||
|
|
||||||
- Server table
|
- Tabel `server`
|
||||||
- Service table
|
- Tabel `service`
|
||||||
- Domain table
|
- Tabel `domain`
|
||||||
- Note table
|
- Tabel `note`
|
||||||
- Auth tables
|
- Tabel auth bawaan better-auth
|
||||||
|
- Relasi:
|
||||||
|
- server -> services
|
||||||
|
- server -> notes
|
||||||
|
- service -> domains
|
||||||
|
- Aturan cascade delete pada relasi utama
|
||||||
|
|
||||||
### 3.4 Perancangan Antarmuka
|
### 3.4 Perancangan Antarmuka
|
||||||
|
|
||||||
- Login page
|
- Halaman login (`/login`)
|
||||||
- Dashboard page
|
- Redirect root (`/`)
|
||||||
- Server list/detail/form
|
- Dashboard summary (`/dashboard`)
|
||||||
- Service list/detail/form
|
- Halaman list/detail/form server
|
||||||
- Domain list/detail/form
|
- Halaman list/detail/form service
|
||||||
- Note list/detail/form
|
- Halaman list/detail/form domain
|
||||||
|
- 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 implementation
|
#### Backend
|
||||||
- Frontend implementation
|
|
||||||
- Authentication implementation
|
- Router tRPC untuk server, service, domain, note, dan dashboard summary
|
||||||
- DNS check implementation
|
- Validasi relasi parent-child pada create/update
|
||||||
|
- 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
|
||||||
|
|
||||||
- Login test
|
- Pengujian login admin
|
||||||
- CRUD server test
|
- Pengujian CRUD server
|
||||||
- CRUD service test
|
- Pengujian CRUD service
|
||||||
- CRUD domain test
|
- Pengujian CRUD domain
|
||||||
- CRUD note test
|
- Pengujian CRUD note
|
||||||
- DNS check test
|
- Pengujian select relasi pada form
|
||||||
- Dashboard summary test
|
- Pengujian DNS check domain
|
||||||
|
- Pengujian note terkait pada halaman detail server
|
||||||
|
- Pengujian ringkasan dashboard
|
||||||
|
- Pengujian typecheck web dan API
|
||||||
|
|
||||||
### 4.3 Hasil Pengujian
|
### 4.3 Hasil Pengujian
|
||||||
|
|
||||||
- Tables/screenshots of successful scenarios
|
- Tabel atau screenshot untuk setiap skenario berhasil
|
||||||
|
- Bukti hasil DNS check (`MATCH`, `MISMATCH`, `UNRESOLVED`)
|
||||||
|
- Bukti relasi data tampil benar di halaman detail dan list
|
||||||
|
|
||||||
### 4.4 Evaluasi
|
### 4.4 Evaluasi
|
||||||
|
|
||||||
- Strengths of the system
|
#### Kelebihan
|
||||||
- Current limitations
|
|
||||||
- Suggested future improvements
|
- Struktur data relasional jelas
|
||||||
|
- 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
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,52 @@
|
||||||
|
|
||||||
## Project Description
|
## Project Description
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
## Main Features
|
## Current Main Features
|
||||||
|
|
||||||
- Admin login with better-auth
|
- Admin sign-in with better-auth
|
||||||
- Dashboard summary for servers, services, domains, DNS status, and inactive services
|
- Root route (`/`) redirects automatically:
|
||||||
|
- 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
|
||||||
- DNS A-record check with match/mismatch/unresolved result
|
- Note CRUD
|
||||||
- Server note/documentation CRUD
|
- Domain DNS check from:
|
||||||
- Sample seed data for demo use
|
- domain list
|
||||||
|
- 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
|
||||||
|
|
||||||
|
|
@ -35,19 +69,30 @@ CORS_ORIGIN=http://localhost:5173
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Start PostgreSQL if needed
|
3. Prepare web environment at `apps/web/.env`
|
||||||
|
|
||||||
|
```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
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Apply schema
|
6. Apply schema
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run db:push
|
bun run db:push
|
||||||
```
|
```
|
||||||
|
|
||||||
5. Seed sample data
|
7. Seed sample data
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run --filter @minmon/db db:seed
|
bun run --filter @minmon/db db:seed
|
||||||
|
|
@ -58,17 +103,44 @@ Seeded admin login:
|
||||||
- Email: `admin@minmon.local`
|
- Email: `admin@minmon.local`
|
||||||
- Password: `admin12345`
|
- Password: `admin12345`
|
||||||
|
|
||||||
6. Run the apps
|
8. Run the apps
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run dev
|
bun run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
7. Open the app
|
9. 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
|
||||||
|
|
@ -77,9 +149,12 @@ 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 dashboard app itself, server app, DB push, and DB seed are verified.
|
- The verified implementation path is the web app + server app + database push + database seed.
|
||||||
|
- 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.
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,16 @@
|
||||||
|
|
||||||
### Main Use Cases
|
### Main Use Cases
|
||||||
|
|
||||||
- Login
|
- Sign in
|
||||||
|
- Access protected dashboard
|
||||||
- View dashboard summary
|
- View dashboard summary
|
||||||
- Manage servers
|
- Manage servers
|
||||||
- Manage services
|
- Manage services
|
||||||
- Manage domains
|
- Manage domains
|
||||||
- Check DNS for domain
|
- Check DNS for a domain
|
||||||
- Manage server notes
|
- Manage notes
|
||||||
- Logout
|
- View notes related to a specific server
|
||||||
|
- Sign out
|
||||||
|
|
||||||
## 2. Class Diagram
|
## 2. Class Diagram
|
||||||
|
|
||||||
|
|
@ -79,19 +81,30 @@
|
||||||
- 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**
|
Suggested activity: **Check DNS from Domain List**
|
||||||
|
|
||||||
Flow:
|
Flow:
|
||||||
|
|
||||||
- Admin opens domain list/detail
|
- Admin opens domain list
|
||||||
- Admin clicks Check DNS
|
- Admin clicks the DNS status badge
|
||||||
- System resolves A record
|
- System resolves IPv4 A record(s)
|
||||||
- System compares resolved IPs with expected server IP
|
- System compares resolved IPs with `expectedServerIp`
|
||||||
- System stores result
|
- System stores check result in the domain record
|
||||||
- System shows Match / Mismatch / Unresolved
|
- System refreshes the list
|
||||||
|
- System shows `MATCH`, `MISMATCH`, or `UNRESOLVED`
|
||||||
|
|
||||||
## 4. State Chart Diagram
|
## 4. State Chart Diagram
|
||||||
|
|
||||||
|
|
@ -99,15 +112,15 @@ Suggested state chart: **Domain DNS Status**
|
||||||
|
|
||||||
States:
|
States:
|
||||||
|
|
||||||
- Unresolved
|
- UNRESOLVED
|
||||||
- Match
|
- MATCH
|
||||||
- Mismatch
|
- MISMATCH
|
||||||
|
|
||||||
Transitions:
|
Transitions:
|
||||||
|
|
||||||
- Check DNS success with expected IP found -> Match
|
- DNS check success and expected IP found -> MATCH
|
||||||
- Check DNS success with expected IP missing -> Mismatch
|
- DNS check success and expected IP not found -> MISMATCH
|
||||||
- Check DNS failure -> Unresolved
|
- DNS resolution failure -> UNRESOLVED
|
||||||
|
|
||||||
## 5. Sequence Diagram
|
## 5. Sequence Diagram
|
||||||
|
|
||||||
|
|
@ -123,24 +136,41 @@ Objects:
|
||||||
|
|
||||||
Flow:
|
Flow:
|
||||||
|
|
||||||
- Admin submits domain form
|
- Admin opens create domain page
|
||||||
- UI sends create request
|
- UI loads available services
|
||||||
- API validates and stores domain
|
- Admin selects a service and submits the form
|
||||||
- Admin triggers DNS check
|
- API validates the service relationship
|
||||||
- UI calls check mutation
|
- API stores the domain
|
||||||
- API resolves DNS
|
- Admin clicks DNS status / check action
|
||||||
- API updates database
|
- UI calls `domains.check`
|
||||||
- UI displays result
|
- API resolves DNS A records
|
||||||
|
- API updates domain check fields in the database
|
||||||
|
- UI refetches and displays the new status
|
||||||
|
|
||||||
## 6. Collaboration Diagram
|
## 6. Collaboration Diagram
|
||||||
|
|
||||||
Show interaction between:
|
Show collaboration between:
|
||||||
|
|
||||||
- Admin
|
- Admin
|
||||||
- Dashboard UI
|
- Dashboard UI
|
||||||
- Auth module
|
- better-auth client / auth middleware
|
||||||
- API router
|
- tRPC routers
|
||||||
- Database
|
- Database
|
||||||
- DNS lookup module
|
- DNS lookup module
|
||||||
|
|
||||||
Focus on how each module collaborates during CRUD and DNS checking.
|
Suggested emphasis:
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
|
||||||
|
|
@ -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.updatedAt));
|
return db.select().from(domain).orderBy(desc(domain.createdAt), desc(domain.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
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.updatedAt));
|
.orderBy(desc(domain.createdAt), desc(domain.id));
|
||||||
}),
|
}),
|
||||||
|
|
||||||
byId: protectedProcedure.input(byIdSchema).query(async ({ input }) => {
|
byId: protectedProcedure.input(byIdSchema).query(async ({ input }) => {
|
||||||
|
|
|
||||||
|
|
@ -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.updatedAt));
|
return db.select().from(note).orderBy(desc(note.createdAt), desc(note.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
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.updatedAt));
|
.orderBy(desc(note.createdAt), desc(note.id));
|
||||||
}),
|
}),
|
||||||
|
|
||||||
byId: protectedProcedure.input(byIdSchema).query(async ({ input }) => {
|
byId: protectedProcedure.input(byIdSchema).query(async ({ input }) => {
|
||||||
|
|
|
||||||
|
|
@ -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.updatedAt));
|
return db.select().from(server).orderBy(desc(server.createdAt), desc(server.id));
|
||||||
}),
|
}),
|
||||||
|
|
||||||
byId: protectedProcedure.input(byIdSchema).query(async ({ input }) => {
|
byId: protectedProcedure.input(byIdSchema).query(async ({ input }) => {
|
||||||
|
|
|
||||||
|
|
@ -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.updatedAt));
|
return db.select().from(service).orderBy(desc(service.createdAt), desc(service.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
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.updatedAt));
|
.orderBy(desc(service.createdAt), desc(service.id));
|
||||||
}),
|
}),
|
||||||
|
|
||||||
byId: protectedProcedure.input(byIdSchema).query(async ({ input }) => {
|
byId: protectedProcedure.input(byIdSchema).query(async ({ input }) => {
|
||||||
|
|
|
||||||
199
packages/ui/src/components/select.tsx
Normal file
199
packages/ui/src/components/select.tsx
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
@ -7,76 +7,118 @@
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1.0000 0 0);
|
||||||
--foreground: oklch(0.145 0 0);
|
--foreground: oklch(0.2686 0 0);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(1.0000 0 0);
|
||||||
--card-foreground: oklch(0.145 0 0);
|
--card-foreground: oklch(0.2686 0 0);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1.0000 0 0);
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: oklch(0.2686 0 0);
|
||||||
--primary: oklch(0.205 0 0);
|
--primary: oklch(0.7686 0.1647 70.0804);
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: oklch(0 0 0);
|
||||||
--secondary: oklch(0.97 0 0);
|
--secondary: oklch(0.9670 0.0029 264.5419);
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--secondary-foreground: oklch(0.4461 0.0263 256.8018);
|
||||||
--muted: oklch(0.97 0 0);
|
--muted: oklch(0.9846 0.0017 247.8389);
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
--muted-foreground: oklch(0.5510 0.0234 264.3637);
|
||||||
--accent: oklch(0.97 0 0);
|
--accent: oklch(0.9869 0.0214 95.2774);
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
--accent-foreground: oklch(0.4732 0.1247 46.2007);
|
||||||
--destructive: oklch(0.58 0.22 27);
|
--destructive: oklch(0.6368 0.2078 25.3313);
|
||||||
--border: oklch(0.922 0 0);
|
--border: oklch(0.9276 0.0058 264.5313);
|
||||||
--input: oklch(0.922 0 0);
|
--input: oklch(0.9276 0.0058 264.5313);
|
||||||
--ring: oklch(0.708 0 0);
|
--ring: oklch(0.7686 0.1647 70.0804);
|
||||||
--chart-1: oklch(0.809 0.105 251.813);
|
--chart-1: oklch(0.7686 0.1647 70.0804);
|
||||||
--chart-2: oklch(0.623 0.214 259.815);
|
--chart-2: oklch(0.6658 0.1574 58.3183);
|
||||||
--chart-3: oklch(0.546 0.245 262.881);
|
--chart-3: oklch(0.5553 0.1455 48.9975);
|
||||||
--chart-4: oklch(0.488 0.243 264.376);
|
--chart-4: oklch(0.4732 0.1247 46.2007);
|
||||||
--chart-5: oklch(0.424 0.199 265.638);
|
--chart-5: oklch(0.4137 0.1054 45.9038);
|
||||||
--radius: 0.625rem;
|
--radius: 0.375rem;
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar: oklch(0.9846 0.0017 247.8389);
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
--sidebar-foreground: oklch(0.2686 0 0);
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
--sidebar-primary: oklch(0.7686 0.1647 70.0804);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(1.0000 0 0);
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
--sidebar-accent: oklch(0.9869 0.0214 95.2774);
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--sidebar-accent-foreground: oklch(0.4732 0.1247 46.2007);
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-border: oklch(0.9276 0.0058 264.5313);
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
--sidebar-ring: oklch(0.7686 0.1647 70.0804);
|
||||||
|
--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.145 0 0);
|
--background: oklch(0.2046 0 0);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.9219 0 0);
|
||||||
--card: oklch(0.205 0 0);
|
--card: oklch(0.2686 0 0);
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.9219 0 0);
|
||||||
--popover: oklch(0.205 0 0);
|
--popover: oklch(0.2686 0 0);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: oklch(0.9219 0 0);
|
||||||
--primary: oklch(0.87 0 0);
|
--primary: oklch(0.7686 0.1647 70.0804);
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
--primary-foreground: oklch(0 0 0);
|
||||||
--secondary: oklch(0.269 0 0);
|
--secondary: oklch(0.2686 0 0);
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary-foreground: oklch(0.9219 0 0);
|
||||||
--muted: oklch(0.269 0 0);
|
--muted: oklch(0.2393 0 0);
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
--muted-foreground: oklch(0.7155 0 0);
|
||||||
--accent: oklch(0.371 0 0);
|
--accent: oklch(0.4732 0.1247 46.2007);
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--accent-foreground: oklch(0.9243 0.1151 95.7459);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.6368 0.2078 25.3313);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(0.3715 0 0);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(0.3715 0 0);
|
||||||
--ring: oklch(0.556 0 0);
|
--ring: oklch(0.7686 0.1647 70.0804);
|
||||||
--chart-1: oklch(0.809 0.105 251.813);
|
--chart-1: oklch(0.8369 0.1644 84.4286);
|
||||||
--chart-2: oklch(0.623 0.214 259.815);
|
--chart-2: oklch(0.6658 0.1574 58.3183);
|
||||||
--chart-3: oklch(0.546 0.245 262.881);
|
--chart-3: oklch(0.4732 0.1247 46.2007);
|
||||||
--chart-4: oklch(0.488 0.243 264.376);
|
--chart-4: oklch(0.5553 0.1455 48.9975);
|
||||||
--chart-5: oklch(0.424 0.199 265.638);
|
--chart-5: oklch(0.4732 0.1247 46.2007);
|
||||||
--sidebar: oklch(0.205 0 0);
|
--sidebar: oklch(0.1684 0 0);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.9219 0 0);
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
--sidebar-primary: oklch(0.7686 0.1647 70.0804);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(1.0000 0 0);
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
--sidebar-accent: oklch(0.4732 0.1247 46.2007);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.9243 0.1151 95.7459);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(0.3715 0 0);
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
--sidebar-ring: oklch(0.7686 0.1647 70.0804);
|
||||||
|
--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 Variable", sans-serif;
|
--font-sans: Inter, 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);
|
||||||
|
|
@ -115,6 +157,32 @@
|
||||||
--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 {
|
||||||
|
|
@ -123,6 +191,7 @@
|
||||||
}
|
}
|
||||||
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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue