ui: add protected dashboard CRUD screens and assignment docs
This commit is contained in:
parent
bf42037619
commit
cb0d4d7809
24 changed files with 2662 additions and 101 deletions
|
|
@ -46,7 +46,7 @@ function RootComponent() {
|
|||
disableTransitionOnChange
|
||||
storageKey="vite-ui-theme"
|
||||
>
|
||||
<div className="grid grid-rows-[auto_1fr] h-svh">
|
||||
<div className="grid min-h-svh grid-rows-[auto_1fr] bg-background">
|
||||
<Header />
|
||||
<Outlet />
|
||||
</div>
|
||||
|
|
|
|||
106
apps/web/src/routes/dashboard.domains.new.tsx
Normal file
106
apps/web/src/routes/dashboard.domains.new.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { Button } from "@minmon/ui/components/button";
|
||||
import { useForm } from "@tanstack/react-form";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
import { FormCard, PageSection, QueryStateCard, TextAreaField, TextField } from "@/components/dashboard-ui";
|
||||
import { queryClient, trpc, trpcClient } from "@/utils/trpc";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/domains/new")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
type DomainFormValues = {
|
||||
serviceId: string;
|
||||
name: string;
|
||||
expectedServerIp: string;
|
||||
remarks: string;
|
||||
};
|
||||
|
||||
function RouteComponent() {
|
||||
const navigate = useNavigate();
|
||||
const services = useQuery(trpc.domains.serviceOptions.queryOptions());
|
||||
const createDomain = useMutation({
|
||||
mutationFn: async (values: DomainFormValues) =>
|
||||
trpcClient.domains.create.mutate({
|
||||
serviceId: values.serviceId,
|
||||
name: values.name,
|
||||
expectedServerIp: values.expectedServerIp || undefined,
|
||||
remarks: values.remarks || null,
|
||||
}),
|
||||
onSuccess: async (created) => {
|
||||
toast.success("Domain created");
|
||||
await queryClient.invalidateQueries();
|
||||
navigate({ to: "/dashboard/domains/$domainId", params: { domainId: created.id } });
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
});
|
||||
|
||||
const defaultValues: DomainFormValues = {
|
||||
serviceId: "",
|
||||
name: "",
|
||||
expectedServerIp: "",
|
||||
remarks: "",
|
||||
};
|
||||
|
||||
const form = useForm({
|
||||
defaultValues,
|
||||
onSubmit: async ({ value }) => {
|
||||
await createDomain.mutateAsync(value);
|
||||
},
|
||||
});
|
||||
|
||||
if (services.isLoading) {
|
||||
return <QueryStateCard title="Loading services" description="Fetching service options for the domain form." />;
|
||||
}
|
||||
|
||||
if (services.isError || !services.data) {
|
||||
return <QueryStateCard title="Service options unavailable" description="Create a service before adding domains." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageSection title="Add domain" description="Create a domain or subdomain attached to a service.">
|
||||
<form
|
||||
className="grid gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<FormCard
|
||||
title="Domain details"
|
||||
description="Expected server IP will default from the selected service's server when omitted."
|
||||
footer={
|
||||
<>
|
||||
<Button type="button" variant="ghost" onClick={() => navigate({ to: "/dashboard/domains" })}>
|
||||
Cancel
|
||||
</Button>
|
||||
<form.Subscribe selector={(state) => ({ isSubmitting: state.isSubmitting })}>
|
||||
{({ isSubmitting }) => <Button type="submit">{isSubmitting ? "Saving..." : "Save domain"}</Button>}
|
||||
</form.Subscribe>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form.Field name="serviceId">
|
||||
{(field) => (
|
||||
<TextField
|
||||
label="Service ID"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
description={`Available: ${services.data.map((item) => `${item.name} (${item.id})`).join(", ")}`}
|
||||
/>
|
||||
)}
|
||||
</form.Field>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<form.Field name="name">{(field) => <TextField label="Domain name" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} placeholder="dashboard.minmon.dev" />}</form.Field>
|
||||
<form.Field name="expectedServerIp">{(field) => <TextField label="Expected server IP" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} placeholder="203.0.113.10" />}</form.Field>
|
||||
</div>
|
||||
<form.Field name="remarks">{(field) => <TextAreaField label="Remarks" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} />}</form.Field>
|
||||
</FormCard>
|
||||
</form>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
137
apps/web/src/routes/dashboard.domains.tsx
Normal file
137
apps/web/src/routes/dashboard.domains.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { Button } from "@minmon/ui/components/button";
|
||||
import { StatusBadge } from "@minmon/ui/components/status-badge";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { PageSection, QueryStateCard, ResourceListCard } from "@/components/dashboard-ui";
|
||||
import { queryClient, trpc, trpcClient } from "@/utils/trpc";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/domains")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function dnsStatusVariant(status: "MATCH" | "MISMATCH" | "UNRESOLVED") {
|
||||
if (status === "MATCH") return "success" as const;
|
||||
if (status === "MISMATCH") return "warning" as const;
|
||||
return "destructive" as const;
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
const domains = useQuery(trpc.domains.list.queryOptions());
|
||||
const services = useQuery(trpc.services.list.queryOptions());
|
||||
const removeDomain = useMutation({
|
||||
mutationFn: async (id: string) => trpcClient.domains.delete.mutate({ id }),
|
||||
onSuccess: async () => {
|
||||
toast.success("Domain deleted");
|
||||
await queryClient.invalidateQueries();
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
});
|
||||
const checkDns = useMutation({
|
||||
mutationFn: async (id: string) => trpcClient.domains.check.mutate({ id }),
|
||||
onSuccess: async () => {
|
||||
toast.success("DNS check completed");
|
||||
await queryClient.invalidateQueries();
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
});
|
||||
|
||||
if (domains.isLoading || services.isLoading) {
|
||||
return <QueryStateCard title="Loading domains" description="Fetching domain records and service references." />;
|
||||
}
|
||||
|
||||
if (domains.isError || services.isError || !domains.data || !services.data) {
|
||||
return <QueryStateCard title="Domains unavailable" description="The domain list could not be loaded." />;
|
||||
}
|
||||
|
||||
const serviceMap = new Map(services.data.map((item) => [item.id, item.name]));
|
||||
|
||||
return (
|
||||
<PageSection
|
||||
title="Domains"
|
||||
description="Manage service domains, expected IPs, and DNS resolution checks."
|
||||
action={
|
||||
<Link to="/dashboard/domains/new">
|
||||
<Button>Add domain</Button>
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<ResourceListCard
|
||||
title="Domain list"
|
||||
description="Use the Check DNS action to resolve A records and compare them with the expected server IP."
|
||||
columns={[
|
||||
{
|
||||
key: "name",
|
||||
header: "Domain",
|
||||
render: (item: (typeof domains.data)[number]) => (
|
||||
<div className="grid gap-1">
|
||||
<Link to="/dashboard/domains/$domainId" params={{ domainId: item.id }} className="font-medium hover:underline">
|
||||
{item.name}
|
||||
</Link>
|
||||
<span className="text-xs text-muted-foreground">{item.remarks || "No remarks"}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "service",
|
||||
header: "Service",
|
||||
render: (item: (typeof domains.data)[number]) => serviceMap.get(item.serviceId) || item.serviceId,
|
||||
},
|
||||
{
|
||||
key: "expectedServerIp",
|
||||
header: "Expected IP",
|
||||
render: (item: (typeof domains.data)[number]) => item.expectedServerIp,
|
||||
},
|
||||
{
|
||||
key: "lastResolvedIp",
|
||||
header: "Last resolved IP",
|
||||
render: (item: (typeof domains.data)[number]) => item.lastResolvedIp || "-",
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "DNS status",
|
||||
className: "w-36",
|
||||
render: (item: (typeof domains.data)[number]) => (
|
||||
<StatusBadge variant={dnsStatusVariant(item.resolutionStatus)}>{item.resolutionStatus}</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "Actions",
|
||||
className: "w-44",
|
||||
render: (item: (typeof domains.data)[number]) => (
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-auto px-0"
|
||||
disabled={checkDns.isPending}
|
||||
onClick={() => checkDns.mutate(item.id)}
|
||||
>
|
||||
Check DNS
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-auto px-0 text-red-600 hover:text-red-700"
|
||||
disabled={removeDomain.isPending}
|
||||
onClick={() => {
|
||||
if (confirm(`Delete domain \"${item.name}\"?`)) {
|
||||
removeDomain.mutate(item.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
items={domains.data}
|
||||
emptyTitle="No domains added"
|
||||
emptyDescription="Create a domain after adding a service."
|
||||
/>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
169
apps/web/src/routes/dashboard.index.tsx
Normal file
169
apps/web/src/routes/dashboard.index.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import { StatusBadge } from "@minmon/ui/components/status-badge";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
import {
|
||||
PageSection,
|
||||
QueryStateCard,
|
||||
ResourceListCard,
|
||||
SectionLinks,
|
||||
SummaryCard,
|
||||
} from "@/components/dashboard-ui";
|
||||
import { trpc } from "@/utils/trpc";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const summary = useQuery(trpc.dashboard.summary.queryOptions());
|
||||
|
||||
if (summary.isLoading) {
|
||||
return (
|
||||
<QueryStateCard
|
||||
title="Loading dashboard summary"
|
||||
description="Fetching current counts, DNS check results, and recent notes."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (summary.isError || !summary.data) {
|
||||
return (
|
||||
<QueryStateCard
|
||||
title="Dashboard summary unavailable"
|
||||
description="The overview could not be loaded from the protected dashboard API."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { totals, domainChecks, inactiveServices, recentNotes, recentServiceIssues } = summary.data;
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<PageSection
|
||||
title="Dashboard"
|
||||
description="Summary of servers, services, domains, DNS results, and maintenance items."
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<SummaryCard
|
||||
label="Total servers"
|
||||
value={String(totals.servers)}
|
||||
hint="All managed private servers currently recorded in the system."
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Total services"
|
||||
value={String(totals.services)}
|
||||
hint="Applications or services attached to managed servers."
|
||||
status={
|
||||
inactiveServices > 0
|
||||
? { label: `${inactiveServices} inactive/down`, variant: "warning" }
|
||||
: { label: "All active", variant: "success" }
|
||||
}
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Total domains"
|
||||
value={String(totals.domains)}
|
||||
hint="Domains and subdomains attached to services."
|
||||
/>
|
||||
<SummaryCard
|
||||
label="DNS match results"
|
||||
value={String(domainChecks.match)}
|
||||
hint={`${domainChecks.mismatch} mismatch · ${domainChecks.unresolved} unresolved/error`}
|
||||
status={
|
||||
domainChecks.mismatch || domainChecks.unresolved
|
||||
? { label: "Needs review", variant: "warning" }
|
||||
: { label: "Healthy", variant: "success" }
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</PageSection>
|
||||
|
||||
<PageSection title="Areas" description="Open each section to manage data and review detail pages.">
|
||||
<SectionLinks
|
||||
items={[
|
||||
{ label: "Servers", to: "/dashboard/servers", detail: "Create, edit, delete, and inspect server records." },
|
||||
{ label: "Services", to: "/dashboard/services", detail: "Manage services running on each server." },
|
||||
{ label: "Domains", to: "/dashboard/domains", detail: "Check DNS and compare resolved IPs against expected IPs." },
|
||||
{ label: "Notes", to: "/dashboard/notes", detail: "Write documentation and operational notes per server." },
|
||||
]}
|
||||
/>
|
||||
</PageSection>
|
||||
|
||||
<PageSection title="Recent service issues" description="Services currently marked down or under maintenance.">
|
||||
<ResourceListCard
|
||||
title="Inactive or down services"
|
||||
description="These services affect the dashboard summary and should be reviewed first."
|
||||
columns={[
|
||||
{
|
||||
key: "name",
|
||||
header: "Service",
|
||||
render: (item: (typeof recentServiceIssues)[number]) => (
|
||||
<Link
|
||||
to="/dashboard/services/$serviceId"
|
||||
params={{ serviceId: item.id }}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "serverName",
|
||||
header: "Server",
|
||||
render: (item: (typeof recentServiceIssues)[number]) => item.serverName,
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "Status",
|
||||
className: "w-40",
|
||||
render: (item: (typeof recentServiceIssues)[number]) => (
|
||||
<StatusBadge variant={item.status === "down" ? "destructive" : "warning"}>
|
||||
{item.status}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
]}
|
||||
items={recentServiceIssues}
|
||||
emptyTitle="No inactive services"
|
||||
emptyDescription="All services are currently marked active."
|
||||
/>
|
||||
</PageSection>
|
||||
|
||||
<PageSection title="Recent notes" description="Latest documentation updates that may help during demos or maintenance.">
|
||||
<ResourceListCard
|
||||
title="Recently updated notes"
|
||||
description="Recent server notes and internal documentation entries."
|
||||
columns={[
|
||||
{
|
||||
key: "title",
|
||||
header: "Title",
|
||||
render: (item: (typeof recentNotes)[number]) => (
|
||||
<Link
|
||||
to="/dashboard/notes/$noteId"
|
||||
params={{ noteId: item.id }}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "category",
|
||||
header: "Category",
|
||||
render: (item: (typeof recentNotes)[number]) => item.category || "-",
|
||||
},
|
||||
{
|
||||
key: "updatedAt",
|
||||
header: "Updated",
|
||||
render: (item: (typeof recentNotes)[number]) =>
|
||||
item.updatedAt ? new Date(item.updatedAt).toLocaleString() : "-",
|
||||
},
|
||||
]}
|
||||
items={recentNotes}
|
||||
emptyTitle="No recent notes"
|
||||
emptyDescription="Create a note from the Notes section to start documenting servers."
|
||||
/>
|
||||
</PageSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
apps/web/src/routes/dashboard.notes.new.tsx
Normal file
106
apps/web/src/routes/dashboard.notes.new.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { Button } from "@minmon/ui/components/button";
|
||||
import { useForm } from "@tanstack/react-form";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
import { FormCard, PageSection, QueryStateCard, TextAreaField, TextField } from "@/components/dashboard-ui";
|
||||
import { queryClient, trpc, trpcClient } from "@/utils/trpc";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/notes/new")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
type NoteFormValues = {
|
||||
serverId: string;
|
||||
title: string;
|
||||
category: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
function RouteComponent() {
|
||||
const navigate = useNavigate();
|
||||
const servers = useQuery(trpc.servers.list.queryOptions());
|
||||
const createNote = useMutation({
|
||||
mutationFn: async (values: NoteFormValues) =>
|
||||
trpcClient.notes.create.mutate({
|
||||
serverId: values.serverId,
|
||||
title: values.title,
|
||||
category: values.category || null,
|
||||
content: values.content,
|
||||
}),
|
||||
onSuccess: async (created) => {
|
||||
toast.success("Note created");
|
||||
await queryClient.invalidateQueries();
|
||||
navigate({ to: "/dashboard/notes/$noteId", params: { noteId: created.id } });
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
});
|
||||
|
||||
const defaultValues: NoteFormValues = {
|
||||
serverId: "",
|
||||
title: "",
|
||||
category: "general",
|
||||
content: "",
|
||||
};
|
||||
|
||||
const form = useForm({
|
||||
defaultValues,
|
||||
onSubmit: async ({ value }) => {
|
||||
await createNote.mutateAsync(value);
|
||||
},
|
||||
});
|
||||
|
||||
if (servers.isLoading) {
|
||||
return <QueryStateCard title="Loading servers" description="Fetching server options for the note form." />;
|
||||
}
|
||||
|
||||
if (servers.isError || !servers.data) {
|
||||
return <QueryStateCard title="Server options unavailable" description="Add or load servers before creating notes." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageSection title="New note" description="Add documentation or operational notes for a server.">
|
||||
<form
|
||||
className="grid gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<FormCard
|
||||
title="Note details"
|
||||
description="Each note belongs to one server."
|
||||
footer={
|
||||
<>
|
||||
<Button type="button" variant="ghost" onClick={() => navigate({ to: "/dashboard/notes" })}>
|
||||
Cancel
|
||||
</Button>
|
||||
<form.Subscribe selector={(state) => ({ isSubmitting: state.isSubmitting })}>
|
||||
{({ isSubmitting }) => <Button type="submit">{isSubmitting ? "Saving..." : "Save note"}</Button>}
|
||||
</form.Subscribe>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form.Field name="serverId">
|
||||
{(field) => (
|
||||
<TextField
|
||||
label="Server ID"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
description={`Available: ${servers.data.map((item) => `${item.name} (${item.id})`).join(", ")}`}
|
||||
/>
|
||||
)}
|
||||
</form.Field>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<form.Field name="title">{(field) => <TextField label="Title" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} />}</form.Field>
|
||||
<form.Field name="category">{(field) => <TextField label="Category" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} />}</form.Field>
|
||||
</div>
|
||||
<form.Field name="content">{(field) => <TextAreaField label="Content" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} className="min-h-40" />}</form.Field>
|
||||
</FormCard>
|
||||
</form>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
103
apps/web/src/routes/dashboard.notes.tsx
Normal file
103
apps/web/src/routes/dashboard.notes.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { Button } from "@minmon/ui/components/button";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { PageSection, QueryStateCard, ResourceListCard } from "@/components/dashboard-ui";
|
||||
import { queryClient, trpc, trpcClient } from "@/utils/trpc";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/notes")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const notes = useQuery(trpc.notes.list.queryOptions());
|
||||
const servers = useQuery(trpc.servers.list.queryOptions());
|
||||
const removeNote = useMutation({
|
||||
mutationFn: async (id: string) => trpcClient.notes.delete.mutate({ id }),
|
||||
onSuccess: async () => {
|
||||
toast.success("Note deleted");
|
||||
await queryClient.invalidateQueries();
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
});
|
||||
|
||||
if (notes.isLoading || servers.isLoading) {
|
||||
return <QueryStateCard title="Loading notes" description="Fetching server documentation and internal notes." />;
|
||||
}
|
||||
|
||||
if (notes.isError || servers.isError || !notes.data || !servers.data) {
|
||||
return <QueryStateCard title="Notes unavailable" description="The note list could not be loaded." />;
|
||||
}
|
||||
|
||||
const serverMap = new Map(servers.data.map((item) => [item.id, item.name]));
|
||||
|
||||
return (
|
||||
<PageSection
|
||||
title="Notes"
|
||||
description="Manage documentation, incident notes, and internal reminders for each server."
|
||||
action={
|
||||
<Link to="/dashboard/notes/new">
|
||||
<Button>New note</Button>
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<ResourceListCard
|
||||
title="Server notes"
|
||||
description="Each note belongs to one server and acts as lightweight internal documentation."
|
||||
columns={[
|
||||
{
|
||||
key: "title",
|
||||
header: "Title",
|
||||
render: (item: (typeof notes.data)[number]) => (
|
||||
<div className="grid gap-1">
|
||||
<Link to="/dashboard/notes/$noteId" params={{ noteId: item.id }} className="font-medium hover:underline">
|
||||
{item.title}
|
||||
</Link>
|
||||
<span className="text-xs text-muted-foreground line-clamp-2">{item.content}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "server",
|
||||
header: "Server",
|
||||
render: (item: (typeof notes.data)[number]) => serverMap.get(item.serverId) || item.serverId,
|
||||
},
|
||||
{
|
||||
key: "category",
|
||||
header: "Category",
|
||||
render: (item: (typeof notes.data)[number]) => item.category || "-",
|
||||
},
|
||||
{
|
||||
key: "updatedAt",
|
||||
header: "Updated",
|
||||
render: (item: (typeof notes.data)[number]) => new Date(item.updatedAt).toLocaleString(),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "Actions",
|
||||
className: "w-28",
|
||||
render: (item: (typeof notes.data)[number]) => (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-auto px-0 text-red-600 hover:text-red-700"
|
||||
disabled={removeNote.isPending}
|
||||
onClick={() => {
|
||||
if (confirm(`Delete note \"${item.title}\"?`)) {
|
||||
removeNote.mutate(item.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
items={notes.data}
|
||||
emptyTitle="No notes yet"
|
||||
emptyDescription="Add a note to document maintenance, incidents, or general server information."
|
||||
/>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
190
apps/web/src/routes/dashboard.servers.new.tsx
Normal file
190
apps/web/src/routes/dashboard.servers.new.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import { Button } from "@minmon/ui/components/button";
|
||||
import { useForm } from "@tanstack/react-form";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
import { FormCard, PageSection, TextAreaField, TextField } from "@/components/dashboard-ui";
|
||||
import { queryClient, trpcClient } from "@/utils/trpc";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/servers/new")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
type ServerFormValues = {
|
||||
name: string;
|
||||
primaryIpAddress: string;
|
||||
secondaryIpAddress: string;
|
||||
operatingSystem: string;
|
||||
location: string;
|
||||
provider: string;
|
||||
description: string;
|
||||
status: "active" | "maintenance" | "inactive";
|
||||
};
|
||||
|
||||
function RouteComponent() {
|
||||
const navigate = useNavigate();
|
||||
const createServer = useMutation({
|
||||
mutationFn: async (values: ServerFormValues) =>
|
||||
trpcClient.servers.create.mutate({
|
||||
...values,
|
||||
secondaryIpAddress: values.secondaryIpAddress || null,
|
||||
operatingSystem: values.operatingSystem || null,
|
||||
location: values.location || null,
|
||||
provider: values.provider || null,
|
||||
description: values.description || null,
|
||||
}),
|
||||
onSuccess: async (created) => {
|
||||
toast.success("Server created");
|
||||
await queryClient.invalidateQueries();
|
||||
navigate({ to: "/dashboard/servers/$serverId", params: { serverId: created.id } });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const defaultValues: ServerFormValues = {
|
||||
name: "",
|
||||
primaryIpAddress: "",
|
||||
secondaryIpAddress: "",
|
||||
operatingSystem: "",
|
||||
location: "",
|
||||
provider: "",
|
||||
description: "",
|
||||
status: "active",
|
||||
};
|
||||
|
||||
const form = useForm({
|
||||
defaultValues,
|
||||
onSubmit: async ({ value }) => {
|
||||
await createServer.mutateAsync(value);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<PageSection title="Add server" description="Create a new managed server record.">
|
||||
<form
|
||||
className="grid gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<FormCard
|
||||
title="Server details"
|
||||
description="Server name and primary IP are required."
|
||||
footer={
|
||||
<>
|
||||
<Button type="button" variant="ghost" onClick={() => navigate({ to: "/dashboard/servers" })}>
|
||||
Cancel
|
||||
</Button>
|
||||
<form.Subscribe selector={(state) => ({ isSubmitting: state.isSubmitting })}>
|
||||
{({ isSubmitting }) => <Button type="submit">{isSubmitting ? "Saving..." : "Save server"}</Button>}
|
||||
</form.Subscribe>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<form.Field name="name">
|
||||
{(field) => (
|
||||
<TextField
|
||||
label="Server name"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
placeholder="Production VPS"
|
||||
/>
|
||||
)}
|
||||
</form.Field>
|
||||
<form.Field name="primaryIpAddress">
|
||||
{(field) => (
|
||||
<TextField
|
||||
label="Primary IP address"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
placeholder="203.0.113.10"
|
||||
/>
|
||||
)}
|
||||
</form.Field>
|
||||
<form.Field name="secondaryIpAddress">
|
||||
{(field) => (
|
||||
<TextField
|
||||
label="Secondary IP address"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
placeholder="203.0.113.11"
|
||||
/>
|
||||
)}
|
||||
</form.Field>
|
||||
<form.Field name="operatingSystem">
|
||||
{(field) => (
|
||||
<TextField
|
||||
label="Operating system"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
placeholder="Ubuntu 24.04 LTS"
|
||||
/>
|
||||
)}
|
||||
</form.Field>
|
||||
<form.Field name="location">
|
||||
{(field) => (
|
||||
<TextField
|
||||
label="Location"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
placeholder="Singapore"
|
||||
/>
|
||||
)}
|
||||
</form.Field>
|
||||
<form.Field name="provider">
|
||||
{(field) => (
|
||||
<TextField
|
||||
label="Provider"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
placeholder="DigitalOcean"
|
||||
/>
|
||||
)}
|
||||
</form.Field>
|
||||
<form.Field name="status">
|
||||
{(field) => (
|
||||
<TextField
|
||||
label="Status"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value as "active" | "maintenance" | "inactive")}
|
||||
placeholder="active"
|
||||
/>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
<form.Field name="description">
|
||||
{(field) => (
|
||||
<TextAreaField
|
||||
label="Description"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
placeholder="Describe the purpose of this server."
|
||||
/>
|
||||
)}
|
||||
</form.Field>
|
||||
</FormCard>
|
||||
</form>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
126
apps/web/src/routes/dashboard.servers.tsx
Normal file
126
apps/web/src/routes/dashboard.servers.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { Button } from "@minmon/ui/components/button";
|
||||
import { StatusBadge } from "@minmon/ui/components/status-badge";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { PageSection, QueryStateCard, ResourceListCard } from "@/components/dashboard-ui";
|
||||
import { queryClient, trpc, trpcClient } from "@/utils/trpc";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/servers")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function serverStatusVariant(status: "active" | "maintenance" | "inactive") {
|
||||
if (status === "active") return "success" as const;
|
||||
if (status === "maintenance") return "warning" as const;
|
||||
return "neutral" as const;
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
const servers = useQuery(trpc.servers.list.queryOptions());
|
||||
const removeServer = useMutation({
|
||||
mutationFn: async (id: string) => trpcClient.servers.delete.mutate({ id }),
|
||||
onSuccess: async () => {
|
||||
toast.success("Server deleted");
|
||||
await queryClient.invalidateQueries();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
if (servers.isLoading) {
|
||||
return (
|
||||
<QueryStateCard
|
||||
title="Loading servers"
|
||||
description="Fetching the current server inventory from the protected dashboard API."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (servers.isError || !servers.data) {
|
||||
return (
|
||||
<QueryStateCard
|
||||
title="Servers unavailable"
|
||||
description="The server list could not be loaded right now."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageSection
|
||||
title="Servers"
|
||||
description="Manage server inventory, IP addresses, operating systems, and current status."
|
||||
action={
|
||||
<Link to="/dashboard/servers/new">
|
||||
<Button>Add server</Button>
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<ResourceListCard
|
||||
title="Server list"
|
||||
description="Each server can have many services and notes. Deleting a server also removes its related records."
|
||||
columns={[
|
||||
{
|
||||
key: "name",
|
||||
header: "Server",
|
||||
render: (item: (typeof servers.data)[number]) => (
|
||||
<div className="grid gap-1">
|
||||
<Link
|
||||
to="/dashboard/servers/$serverId"
|
||||
params={{ serverId: item.id }}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
<span className="text-xs text-muted-foreground">{item.description || "No description"}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "primaryIpAddress",
|
||||
header: "Primary IP",
|
||||
render: (item: (typeof servers.data)[number]) => item.primaryIpAddress,
|
||||
},
|
||||
{
|
||||
key: "location",
|
||||
header: "Location / Provider",
|
||||
render: (item: (typeof servers.data)[number]) => [item.location, item.provider].filter(Boolean).join(" / ") || "-",
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "Status",
|
||||
className: "w-32",
|
||||
render: (item: (typeof servers.data)[number]) => (
|
||||
<StatusBadge variant={serverStatusVariant(item.status)}>{item.status}</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "Actions",
|
||||
className: "w-28",
|
||||
render: (item: (typeof servers.data)[number]) => (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-auto px-0 text-red-600 hover:text-red-700"
|
||||
disabled={removeServer.isPending}
|
||||
onClick={() => {
|
||||
if (confirm(`Delete server \"${item.name}\" and all related services, domains, and notes?`)) {
|
||||
removeServer.mutate(item.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
items={servers.data}
|
||||
emptyTitle="No servers added"
|
||||
emptyDescription="Add your first server to start managing services, notes, and domains."
|
||||
/>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
122
apps/web/src/routes/dashboard.services.new.tsx
Normal file
122
apps/web/src/routes/dashboard.services.new.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { Button } from "@minmon/ui/components/button";
|
||||
import { useForm } from "@tanstack/react-form";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
import { FormCard, PageSection, QueryStateCard, TextAreaField, TextField } from "@/components/dashboard-ui";
|
||||
import { queryClient, trpc, trpcClient } from "@/utils/trpc";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/services/new")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
type ServiceFormValues = {
|
||||
serverId: string;
|
||||
name: string;
|
||||
type: "web_app" | "mail" | "database" | "api" | "proxy" | "other";
|
||||
internalPort: string;
|
||||
externalPort: string;
|
||||
protocol: "http" | "https" | "tcp" | "udp" | "other";
|
||||
status: "active" | "down" | "maintenance";
|
||||
description: string;
|
||||
};
|
||||
|
||||
function RouteComponent() {
|
||||
const navigate = useNavigate();
|
||||
const servers = useQuery(trpc.servers.list.queryOptions());
|
||||
const createService = useMutation({
|
||||
mutationFn: async (values: ServiceFormValues) =>
|
||||
trpcClient.services.create.mutate({
|
||||
serverId: values.serverId,
|
||||
name: values.name,
|
||||
type: values.type,
|
||||
internalPort: values.internalPort ? Number(values.internalPort) : null,
|
||||
externalPort: values.externalPort ? Number(values.externalPort) : null,
|
||||
protocol: values.protocol,
|
||||
status: values.status,
|
||||
description: values.description || null,
|
||||
}),
|
||||
onSuccess: async (created) => {
|
||||
toast.success("Service created");
|
||||
await queryClient.invalidateQueries();
|
||||
navigate({ to: "/dashboard/services/$serviceId", params: { serviceId: created.id } });
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
});
|
||||
|
||||
const defaultValues: ServiceFormValues = {
|
||||
serverId: "",
|
||||
name: "",
|
||||
type: "web_app",
|
||||
internalPort: "",
|
||||
externalPort: "",
|
||||
protocol: "http",
|
||||
status: "active",
|
||||
description: "",
|
||||
};
|
||||
|
||||
const form = useForm({
|
||||
defaultValues,
|
||||
onSubmit: async ({ value }) => {
|
||||
await createService.mutateAsync(value);
|
||||
},
|
||||
});
|
||||
|
||||
if (servers.isLoading) {
|
||||
return <QueryStateCard title="Loading servers" description="Fetching server options for the service form." />;
|
||||
}
|
||||
|
||||
if (servers.isError || !servers.data) {
|
||||
return <QueryStateCard title="Server options unavailable" description="Add or load servers before creating a service." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageSection title="Add service" description="Create a new service under an existing server.">
|
||||
<form
|
||||
className="grid gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<FormCard
|
||||
title="Service details"
|
||||
description="A service must belong to a server."
|
||||
footer={
|
||||
<>
|
||||
<Button type="button" variant="ghost" onClick={() => navigate({ to: "/dashboard/services" })}>
|
||||
Cancel
|
||||
</Button>
|
||||
<form.Subscribe selector={(state) => ({ isSubmitting: state.isSubmitting })}>
|
||||
{({ isSubmitting }) => <Button type="submit">{isSubmitting ? "Saving..." : "Save service"}</Button>}
|
||||
</form.Subscribe>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form.Field name="serverId">
|
||||
{(field) => (
|
||||
<TextField
|
||||
label="Server ID"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
description={`Available: ${servers.data.map((item) => `${item.name} (${item.id})`).join(", ")}`}
|
||||
/>
|
||||
)}
|
||||
</form.Field>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<form.Field name="name">{(field) => <TextField label="Service name" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} />}</form.Field>
|
||||
<form.Field name="type">{(field) => <TextField label="Service type" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value as ServiceFormValues["type"])} />}</form.Field>
|
||||
<form.Field name="internalPort">{(field) => <TextField label="Internal port" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} />}</form.Field>
|
||||
<form.Field name="externalPort">{(field) => <TextField label="External port" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} />}</form.Field>
|
||||
<form.Field name="protocol">{(field) => <TextField label="Protocol" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value as ServiceFormValues["protocol"])} />}</form.Field>
|
||||
<form.Field name="status">{(field) => <TextField label="Status" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value as ServiceFormValues["status"])} />}</form.Field>
|
||||
</div>
|
||||
<form.Field name="description">{(field) => <TextAreaField label="Description" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} />}</form.Field>
|
||||
</FormCard>
|
||||
</form>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
123
apps/web/src/routes/dashboard.services.tsx
Normal file
123
apps/web/src/routes/dashboard.services.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { Button } from "@minmon/ui/components/button";
|
||||
import { StatusBadge } from "@minmon/ui/components/status-badge";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { PageSection, QueryStateCard, ResourceListCard } from "@/components/dashboard-ui";
|
||||
import { queryClient, trpc, trpcClient } from "@/utils/trpc";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/services")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function serviceStatusVariant(status: "active" | "down" | "maintenance") {
|
||||
if (status === "active") return "success" as const;
|
||||
if (status === "down") return "destructive" as const;
|
||||
return "warning" as const;
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
const services = useQuery(trpc.services.list.queryOptions());
|
||||
const servers = useQuery(trpc.servers.list.queryOptions());
|
||||
const removeService = useMutation({
|
||||
mutationFn: async (id: string) => trpcClient.services.delete.mutate({ id }),
|
||||
onSuccess: async () => {
|
||||
toast.success("Service deleted");
|
||||
await queryClient.invalidateQueries();
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
});
|
||||
|
||||
if (services.isLoading || servers.isLoading) {
|
||||
return <QueryStateCard title="Loading services" description="Fetching service records and server references." />;
|
||||
}
|
||||
|
||||
if (services.isError || servers.isError || !services.data || !servers.data) {
|
||||
return <QueryStateCard title="Services unavailable" description="The service list could not be loaded." />;
|
||||
}
|
||||
|
||||
const serverMap = new Map(servers.data.map((item) => [item.id, item.name]));
|
||||
|
||||
return (
|
||||
<PageSection
|
||||
title="Services"
|
||||
description="Manage services running on each server, including ports, protocol, and current status."
|
||||
action={
|
||||
<Link to="/dashboard/services/new">
|
||||
<Button>Add service</Button>
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<ResourceListCard
|
||||
title="Service list"
|
||||
description="Each service belongs to exactly one server. Domains are attached to services."
|
||||
columns={[
|
||||
{
|
||||
key: "name",
|
||||
header: "Service",
|
||||
render: (item: (typeof services.data)[number]) => (
|
||||
<div className="grid gap-1">
|
||||
<Link
|
||||
to="/dashboard/services/$serviceId"
|
||||
params={{ serviceId: item.id }}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
<span className="text-xs text-muted-foreground">{item.description || "No description"}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "server",
|
||||
header: "Server",
|
||||
render: (item: (typeof services.data)[number]) => serverMap.get(item.serverId) || item.serverId,
|
||||
},
|
||||
{
|
||||
key: "ports",
|
||||
header: "Ports",
|
||||
render: (item: (typeof services.data)[number]) =>
|
||||
`${item.internalPort ?? "-"} / ${item.externalPort ?? "-"}`,
|
||||
},
|
||||
{
|
||||
key: "protocol",
|
||||
header: "Protocol",
|
||||
render: (item: (typeof services.data)[number]) => item.protocol,
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "Status",
|
||||
className: "w-32",
|
||||
render: (item: (typeof services.data)[number]) => (
|
||||
<StatusBadge variant={serviceStatusVariant(item.status)}>{item.status}</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "Actions",
|
||||
className: "w-28",
|
||||
render: (item: (typeof services.data)[number]) => (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-auto px-0 text-red-600 hover:text-red-700"
|
||||
disabled={removeService.isPending}
|
||||
onClick={() => {
|
||||
if (confirm(`Delete service \"${item.name}\" and its related domains?`)) {
|
||||
removeService.mutate(item.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
items={services.data}
|
||||
emptyTitle="No services added"
|
||||
emptyDescription="Create a service after adding at least one server."
|
||||
/>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,33 +1,17 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { trpc } from "@/utils/trpc";
|
||||
import { DashboardShell } from "@/components/dashboard-shell";
|
||||
import { requireSession } from "@/lib/protected-route";
|
||||
|
||||
export const Route = createFileRoute("/dashboard")({
|
||||
component: RouteComponent,
|
||||
beforeLoad: async () => {
|
||||
const session = await authClient.getSession();
|
||||
if (!session.data) {
|
||||
redirect({
|
||||
to: "/login",
|
||||
throw: true,
|
||||
});
|
||||
}
|
||||
return { session };
|
||||
},
|
||||
beforeLoad: requireSession,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { session } = Route.useRouteContext();
|
||||
|
||||
const privateData = useQuery(trpc.privateData.queryOptions());
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
<p>Welcome {session.data?.user.name}</p>
|
||||
<p>API: {privateData.data?.message}</p>
|
||||
</div>
|
||||
<DashboardShell userName={session.data?.user.name} userEmail={session.data?.user.email} />
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue