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 || "-" }, + ]} + /> + + +
{ + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + > + + +
+ + ({ isSubmitting: state.isSubmitting })}> + {({ isSubmitting }) => } + +
+ + } + > + + {(field) => ( + field.handleChange(e.target.value)} + description={`Available: ${services.data.map((item) => `${item.name} (${item.id})`).join(", ")}`} + /> + )} + +
+ {(field) => field.handleChange(e.target.value)} />} + {(field) => field.handleChange(e.target.value)} />} +
+ {(field) => field.handleChange(e.target.value)} />} +
+
+
+ ); +} 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 ( +
+ + + + +
{ + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + > + + + ({ isSubmitting: state.isSubmitting })}> + {({ isSubmitting }) => } + + + } + > + + {(field) => ( + field.handleChange(e.target.value)} + description={`Available: ${servers.data.map((item) => `${item.name} (${item.id})`).join(", ")}`} + /> + )} + +
+ {(field) => field.handleChange(e.target.value)} />} + {(field) => field.handleChange(e.target.value)} />} +
+ {(field) => field.handleChange(e.target.value)} className="min-h-40" />} +
+
+
+ ); +} 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) }, + ]} + /> + + +
{ + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + > + + + ({ isSubmitting: state.isSubmitting })}> + {({ isSubmitting }) => } + + + } + > +
+ {(field) => field.handleChange(e.target.value)} />} + {(field) => field.handleChange(e.target.value)} />} + {(field) => field.handleChange(e.target.value)} />} + {(field) => field.handleChange(e.target.value)} />} + {(field) => field.handleChange(e.target.value)} />} + {(field) => field.handleChange(e.target.value)} />} + {(field) => field.handleChange(e.target.value as "active" | "maintenance" | "inactive")} />} +
+ {(field) => field.handleChange(e.target.value)} />} +
+
+
+ ); +} 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) }, + ]} + /> + + +
{ + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + > + + + ({ isSubmitting: state.isSubmitting })}> + {({ isSubmitting }) => } + + + } + > + + {(field) => ( + field.handleChange(e.target.value)} + description={`Available: ${servers.data.map((item) => `${item.name} (${item.id})`).join(", ")}`} + /> + )} + +
+ {(field) => field.handleChange(e.target.value)} />} + {(field) => field.handleChange(e.target.value as ServiceFormValues["type"])} />} + {(field) => field.handleChange(e.target.value)} />} + {(field) => field.handleChange(e.target.value)} />} + {(field) => field.handleChange(e.target.value as ServiceFormValues["protocol"])} />} + {(field) => field.handleChange(e.target.value as ServiceFormValues["status"])} />} +
+ {(field) => field.handleChange(e.target.value)} />} +
+
+
+ ); +}