minmon/apps/web/src/routes/dashboard.domains.tsx

137 lines
4.9 KiB
TypeScript

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>
);
}