diff --git a/apps/web/src/routes/dashboard.domains.$domainId.tsx b/apps/web/src/routes/dashboard.domains.$domainId.tsx
new file mode 100644
index 0000000..5f15dc4
--- /dev/null
+++ b/apps/web/src/routes/dashboard.domains.$domainId.tsx
@@ -0,0 +1,144 @@
+import { Button } from "@minmon/ui/components/button";
+import { StatusBadge } from "@minmon/ui/components/status-badge";
+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 { DetailCard, FormCard, PageSection, QueryStateCard, TextAreaField, TextField } from "@/components/dashboard-ui";
+import { queryClient, trpc, trpcClient } from "@/utils/trpc";
+
+export const Route = createFileRoute("/dashboard/domains/$domainId")({
+ component: RouteComponent,
+});
+
+type DomainFormValues = {
+ serviceId: string;
+ name: string;
+ expectedServerIp: string;
+ remarks: string;
+};
+
+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 { domainId } = Route.useParams();
+ const navigate = useNavigate();
+ const domain = useQuery(trpc.domains.byId.queryOptions({ id: domainId }));
+ const services = useQuery(trpc.domains.serviceOptions.queryOptions());
+
+ const updateDomain = useMutation({
+ mutationFn: async (values: DomainFormValues) =>
+ trpcClient.domains.update.mutate({
+ id: domainId,
+ serviceId: values.serviceId || undefined,
+ name: values.name,
+ expectedServerIp: values.expectedServerIp || undefined,
+ remarks: values.remarks || null,
+ }),
+ onSuccess: async () => {
+ toast.success("Domain updated");
+ await queryClient.invalidateQueries();
+ },
+ onError: (error) => toast.error(error.message),
+ });
+ const checkDns = useMutation({
+ mutationFn: async () => trpcClient.domains.check.mutate({ id: domainId }),
+ onSuccess: async () => {
+ toast.success("DNS check completed");
+ await queryClient.invalidateQueries();
+ },
+ onError: (error) => toast.error(error.message),
+ });
+
+ if (domain.isLoading || services.isLoading) {
+ return ;
+ }
+
+ if (domain.isError || services.isError || !domain.data || !services.data) {
+ return ;
+ }
+
+ const defaultValues: DomainFormValues = {
+ serviceId: domain.data.serviceId,
+ name: domain.data.name,
+ expectedServerIp: domain.data.expectedServerIp,
+ remarks: domain.data.remarks ?? "",
+ };
+
+ const form = useForm({
+ defaultValues,
+ onSubmit: async ({ value }) => {
+ await updateDomain.mutateAsync(value);
+ },
+ });
+
+ return (
+
+
+ {domain.data.resolutionStatus} },
+ { label: "Expected IP", value: domain.data.expectedServerIp },
+ { label: "Last resolved IP", value: domain.data.lastResolvedIp || "-" },
+ { label: "Resolved IPv4 list", value: domain.data.lastResolvedIps.join(", ") || "-" },
+ { label: "Last checked at", value: domain.data.lastCheckedAt ? new Date(domain.data.lastCheckedAt).toLocaleString() : "-" },
+ { label: "Check message", value: domain.data.lastCheckMessage || "-" },
+ ]}
+ />
+
+
+
+
+ );
+}
diff --git a/apps/web/src/routes/dashboard.notes.$noteId.tsx b/apps/web/src/routes/dashboard.notes.$noteId.tsx
new file mode 100644
index 0000000..c56fc07
--- /dev/null
+++ b/apps/web/src/routes/dashboard.notes.$noteId.tsx
@@ -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 { DetailCard, FormCard, PageSection, QueryStateCard, TextAreaField, TextField } from "@/components/dashboard-ui";
+import { queryClient, trpc, trpcClient } from "@/utils/trpc";
+
+export const Route = createFileRoute("/dashboard/notes/$noteId")({
+ component: RouteComponent,
+});
+
+type NoteFormValues = {
+ serverId: string;
+ title: string;
+ category: string;
+ content: string;
+};
+
+function RouteComponent() {
+ const { noteId } = Route.useParams();
+ const navigate = useNavigate();
+ const note = useQuery(trpc.notes.byId.queryOptions({ id: noteId }));
+ const servers = useQuery(trpc.servers.list.queryOptions());
+
+ const updateNote = useMutation({
+ mutationFn: async (values: NoteFormValues) =>
+ trpcClient.notes.update.mutate({
+ id: noteId,
+ serverId: values.serverId,
+ title: values.title,
+ category: values.category || null,
+ content: values.content,
+ }),
+ onSuccess: async () => {
+ toast.success("Note updated");
+ await queryClient.invalidateQueries();
+ },
+ onError: (error) => toast.error(error.message),
+ });
+
+ if (note.isLoading || servers.isLoading) {
+ return ;
+ }
+
+ if (note.isError || servers.isError || !note.data || !servers.data) {
+ return ;
+ }
+
+ const defaultValues: NoteFormValues = {
+ serverId: note.data.serverId,
+ title: note.data.title,
+ category: note.data.category ?? "",
+ content: note.data.content,
+ };
+
+ const form = useForm({
+ defaultValues,
+ onSubmit: async ({ value }) => {
+ await updateNote.mutateAsync(value);
+ },
+ });
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/routes/dashboard.servers.$serverId.tsx b/apps/web/src/routes/dashboard.servers.$serverId.tsx
new file mode 100644
index 0000000..a0253f7
--- /dev/null
+++ b/apps/web/src/routes/dashboard.servers.$serverId.tsx
@@ -0,0 +1,129 @@
+import { Button } from "@minmon/ui/components/button";
+import { StatusBadge } from "@minmon/ui/components/status-badge";
+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 { DetailCard, FormCard, PageSection, QueryStateCard, TextAreaField, TextField } from "@/components/dashboard-ui";
+import { queryClient, trpc, trpcClient } from "@/utils/trpc";
+
+export const Route = createFileRoute("/dashboard/servers/$serverId")({
+ 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 { serverId } = Route.useParams();
+ const navigate = useNavigate();
+ const server = useQuery(trpc.servers.byId.queryOptions({ id: serverId }));
+
+ const updateServer = useMutation({
+ mutationFn: async (values: ServerFormValues) =>
+ trpcClient.servers.update.mutate({
+ id: serverId,
+ ...values,
+ secondaryIpAddress: values.secondaryIpAddress || null,
+ operatingSystem: values.operatingSystem || null,
+ location: values.location || null,
+ provider: values.provider || null,
+ description: values.description || null,
+ }),
+ onSuccess: async () => {
+ toast.success("Server updated");
+ await queryClient.invalidateQueries();
+ },
+ onError: (error) => toast.error(error.message),
+ });
+
+ if (server.isLoading) {
+ return ;
+ }
+
+ if (server.isError || !server.data) {
+ return ;
+ }
+
+ const defaultValues: ServerFormValues = {
+ name: server.data.name,
+ primaryIpAddress: server.data.primaryIpAddress,
+ secondaryIpAddress: server.data.secondaryIpAddress ?? "",
+ operatingSystem: server.data.operatingSystem ?? "",
+ location: server.data.location ?? "",
+ provider: server.data.provider ?? "",
+ description: server.data.description ?? "",
+ status: server.data.status,
+ };
+
+ const form = useForm({
+ defaultValues,
+ onSubmit: async ({ value }) => {
+ await updateServer.mutateAsync(value);
+ },
+ });
+
+ return (
+
+
+ {server.data.status} },
+ { label: "Primary IP", value: server.data.primaryIpAddress },
+ { label: "Secondary IP", value: server.data.secondaryIpAddress || "-" },
+ { label: "Operating system", value: server.data.operatingSystem || "-" },
+ { label: "Location", value: server.data.location || "-" },
+ { label: "Provider", value: server.data.provider || "-" },
+ { label: "Services", value: String(server.data.summary.services) },
+ { label: "Notes", value: String(server.data.summary.notes) },
+ ]}
+ />
+
+
+
+
+ );
+}
diff --git a/apps/web/src/routes/dashboard.services.$serviceId.tsx b/apps/web/src/routes/dashboard.services.$serviceId.tsx
new file mode 100644
index 0000000..598ee1d
--- /dev/null
+++ b/apps/web/src/routes/dashboard.services.$serviceId.tsx
@@ -0,0 +1,142 @@
+import { Button } from "@minmon/ui/components/button";
+import { StatusBadge } from "@minmon/ui/components/status-badge";
+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 { DetailCard, FormCard, PageSection, QueryStateCard, TextAreaField, TextField } from "@/components/dashboard-ui";
+import { queryClient, trpc, trpcClient } from "@/utils/trpc";
+
+export const Route = createFileRoute("/dashboard/services/$serviceId")({
+ 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 { serviceId } = Route.useParams();
+ const navigate = useNavigate();
+ const service = useQuery(trpc.services.byId.queryOptions({ id: serviceId }));
+ const servers = useQuery(trpc.servers.list.queryOptions());
+
+ const updateService = useMutation({
+ mutationFn: async (values: ServiceFormValues) =>
+ trpcClient.services.update.mutate({
+ id: serviceId,
+ 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 () => {
+ toast.success("Service updated");
+ await queryClient.invalidateQueries();
+ },
+ onError: (error) => toast.error(error.message),
+ });
+
+ if (service.isLoading || servers.isLoading) {
+ return ;
+ }
+
+ if (service.isError || servers.isError || !service.data || !servers.data) {
+ return ;
+ }
+
+ const defaultValues: ServiceFormValues = {
+ serverId: service.data.serverId,
+ name: service.data.name,
+ type: service.data.type,
+ internalPort: service.data.internalPort?.toString() ?? "",
+ externalPort: service.data.externalPort?.toString() ?? "",
+ protocol: service.data.protocol,
+ status: service.data.status,
+ description: service.data.description ?? "",
+ };
+
+ const form = useForm({
+ defaultValues,
+ onSubmit: async ({ value }) => {
+ await updateService.mutateAsync(value);
+ },
+ });
+
+ return (
+
+
+ {service.data.status} },
+ { label: "Server ID", value: service.data.serverId },
+ { label: "Service type", value: service.data.type },
+ { label: "Protocol", value: service.data.protocol },
+ { label: "Internal port", value: service.data.internalPort ?? "-" },
+ { label: "External port", value: service.data.externalPort ?? "-" },
+ { label: "Related domains", value: String(service.data.summary.domains) },
+ ]}
+ />
+
+
+
+
+ );
+}