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:
Tomas DvorakandDevin committed 2026-09-21 18:35:47 +02:00
1 parent 1ff1b29ae7
commit f9fd1d9cf2
18 files changed
+2376 -14

No files matched your search

@@ -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>