mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-28 07:41:27 +00:00
feat: domain monitoring (merge plan M2, phase 1)
Port of the domain-intelligence pipeline from the old Beszel fork, adapted to Dash's SQLite + no-PocketBase architecture: - internal/domain: RDAP via IANA bootstrap (cached), TCP WHOIS :43 fallback with multi-format parser, DNS (NS/MX/TXT/CNAME/A/AAAA), TLS leaf cert details, ip-api.com geolocation, provider detection (DNS/email/hosting/CA). HTML scraping, paid APIs, and exec-based whois deliberately dropped. - API: domains CRUD + synchronous refresh + domain_checks snapshot history; refresh runs inside the monitor sweep on interval_h. - UI: /domains + /domains/:id pages, add/edit dialog, domain board widget (favicon + expiry countdown), palette + nav entries, domainId picker in the widget dialog. - Test seam: Server.lookup injects the refresh; domain tests run hermetic against a stubbed result. Verified live: github.com resolves MarkMonitor expiry 2028-10, Route 53 DNS, GitHub Pages hosting, Sectigo cert with 69d left. Authored By: TDvorak <[email protected]> Generated with Devin (https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
1ff1b29ae7
commit
f9fd1d9cf2
18 files changed
+2376
-14
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, Route, useLocation } from "wouter";
|
||||
import { Activity, LayoutGrid, Moon, Plus, Search, Settings, Sun } from "lucide-react";
|
||||
import { Activity, Globe, LayoutGrid, Moon, Plus, Search, Settings, Sun } from "lucide-react";
|
||||
import { BoardProvider, useBoard } from "@/board/store";
|
||||
import { Board } from "@/board/Board";
|
||||
import { ServiceDialog } from "@/components/ServiceDialog";
|
||||
@@ -9,6 +9,8 @@ import { SettingsDialog } from "@/components/SettingsDialog";
|
||||
import { CommandPalette } from "@/components/CommandPalette";
|
||||
import { MonitorsPage } from "@/pages/MonitorsPage";
|
||||
import { MonitorDetailPage } from "@/pages/MonitorDetailPage";
|
||||
import { DomainsPage } from "@/pages/DomainsPage";
|
||||
import { DomainDetailPage } from "@/pages/DomainDetailPage";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Item } from "@/types";
|
||||
@@ -24,6 +26,7 @@ export default function App() {
|
||||
const NAV = [
|
||||
{ href: "/", label: "Board", icon: LayoutGrid },
|
||||
{ href: "/monitors", label: "Monitors", icon: Activity },
|
||||
{ href: "/domains", label: "Domains", icon: Globe },
|
||||
];
|
||||
|
||||
function Shell() {
|
||||
@@ -132,6 +135,12 @@ function Shell() {
|
||||
<Route path="/monitors/:id">
|
||||
{(p) => <MonitorDetailPage id={p.id} />}
|
||||
</Route>
|
||||
<Route path="/domains">
|
||||
<DomainsPage />
|
||||
</Route>
|
||||
<Route path="/domains/:id">
|
||||
{(p) => <DomainDetailPage id={p.id} />}
|
||||
</Route>
|
||||
|
||||
<ServiceDialog open={svcOpen} onOpenChange={setSvcOpen} item={editing?.kind === "service" ? editing : undefined} />
|
||||
<WidgetDialog open={wdgOpen} onOpenChange={setWdgOpen} item={editing?.kind === "widget" ? editing : undefined} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { Activity, ArrowRight, Download, LayoutGrid, Moon, Plus, Settings, Sun } from "lucide-react";
|
||||
import { Activity, ArrowRight, Download, Globe, LayoutGrid, Moon, Plus, Settings, Sun } from "lucide-react";
|
||||
import { useBoard } from "@/board/store";
|
||||
import { hostOf, IconImg } from "@/board/primitives";
|
||||
import { rendererLabels, rendererList } from "@/renderers";
|
||||
@@ -44,6 +44,7 @@ export function CommandPalette({
|
||||
const cmds: Command[] = [
|
||||
{ id: "nav-board", group: "Actions", glyph: LayoutGrid, label: "Go to Board", run: () => navigate("/") },
|
||||
{ id: "nav-monitors", group: "Actions", glyph: Activity, label: "Go to Monitors", run: () => navigate("/monitors") },
|
||||
{ id: "nav-domains", group: "Actions", glyph: Globe, label: "Go to Domains", run: () => navigate("/domains") },
|
||||
{ id: "add-service", group: "Actions", glyph: Plus, label: "Add service", hint: "new", run: onAddService },
|
||||
{ id: "add-widget", group: "Actions", glyph: Plus, label: "Add widget", hint: "new", run: onAddWidget },
|
||||
{ id: "settings", group: "Actions", glyph: Settings, label: "Open settings", run: onSettings },
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "@/api";
|
||||
import type { Domain, DomainInput } from "@/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
// Add/edit a tracked domain. The first WHOIS/DNS/TLS lookup runs on create —
|
||||
// expect a few seconds of spinner.
|
||||
export function DomainDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
domain,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (o: boolean) => void;
|
||||
domain?: Domain | undefined;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const editing = Boolean(domain);
|
||||
const [name, setName] = useState("");
|
||||
const [intervalH, setIntervalH] = useState("24");
|
||||
const [alertDays, setAlertDays] = useState("30");
|
||||
const [autoRenew, setAutoRenew] = useState(false);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setErr("");
|
||||
setName(domain?.name ?? "");
|
||||
setIntervalH(String(domain?.intervalH ?? 24));
|
||||
setAlertDays(String(domain?.alertDaysBefore ?? 30));
|
||||
setAutoRenew(domain?.autoRenew ?? false);
|
||||
setNotes(domain?.notes ?? "");
|
||||
}, [open, domain]);
|
||||
|
||||
async function submit() {
|
||||
if (!name.trim()) return setErr("Domain is required"), undefined;
|
||||
setBusy(true);
|
||||
const body: DomainInput = {
|
||||
name: name.trim(),
|
||||
intervalH: Math.max(1, parseInt(intervalH) || 24),
|
||||
alertDaysBefore: Math.max(0, parseInt(alertDays) || 30),
|
||||
autoRenew,
|
||||
notes,
|
||||
};
|
||||
try {
|
||||
if (editing && domain) {
|
||||
await api.PATCH("/api/domains/{id}", { params: { path: { id: domain.id! } }, body });
|
||||
} else {
|
||||
const { error } = await api.POST("/api/domains", { body });
|
||||
if (error) throw new Error("create failed");
|
||||
}
|
||||
onSaved();
|
||||
onOpenChange(false);
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : "save failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing ? "Edit domain" : "Track a domain"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
WHOIS, DNS, TLS certificate, and host info — refreshed on your interval.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="dom-name">Domain</Label>
|
||||
<Input id="dom-name" value={name} onChange={(e) => setName(e.target.value)}
|
||||
placeholder="example.com" className="font-mono text-[12px]" autoFocus />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="dom-int">Refresh every (hours)</Label>
|
||||
<Input id="dom-int" value={intervalH} onChange={(e) => setIntervalH(e.target.value)} className="font-mono text-[12px]" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="dom-alert">Alert days before expiry</Label>
|
||||
<Input id="dom-alert" value={alertDays} onChange={(e) => setAlertDays(e.target.value)} className="font-mono text-[12px]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-[12.5px] text-text-dim">
|
||||
<input type="checkbox" checked={autoRenew} onChange={(e) => setAutoRenew(e.target.checked)} />
|
||||
Auto-renew enabled at the registrar
|
||||
</label>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="dom-notes">Notes (optional)</Label>
|
||||
<Input id="dom-notes" value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</div>
|
||||
|
||||
{err ? <p className="text-[12px] text-destructive">{err}</p> : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="items-center">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={() => void submit()} disabled={busy}>
|
||||
{busy ? "Looking up…" : editing ? "Save" : "Track domain"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -54,6 +54,8 @@ export function MonitorDialog({
|
||||
|
||||
const set = (k: string) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setF((s) => ({ ...s, [k]: e.target.value }));
|
||||
// SAFETY: f.type only ever receives values from the TYPES select or a
|
||||
// persisted monitor row, both constrained to MonType.
|
||||
const type = (f.type || "http") as MonType;
|
||||
|
||||
async function submit() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useBoard } from "@/board/store";
|
||||
import { api } from "@/api";
|
||||
import type { Item } from "@/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -32,6 +33,7 @@ export function WidgetDialog({
|
||||
const [sectionId, setSectionId] = useState("");
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [monitors, setMonitors] = useState<{ id?: string; name?: string }[]>([]);
|
||||
const [domains, setDomains] = useState<{ id?: string; name?: string }[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
@@ -43,10 +45,8 @@ export function WidgetDialog({
|
||||
.then((r) => r.json() as Promise<WidgetType[]>)
|
||||
.then(setTypes)
|
||||
.catch(() => setTypes([]));
|
||||
fetch("/api/monitors")
|
||||
.then((r) => r.json() as Promise<{ id?: string; name?: string }[]>)
|
||||
.then(setMonitors)
|
||||
.catch(() => setMonitors([]));
|
||||
void api.GET("/api/monitors").then(({ data }) => setMonitors(data ?? []));
|
||||
void api.GET("/api/domains").then(({ data }) => setDomains(data ?? []));
|
||||
|
||||
const cfg = item?.config ?? null;
|
||||
setType(cfg && typeof cfg.type === "string" ? cfg.type : "");
|
||||
@@ -146,14 +146,14 @@ export function WidgetDialog({
|
||||
<Label htmlFor={`wdg-${f.key}`}>
|
||||
{f.label}{f.required ? "" : " (optional)"}
|
||||
</Label>
|
||||
{f.key === "monitorId" ? (
|
||||
{f.key === "monitorId" || f.key === "domainId" ? (
|
||||
<Select
|
||||
value={values[f.key] ?? ""}
|
||||
onValueChange={(v) => setValues((s) => ({ ...s, [f.key]: v }))}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder="Pick a monitor…" /></SelectTrigger>
|
||||
<SelectTrigger><SelectValue placeholder="Pick one…" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{monitors.map((m) => (
|
||||
{(f.key === "monitorId" ? monitors : domains).map((m) => (
|
||||
<SelectItem key={m.id} value={m.id ?? ""}>{m.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link } from "wouter";
|
||||
import { ArrowLeft, Pause, Play, RefreshCw, Shield, Trash2 } from "lucide-react";
|
||||
import { api } from "@/api";
|
||||
import type { DomainCheck, DomainView } from "@/types";
|
||||
import { DomainDialog } from "@/components/DomainDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Row({ k, v, mono }: { k: string; v: React.ReactNode; mono?: boolean }) {
|
||||
if (v === undefined || v === null || v === "") return null;
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-4 border-b border-border/50 px-4 py-2 last:border-0">
|
||||
<span className="shrink-0 font-mono text-[10.5px] uppercase tracking-[0.08em] text-text-faint">{k}</span>
|
||||
<span className={cn("truncate text-right text-[12px] text-text", mono && "font-mono text-[11px]")}>{v}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[10px] border border-border bg-surface">
|
||||
<h2 className="border-b border-border px-4 py-2.5 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{title}</h2>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtDate(iso: string | null | undefined) {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
export function DomainDetailPage({ id }: { id: string }) {
|
||||
const [d, setD] = useState<DomainView | null>(null);
|
||||
const [checks, setChecks] = useState<DomainCheck[]>([]);
|
||||
const [dlgOpen, setDlgOpen] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [{ data: dv }, { data: ck }] = await Promise.all([
|
||||
api.GET("/api/domains/{id}", { params: { path: { id } } }),
|
||||
api.GET("/api/domains/{id}/checks", { params: { path: { id } } }),
|
||||
]);
|
||||
if (dv) setD(dv);
|
||||
if (ck) setChecks(ck);
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
if (!d) return <main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">Loading…</main>;
|
||||
|
||||
const expiring = d.expiring || (d.daysUntilExpiry ?? Infinity) < 0;
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-5xl px-7 py-8">
|
||||
<Link href="/domains" className="mb-4 inline-flex items-center gap-1.5 text-[12px] text-text-faint hover:text-text">
|
||||
<ArrowLeft size={12} /> Domains
|
||||
</Link>
|
||||
|
||||
<div className="mb-6 flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{d.faviconUrl ? <img src={d.faviconUrl} alt="" className="size-8 rounded-[7px]" /> : null}
|
||||
<div>
|
||||
<h1 className="text-[17px] font-semibold tracking-tight">{d.name}</h1>
|
||||
<p className="mt-0.5 flex items-center gap-2 font-mono text-[11px] text-text-faint">
|
||||
{d.tld ? `.${d.tld}` : ""}
|
||||
{d.dnssec === "signed" && <span className="flex items-center gap-1 text-up"><Shield size={10} /> dnssec</span>}
|
||||
{d.transferLock ? <span>transfer-locked</span> : null}
|
||||
{d.privacyEnabled ? <span>whois privacy</span> : null}
|
||||
{d.autoRenew ? <span>auto-renew</span> : null}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" disabled={refreshing}
|
||||
onClick={() => {
|
||||
setRefreshing(true);
|
||||
void api.POST("/api/domains/{id}/refresh", { params: { path: { id } } })
|
||||
.then(load).finally(() => setRefreshing(false));
|
||||
}}>
|
||||
<RefreshCw size={12} className={cn(refreshing && "animate-spin")} /> Refresh
|
||||
</Button>
|
||||
<Button variant="outline" size="sm"
|
||||
onClick={() => void api.PATCH("/api/domains/{id}", { params: { path: { id } }, body: { active: !d.active } }).then(load)}>
|
||||
{d.active ? <Pause size={12} /> : <Play size={12} />} {d.active ? "Pause" : "Resume"}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setDlgOpen(true)}>Edit</Button>
|
||||
<Button variant="outline" size="sm" aria-label="Delete"
|
||||
onClick={() => {
|
||||
if (confirm(`Stop tracking "${d.name}"?`)) {
|
||||
void api.DELETE("/api/domains/{id}", { params: { path: { id } } }).then(() => { location.href = "/domains"; });
|
||||
}
|
||||
}}>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{d.lookupError ? (
|
||||
<p className="mb-4 rounded-[8px] border border-down/40 bg-down/10 px-3 py-2 font-mono text-[11px] text-down">
|
||||
lookup: {d.lookupError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mb-6 grid grid-cols-4 gap-3 max-[720px]:grid-cols-2">
|
||||
<div className="rounded-[10px] border border-border bg-surface px-4 py-3">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">Domain expires</div>
|
||||
<div className={cn("mt-1 font-mono text-[17px] font-medium", expiring && "text-down")}>
|
||||
{d.daysUntilExpiry !== null && d.daysUntilExpiry !== undefined
|
||||
? (d.daysUntilExpiry < 0 ? "expired" : `${d.daysUntilExpiry}d`)
|
||||
: "—"}
|
||||
</div>
|
||||
<div className="font-mono text-[10px] text-text-faint">{fmtDate(d.expiryDate)}</div>
|
||||
</div>
|
||||
<div className="rounded-[10px] border border-border bg-surface px-4 py-3">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">SSL expires</div>
|
||||
<div className={cn("mt-1 font-mono text-[17px] font-medium", d.sslExpiring && "text-down")}>
|
||||
{d.sslDaysUntilExpiry !== null && d.sslDaysUntilExpiry !== undefined
|
||||
? (d.sslDaysUntilExpiry < 0 ? "expired" : `${d.sslDaysUntilExpiry}d`)
|
||||
: "—"}
|
||||
</div>
|
||||
<div className="font-mono text-[10px] text-text-faint">{fmtDate(d.sslValidTo)}</div>
|
||||
</div>
|
||||
<div className="rounded-[10px] border border-border bg-surface px-4 py-3">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">Registrar</div>
|
||||
<div className="mt-1 truncate text-[13px] font-medium">{d.registrarName || "—"}</div>
|
||||
<div className="font-mono text-[10px] text-text-faint">{d.caProvider || "—"}</div>
|
||||
</div>
|
||||
<div className="rounded-[10px] border border-border bg-surface px-4 py-3">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">Providers</div>
|
||||
<div className="mt-1 truncate text-[13px] font-medium">{d.dnsProvider || "—"}</div>
|
||||
<div className="font-mono text-[10px] text-text-faint">{d.hostingProvider || d.emailProvider || "—"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 max-[820px]:grid-cols-1">
|
||||
<Section title="Registration">
|
||||
<Row k="Created" v={fmtDate(d.creationDate)} />
|
||||
<Row k="Updated" v={fmtDate(d.updatedDate)} />
|
||||
<Row k="Registrar" v={d.registrarName} />
|
||||
<Row k="IANA ID" v={d.registrarId} mono />
|
||||
<Row k="Registry ID" v={d.registryDomainId} mono />
|
||||
<Row k="Registrant" v={[d.registrantName, d.registrantOrg].filter(Boolean).join(" / ")} />
|
||||
<Row k="Country" v={d.registrantCountry} />
|
||||
<Row k="Abuse" v={d.abuseEmail} mono />
|
||||
{d.statuses && d.statuses.length > 0 ? <Row k="Status" v={d.statuses.join(", ")} mono /> : null}
|
||||
</Section>
|
||||
|
||||
<Section title="DNS">
|
||||
{d.nameServers?.map((n) => <Row key={n} k="NS" v={n} mono />)}
|
||||
{d.mxRecords?.map((m) => <Row key={m} k="MX" v={m} mono />)}
|
||||
{d.ipv4?.map((a) => <Row key={a} k="A" v={a} mono />)}
|
||||
{d.ipv6?.map((a) => <Row key={a} k="AAAA" v={a} mono />)}
|
||||
{d.cname ? <Row k="CNAME" v={d.cname} mono /> : null}
|
||||
{d.txtRecords?.slice(0, 4).map((t, i) => <Row key={i} k="TXT" v={t.length > 60 ? t.slice(0, 60) + "…" : t} mono />)}
|
||||
{d.dnsProvider ? <Row k="DNS provider" v={d.dnsProvider} /> : null}
|
||||
{d.emailProvider ? <Row k="Email" v={d.emailProvider} /> : null}
|
||||
</Section>
|
||||
|
||||
<Section title="TLS certificate">
|
||||
<Row k="Issuer" v={d.sslIssuer} />
|
||||
<Row k="CA" v={d.caProvider} />
|
||||
<Row k="Valid" v={`${fmtDate(d.sslValidFrom)} → ${fmtDate(d.sslValidTo)}`} mono />
|
||||
<Row k="Subject" v={d.sslSubject} mono />
|
||||
<Row k="Key" v={d.sslKeySize ? `${d.sslKeySize} bit` : ""} mono />
|
||||
<Row k="Algorithm" v={d.sslSigAlgo} mono />
|
||||
{d.sslAltNames && d.sslAltNames.length > 0 ? (
|
||||
<Row k="SANs" v={d.sslAltNames.slice(0, 5).join(", ") + (d.sslAltNames.length > 5 ? ` +${d.sslAltNames.length - 5}` : "")} mono />
|
||||
) : null}
|
||||
<Row k="SHA-256" v={d.sslFingerprint ? d.sslFingerprint.slice(0, 29) + "…" : ""} mono />
|
||||
</Section>
|
||||
|
||||
<Section title="Host">
|
||||
<Row k="Location" v={[d.hostCity, d.hostRegion, d.hostCountry].filter(Boolean).join(", ")} />
|
||||
<Row k="ISP" v={d.hostIsp} />
|
||||
<Row k="Org" v={d.hostOrg} />
|
||||
<Row k="AS" v={d.hostAs} mono />
|
||||
<Row k="Hosting" v={d.hostingProvider} />
|
||||
<Row k="Checked" v={d.lastChecked ? new Date(d.lastChecked).toLocaleString() : "—"} mono />
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
{Object.keys(d.headers ?? {}).length > 0 ? (
|
||||
<details className="mt-3 overflow-hidden rounded-[10px] border border-border bg-surface">
|
||||
<summary className="cursor-pointer px-4 py-2.5 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">
|
||||
Response headers ({Object.keys(d.headers ?? {}).length})
|
||||
</summary>
|
||||
<div className="border-t border-border px-4 py-2">
|
||||
{Object.entries(d.headers ?? {}).map(([k, v]) => (
|
||||
<div key={k} className="flex gap-3 py-0.5 font-mono text-[11px]">
|
||||
<span className="w-44 shrink-0 text-text-faint">{k}</span>
|
||||
<span className="truncate text-text-dim">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
|
||||
<h2 className="mb-2 mt-6 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Check history</h2>
|
||||
<div className="overflow-hidden rounded-[10px] border border-border">
|
||||
<table className="w-full text-[12px]">
|
||||
<tbody>
|
||||
{checks.map((c) => (
|
||||
<tr key={c.id} className="border-b border-border/60 last:border-0">
|
||||
<td className="px-4 py-2 font-mono text-[10.5px] text-text-faint">{new Date(c.checkedAt ?? "").toLocaleString()}</td>
|
||||
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">exp {fmtDate(c.expiryDate)}</td>
|
||||
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">ssl {fmtDate(c.sslValidTo)}</td>
|
||||
<td className="px-4 py-2 font-mono text-[11px] text-text-faint">{(c.ipv4 ?? []).join(", ")}</td>
|
||||
</tr>
|
||||
))}
|
||||
{checks.length === 0 && (
|
||||
<tr><td className="px-4 py-6 text-center text-text-faint">No snapshots yet.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<DomainDialog open={dlgOpen} onOpenChange={setDlgOpen} domain={d} onSaved={load} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link } from "wouter";
|
||||
import { Pause, Play, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { api } from "@/api";
|
||||
import type { DomainView } from "@/types";
|
||||
import { DomainDialog } from "@/components/DomainDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function daysLabel(days: number | null | undefined, warn?: boolean) {
|
||||
if (days === null || days === undefined) return "—";
|
||||
const cls = warn || days < 0 ? "text-down" : days <= 30 ? "text-amber-500" : "text-text-dim";
|
||||
return <span className={cn("font-mono text-[11px]", cls)}>{days < 0 ? "expired" : `${days}d`}</span>;
|
||||
}
|
||||
|
||||
export function DomainsPage() {
|
||||
const [domains, setDomains] = useState<DomainView[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [dlgOpen, setDlgOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<DomainView | undefined>();
|
||||
const [refreshing, setRefreshing] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const { data } = await api.GET("/api/domains");
|
||||
if (data) setDomains(data);
|
||||
setLoaded(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
async function act(id: string | undefined, fn: () => Promise<unknown>) {
|
||||
if (!id) return;
|
||||
await fn();
|
||||
await load();
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-5xl px-7 py-8">
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-[15px] font-semibold tracking-tight">Domains</h1>
|
||||
<p className="text-[12px] text-text-faint">WHOIS, DNS, and certificate watch.</p>
|
||||
</div>
|
||||
<Button onClick={() => { setEditing(undefined); setDlgOpen(true); }}>
|
||||
<Plus size={13} strokeWidth={2.2} /> Track domain
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loaded && domains.length === 0 ? (
|
||||
<div className="rounded-[10px] border border-dashed border-border px-6 py-14 text-center">
|
||||
<p className="text-[13px] text-text-dim">No domains tracked yet.</p>
|
||||
<p className="mt-1 text-[11.5px] text-text-faint">
|
||||
Expiry, registrar, DNS records, SSL certificates, and provider detection.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-[10px] border border-border">
|
||||
<table className="w-full text-[13px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface text-left font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">
|
||||
<th className="px-4 py-2.5 font-medium">Domain</th>
|
||||
<th className="px-4 py-2.5 font-medium">Registrar</th>
|
||||
<th className="px-4 py-2.5 font-medium">Expires</th>
|
||||
<th className="px-4 py-2.5 font-medium">SSL</th>
|
||||
<th className="px-4 py-2.5 font-medium">Providers</th>
|
||||
<th className="px-4 py-2.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{domains.map((d) => (
|
||||
<tr key={d.id} className={cn("border-b border-border/60 last:border-0 hover:bg-surface-hover/60", !d.active && "opacity-50")}>
|
||||
<td className="px-4 py-3">
|
||||
<span className="flex items-center gap-2.5 font-medium">
|
||||
{d.faviconUrl ? (
|
||||
<img src={d.faviconUrl} alt="" className="size-4 rounded-sm" loading="lazy" />
|
||||
) : null}
|
||||
<Link href={`/domains/${d.id}`} className="hover:underline">{d.name}</Link>
|
||||
{d.lookupError ? (
|
||||
<span className="font-mono text-[10px] text-down" title={d.lookupError}>lookup error</span>
|
||||
) : null}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11.5px] text-text-dim">{d.registrarName || "—"}</td>
|
||||
<td className="px-4 py-3">{daysLabel(d.daysUntilExpiry, d.expiring)}</td>
|
||||
<td className="px-4 py-3">{daysLabel(d.sslDaysUntilExpiry, d.sslExpiring)}</td>
|
||||
<td className="px-4 py-3 font-mono text-[10.5px] text-text-faint">
|
||||
{[d.dnsProvider, d.hostingProvider].filter(Boolean).join(" · ") || "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="outline" size="icon" aria-label="Refresh now" disabled={refreshing === d.id}
|
||||
onClick={() => {
|
||||
if (!d.id) return;
|
||||
setRefreshing(d.id);
|
||||
void act(d.id, () => api.POST("/api/domains/{id}/refresh", { params: { path: { id: d.id } } }))
|
||||
.finally(() => setRefreshing(null));
|
||||
}}>
|
||||
<RefreshCw size={12} className={cn(refreshing === d.id && "animate-spin")} />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" aria-label={d.active ? "Pause" : "Resume"}
|
||||
onClick={() => void act(d.id, () => api.PATCH("/api/domains/{id}", { params: { path: { id: d.id! } }, body: { active: !d.active } }))}>
|
||||
{d.active ? <Pause size={12} /> : <Play size={12} />}
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" aria-label="Delete"
|
||||
onClick={() => {
|
||||
if (confirm(`Stop tracking "${d.name}"?`)) {
|
||||
void act(d.id, () => api.DELETE("/api/domains/{id}", { params: { path: { id: d.id! } } }));
|
||||
}
|
||||
}}>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => { setEditing(d); setDlgOpen(true); }}>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DomainDialog open={dlgOpen} onOpenChange={setDlgOpen} domain={editing} onSaved={load} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,10 @@ export type Monitor = components["schemas"]["Monitor"];
|
||||
export type MonitorInput = components["schemas"]["MonitorInput"];
|
||||
export type MonitorView = components["schemas"]["MonitorView"];
|
||||
export type Heartbeat = components["schemas"]["Heartbeat"];
|
||||
export type Domain = components["schemas"]["Domain"];
|
||||
export type DomainInput = components["schemas"]["DomainInput"];
|
||||
export type DomainView = components["schemas"]["DomainView"];
|
||||
export type DomainCheck = components["schemas"]["DomainCheck"];
|
||||
|
||||
export type Theme = "dark" | "light";
|
||||
export type RendererName = "bento" | "cards" | "index" | "console";
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { WidgetData } from "@/widgets";
|
||||
import type { Item } from "@/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Board tile for a tracked domain: favicon, name, expiry countdown.
|
||||
export function DomainWidget({ item, data, error }: {
|
||||
item: Item;
|
||||
data: WidgetData | undefined;
|
||||
error: string | undefined;
|
||||
}) {
|
||||
const days = typeof data?.daysUntilExpiry === "number" ? data.daysUntilExpiry : undefined;
|
||||
const sslDays = typeof data?.sslDaysUntilExpiry === "number" ? data.sslDaysUntilExpiry : undefined;
|
||||
const expiring = data?.expiring === true;
|
||||
const domainId = typeof item.config?.domainId === "string" ? item.config.domainId : "";
|
||||
|
||||
return (
|
||||
<a
|
||||
href={domainId ? `/domains/${domainId}` : "/domains"}
|
||||
className="flex w-full items-center gap-2.5 text-left"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{typeof data?.faviconUrl === "string" && data.faviconUrl ? (
|
||||
<img src={data.faviconUrl} alt="" className="size-4 shrink-0 rounded-sm" loading="lazy" />
|
||||
) : null}
|
||||
<span className="min-w-0 flex-1 truncate text-[12.5px] font-medium">
|
||||
{typeof data?.name === "string" ? data.name : item.name}
|
||||
</span>
|
||||
<span className={cn(
|
||||
"shrink-0 font-mono text-[10.5px]",
|
||||
error ? "text-down" : expiring || (days !== undefined && days < 0) ? "text-down" : "text-text-faint",
|
||||
)}>
|
||||
{error ? "err" : days === undefined ? "…" : days < 0 ? "expired" : `${days}d`}
|
||||
{sslDays !== undefined && sslDays <= 30 ? ` · ssl ${sslDays}d` : ""}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { ClockWidget } from "@/widgets/clock";
|
||||
import { PiholeWidget, AdguardWidget } from "@/widgets/blocker";
|
||||
import { ImmichWidget } from "@/widgets/immich";
|
||||
import { MonitorWidget } from "@/widgets/monitor";
|
||||
import { DomainWidget } from "@/widgets/domain";
|
||||
|
||||
// WidgetHost seam: renderers own the tile chrome; this maps config.type to
|
||||
// the component that fills it. Adding a widget = one fetcher in Go + one
|
||||
@@ -30,6 +31,7 @@ const registry: WidgetRegistry = {
|
||||
adguard: { view: AdguardWidget },
|
||||
immich: { view: ImmichWidget },
|
||||
monitor: { view: MonitorWidget },
|
||||
domain: { view: DomainWidget },
|
||||
};
|
||||
|
||||
export function cfgStr(item: Item, key: string): string {
|
||||
|
||||
Reference in new issue
Block a user