check / check (push) Successful in 1m15s
An apex domain's own records are still saved with the hostnames' records, under the domain's name, so the port and TLS checks find its addresses. Notifications about them now start `Domain:`, decided by the configured domains. The dashboard and /api/v1/status, which read only the saved state, take a hostname entry whose name also has a domain entry as that domain's own records: the dashboard shows them in a second table under Domains, the API in the domain's `recordsByNameserver`, and neither lists or counts them as hostnames. The startup notification counts domains and hostnames from the configuration. README says which of a domain's own records are watched and how their changes are notified. Model: opus-5-5
403 lines
15 KiB
HTML
403 lines
15 KiB
HTML
<!doctype html>
|
|
<html lang="en" class="bg-slate-950">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta http-equiv="refresh" content="30" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>dnswatcher</title>
|
|
<link rel="stylesheet" href="/s/css/tailwind.min.css" />
|
|
</head>
|
|
<body
|
|
class="bg-surface-950 text-slate-300 font-mono text-sm min-h-screen antialiased"
|
|
>
|
|
<div class="max-w-6xl mx-auto px-4 py-8">
|
|
{{/* ---- Header ---- */}}
|
|
<div class="mb-8">
|
|
<h1 class="text-2xl font-bold text-teal-400 tracking-tight">
|
|
dnswatcher
|
|
</h1>
|
|
<p class="text-xs text-slate-500 mt-1">
|
|
state updated {{ .StateAge }} · page generated
|
|
{{ .GeneratedAt }} UTC · auto-refresh 30s
|
|
</p>
|
|
</div>
|
|
|
|
{{/* ---- Summary bar ---- */}}
|
|
<div
|
|
class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8"
|
|
>
|
|
<div class="bg-surface-800 border border-slate-700/50 rounded-lg p-4">
|
|
<div class="text-xs text-slate-500 uppercase tracking-wider">
|
|
Domains
|
|
</div>
|
|
<div class="text-2xl font-bold text-teal-400 mt-1">
|
|
{{ len .Snapshot.Domains }}
|
|
</div>
|
|
</div>
|
|
<div class="bg-surface-800 border border-slate-700/50 rounded-lg p-4">
|
|
<div class="text-xs text-slate-500 uppercase tracking-wider">
|
|
Hostnames
|
|
</div>
|
|
<div class="text-2xl font-bold text-teal-400 mt-1">
|
|
{{ len .Hostnames }}
|
|
</div>
|
|
</div>
|
|
<div class="bg-surface-800 border border-slate-700/50 rounded-lg p-4">
|
|
<div class="text-xs text-slate-500 uppercase tracking-wider">
|
|
Ports
|
|
</div>
|
|
<div class="text-2xl font-bold text-teal-400 mt-1">
|
|
{{ len .Snapshot.Ports }}
|
|
</div>
|
|
</div>
|
|
<div class="bg-surface-800 border border-slate-700/50 rounded-lg p-4">
|
|
<div class="text-xs text-slate-500 uppercase tracking-wider">
|
|
Certificates
|
|
</div>
|
|
<div class="text-2xl font-bold text-teal-400 mt-1">
|
|
{{ len .Snapshot.Certificates }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{/* ---- Domains ---- */}}
|
|
<section class="mb-8">
|
|
<h2
|
|
class="text-sm font-semibold text-teal-300 uppercase tracking-wider mb-3 border-b border-slate-700/50 pb-2"
|
|
>
|
|
Domains
|
|
</h2>
|
|
{{ if .Snapshot.Domains }}
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full text-left text-xs">
|
|
<thead>
|
|
<tr class="text-slate-500 uppercase tracking-wider">
|
|
<th class="py-2 px-3">Domain</th>
|
|
<th class="py-2 px-3">Nameservers</th>
|
|
<th class="py-2 px-3">Checked</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-slate-800">
|
|
{{ range $name, $ds := .Snapshot.Domains }}
|
|
<tr class="hover:bg-surface-800/50">
|
|
<td class="py-2 px-3 text-slate-200 font-medium">
|
|
{{ $name }}
|
|
</td>
|
|
<td class="py-2 px-3 text-slate-400 break-all">
|
|
{{ joinStrings $ds.Nameservers ", " }}
|
|
</td>
|
|
<td class="py-2 px-3 text-slate-500 whitespace-nowrap">
|
|
{{ relTime $ds.LastChecked }}
|
|
</td>
|
|
</tr>
|
|
{{ end }}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{{ if .DomainRecords }}
|
|
<div class="overflow-x-auto mt-4">
|
|
<table class="w-full text-left text-xs">
|
|
<thead>
|
|
<tr class="text-slate-500 uppercase tracking-wider">
|
|
<th class="py-2 px-3">Domain</th>
|
|
<th class="py-2 px-3">NS</th>
|
|
<th class="py-2 px-3">Status</th>
|
|
<th class="py-2 px-3">Records</th>
|
|
<th class="py-2 px-3">Checked</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-slate-800">
|
|
{{ template "records" .DomainRecords }}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{{ end }}
|
|
{{ else }}
|
|
<p class="text-slate-600 italic text-xs">
|
|
No domains configured.
|
|
</p>
|
|
{{ end }}
|
|
</section>
|
|
|
|
{{/* ---- Hostnames ---- */}}
|
|
<section class="mb-8">
|
|
<h2
|
|
class="text-sm font-semibold text-teal-300 uppercase tracking-wider mb-3 border-b border-slate-700/50 pb-2"
|
|
>
|
|
Hostnames
|
|
</h2>
|
|
{{ if .Hostnames }}
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full text-left text-xs">
|
|
<thead>
|
|
<tr class="text-slate-500 uppercase tracking-wider">
|
|
<th class="py-2 px-3">Hostname</th>
|
|
<th class="py-2 px-3">NS</th>
|
|
<th class="py-2 px-3">Status</th>
|
|
<th class="py-2 px-3">Records</th>
|
|
<th class="py-2 px-3">Checked</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-slate-800">
|
|
{{ template "records" .Hostnames }}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{{ else }}
|
|
<p class="text-slate-600 italic text-xs">
|
|
No hostnames configured.
|
|
</p>
|
|
{{ end }}
|
|
</section>
|
|
|
|
{{/* ---- Ports ---- */}}
|
|
<section class="mb-8">
|
|
<h2
|
|
class="text-sm font-semibold text-teal-300 uppercase tracking-wider mb-3 border-b border-slate-700/50 pb-2"
|
|
>
|
|
Ports
|
|
</h2>
|
|
{{ if .Snapshot.Ports }}
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full text-left text-xs">
|
|
<thead>
|
|
<tr class="text-slate-500 uppercase tracking-wider">
|
|
<th class="py-2 px-3">Address</th>
|
|
<th class="py-2 px-3">State</th>
|
|
<th class="py-2 px-3">Hostnames</th>
|
|
<th class="py-2 px-3">Checked</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-slate-800">
|
|
{{ range $key, $ps := .Snapshot.Ports }}
|
|
<tr class="hover:bg-surface-800/50">
|
|
<td class="py-2 px-3 text-slate-200 font-medium">
|
|
{{ $key }}
|
|
</td>
|
|
<td class="py-2 px-3">
|
|
{{ if $ps.Open }}
|
|
<span
|
|
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-teal-900/50 text-teal-400 border border-teal-700/30"
|
|
>open</span
|
|
>
|
|
{{ else }}
|
|
<span
|
|
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-red-900/50 text-red-400 border border-red-700/30"
|
|
>closed</span
|
|
>
|
|
{{ end }}
|
|
</td>
|
|
<td class="py-2 px-3 text-slate-400 break-all">
|
|
{{ joinStrings $ps.Hostnames ", " }}
|
|
</td>
|
|
<td class="py-2 px-3 text-slate-500 whitespace-nowrap">
|
|
{{ relTime $ps.LastChecked }}
|
|
</td>
|
|
</tr>
|
|
{{ end }}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{{ else }}
|
|
<p class="text-slate-600 italic text-xs">
|
|
No port data yet.
|
|
</p>
|
|
{{ end }}
|
|
</section>
|
|
|
|
{{/* ---- Certificates ---- */}}
|
|
<section class="mb-8">
|
|
<h2
|
|
class="text-sm font-semibold text-teal-300 uppercase tracking-wider mb-3 border-b border-slate-700/50 pb-2"
|
|
>
|
|
Certificates
|
|
</h2>
|
|
{{ if .Snapshot.Certificates }}
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full text-left text-xs">
|
|
<thead>
|
|
<tr class="text-slate-500 uppercase tracking-wider">
|
|
<th class="py-2 px-3">Endpoint</th>
|
|
<th class="py-2 px-3">Status</th>
|
|
<th class="py-2 px-3">CN</th>
|
|
<th class="py-2 px-3">Issuer</th>
|
|
<th class="py-2 px-3">Expires</th>
|
|
<th class="py-2 px-3">Checked</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-slate-800">
|
|
{{ range $key, $cs := .Snapshot.Certificates }}
|
|
<tr class="hover:bg-surface-800/50">
|
|
<td class="py-2 px-3 text-slate-400 break-all">
|
|
{{ $key }}
|
|
</td>
|
|
<td class="py-2 px-3">
|
|
{{ if eq $cs.Status "ok" }}
|
|
<span
|
|
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-teal-900/50 text-teal-400 border border-teal-700/30"
|
|
>ok</span
|
|
>
|
|
{{ else }}
|
|
<span
|
|
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-red-900/50 text-red-400 border border-red-700/30"
|
|
>{{ $cs.Status }}</span
|
|
>
|
|
{{ end }}
|
|
</td>
|
|
{{ if $cs.Error }}
|
|
<td colspan="3" class="py-2 px-3 text-red-400 break-all">
|
|
<div class="max-w-xs">{{ $cs.Error }}</div>
|
|
</td>
|
|
{{ else }}
|
|
<td class="py-2 px-3 text-slate-200">
|
|
{{ $cs.CommonName }}
|
|
</td>
|
|
<td class="py-2 px-3 text-slate-400 break-all">
|
|
{{ $cs.Issuer }}
|
|
</td>
|
|
<td class="py-2 px-3 whitespace-nowrap">
|
|
{{ if not $cs.NotAfter.IsZero }}
|
|
{{ $days := expiryDays $cs.NotAfter }}
|
|
{{ if lt $days 7 }}
|
|
<span class="text-red-400 font-medium"
|
|
>{{ $cs.NotAfter.Format "2006-01-02" }}
|
|
({{ $days }}d)</span
|
|
>
|
|
{{ else if lt $days 30 }}
|
|
<span class="text-amber-400"
|
|
>{{ $cs.NotAfter.Format "2006-01-02" }}
|
|
({{ $days }}d)</span
|
|
>
|
|
{{ else }}
|
|
<span class="text-slate-400"
|
|
>{{ $cs.NotAfter.Format "2006-01-02" }}
|
|
({{ $days }}d)</span
|
|
>
|
|
{{ end }}
|
|
{{ end }}
|
|
</td>
|
|
{{ end }}
|
|
<td class="py-2 px-3 text-slate-500 whitespace-nowrap">
|
|
{{ relTime $cs.LastChecked }}
|
|
</td>
|
|
</tr>
|
|
{{ end }}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{{ else }}
|
|
<p class="text-slate-600 italic text-xs">
|
|
No certificate data yet.
|
|
</p>
|
|
{{ end }}
|
|
</section>
|
|
|
|
{{/* ---- Recent Alerts ---- */}}
|
|
<section class="mb-8">
|
|
<h2
|
|
class="text-sm font-semibold text-teal-300 uppercase tracking-wider mb-3 border-b border-slate-700/50 pb-2"
|
|
>
|
|
Recent Alerts ({{ len .Alerts }})
|
|
</h2>
|
|
{{ if .Alerts }}
|
|
<div class="space-y-2">
|
|
{{ range .Alerts }}
|
|
<div
|
|
class="bg-surface-800 border rounded-lg px-4 py-3 {{ if eq .Priority "error" }}border-red-700/40{{ else if eq .Priority "warning" }}border-amber-700/40{{ else if eq .Priority "success" }}border-teal-700/40{{ else }}border-blue-700/40{{ end }}"
|
|
>
|
|
<div class="flex items-center gap-3 mb-1">
|
|
{{ if eq .Priority "error" }}
|
|
<span
|
|
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-red-900/50 text-red-400 border border-red-700/30"
|
|
>error</span
|
|
>
|
|
{{ else if eq .Priority "warning" }}
|
|
<span
|
|
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-amber-900/50 text-amber-400 border border-amber-700/30"
|
|
>warning</span
|
|
>
|
|
{{ else if eq .Priority "success" }}
|
|
<span
|
|
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-teal-900/50 text-teal-400 border border-teal-700/30"
|
|
>success</span
|
|
>
|
|
{{ else }}
|
|
<span
|
|
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-blue-900/50 text-blue-400 border border-blue-700/30"
|
|
>info</span
|
|
>
|
|
{{ end }}
|
|
<span class="text-slate-200 text-xs font-medium">
|
|
{{ .Title }}
|
|
</span>
|
|
<span class="text-slate-600 text-[11px] ml-auto whitespace-nowrap">
|
|
{{ .Timestamp.Format "2006-01-02 15:04:05" }} UTC
|
|
({{ relTime .Timestamp }})
|
|
</span>
|
|
</div>
|
|
<p
|
|
class="text-slate-400 text-xs whitespace-pre-line pl-0.5"
|
|
>
|
|
{{ .Message }}
|
|
</p>
|
|
</div>
|
|
{{ end }}
|
|
</div>
|
|
{{ else }}
|
|
<p class="text-slate-600 italic text-xs">
|
|
No alerts recorded since last restart.
|
|
</p>
|
|
{{ end }}
|
|
</section>
|
|
|
|
{{/* ---- Footer ---- */}}
|
|
<div
|
|
class="text-[11px] text-slate-700 border-t border-slate-800 pt-4 mt-8"
|
|
>
|
|
dnswatcher · monitoring {{ len .Snapshot.Domains }} domains +
|
|
{{ len .Hostnames }} hostnames
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
{{/* ---- One row per nameserver of each name in the map it is given ---- */}}
|
|
{{ define "records" }}
|
|
{{ range $name, $hs := . }}
|
|
{{ range $ns, $nsr := $hs.RecordsByNameserver }}
|
|
<tr class="hover:bg-surface-800/50">
|
|
<td class="py-2 px-3 text-slate-200 font-medium">
|
|
{{ $name }}
|
|
</td>
|
|
<td class="py-2 px-3 text-slate-400 break-all">
|
|
{{ $ns }}
|
|
</td>
|
|
<td class="py-2 px-3">
|
|
{{ if eq $nsr.Status "ok" }}
|
|
<span
|
|
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-teal-900/50 text-teal-400 border border-teal-700/30"
|
|
>ok</span
|
|
>
|
|
{{ else }}
|
|
<span
|
|
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-red-900/50 text-red-400 border border-red-700/30"
|
|
>{{ $nsr.Status }}</span
|
|
>
|
|
{{ end }}
|
|
</td>
|
|
<td
|
|
class="py-2 px-3 text-slate-400 break-all max-w-xs"
|
|
>
|
|
{{ if $nsr.Error }}
|
|
<span class="text-red-400">{{ $nsr.Error }}</span>
|
|
{{ else }}
|
|
{{ formatRecords $nsr.Records }}
|
|
{{ end }}
|
|
</td>
|
|
<td class="py-2 px-3 text-slate-500 whitespace-nowrap">
|
|
{{ relTime $nsr.LastChecked }}
|
|
</td>
|
|
</tr>
|
|
{{ end }}
|
|
{{ end }}
|
|
{{ end }}
|