feat(panel): show each domain's DNS status in the domain list
The list gave no hint which domains still needed records published — the verdict lived only on the domain page, one click away per domain. Each row now carries a badge with the worst of that domain's DKIM, SPF and DMARC checks, in the panel's shared ok/warn/error/unknown vocabulary, linking to that domain's DNS status card. The checks run concurrently across the listed domains: each carries its own timeout, so in series a dead resolver would multiply that wait by the number of domains and the list would look hung. They share the checker's cache with the domain page, so a repeat view costs no lookups and opening a domain after the list is free. A domain whose DKIM key cannot be read stays "unknown" rather than being reported as misconfigured — the missing half of the comparison is this server's, not the domain's. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,16 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- A **DNS** badge in the domain list, one per row, carrying the same
|
||||||
|
ok/warn/error/unknown vocabulary as the rest of the panel: the worst of that
|
||||||
|
domain's DKIM, SPF and DMARC checks, so a domain whose records were never
|
||||||
|
published is visible without opening it. The badge links to that domain's
|
||||||
|
DNS status card. The checks run concurrently across the listed domains —
|
||||||
|
each carries its own timeout, and in series a dead resolver would multiply
|
||||||
|
that wait by the number of domains — and share the checker's cache with the
|
||||||
|
domain page, so a repeat view costs no lookups. A domain whose DKIM key
|
||||||
|
cannot be read stays "unknown" rather than being reported as misconfigured,
|
||||||
|
since the missing half is this server's.
|
||||||
- Machine metrics on the status page: a **Machine** card reporting the
|
- Machine metrics on the status page: a **Machine** card reporting the
|
||||||
processor (busy percentage, core count, load average), memory and swap, and
|
processor (busy percentage, core count, load average), memory and swap, and
|
||||||
network throughput and totals per interface, read from the kernel's counters
|
network throughput and totals per interface, read from the kernel's counters
|
||||||
|
|||||||
@@ -5,10 +5,20 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/health"
|
||||||
"github.com/mixeme/selfpost/internal/store"
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// domainRow is one line of the domain list: the stored domain plus the rolled-up
|
||||||
|
// verdict of its published DNS records, so the operator sees which domains still
|
||||||
|
// need a record published without opening each one.
|
||||||
|
type domainRow struct {
|
||||||
|
store.Domain
|
||||||
|
DNS health.Status
|
||||||
|
}
|
||||||
|
|
||||||
// handleDashboard is the authenticated landing page: the list of sending
|
// handleDashboard is the authenticated landing page: the list of sending
|
||||||
// domains with their DKIM/selector and application counts, plus the add-domain
|
// domains with their DKIM/selector and application counts, plus the add-domain
|
||||||
// form (product.md).
|
// form (product.md).
|
||||||
@@ -30,13 +40,43 @@ func (s *Server) renderDashboard(w http.ResponseWriter, r *http.Request, status
|
|||||||
"Title": "SelfPost",
|
"Title": "SelfPost",
|
||||||
"User": currentUser(r),
|
"User": currentUser(r),
|
||||||
"Active": "domains",
|
"Active": "domains",
|
||||||
"Domains": domains,
|
"Domains": s.domainRows(domains),
|
||||||
"Error": formErr,
|
"Error": formErr,
|
||||||
"FormName": formName,
|
"FormName": formName,
|
||||||
"Flash": dashboardFlash(r),
|
"Flash": dashboardFlash(r),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// domainRows attaches each domain's DNS verdict to its row. The checks run
|
||||||
|
// concurrently rather than one after another: each carries its own timeout, so
|
||||||
|
// in series a dead resolver would multiply that wait by the number of domains
|
||||||
|
// and the list would look hung. The checker caches results for a few minutes,
|
||||||
|
// so a repeat view of the list costs no lookups at all, and it is the same
|
||||||
|
// cache the domain page fills — opening a domain after the list is free.
|
||||||
|
func (s *Server) domainRows(domains []store.Domain) []domainRow {
|
||||||
|
rows := make([]domainRow, len(domains))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i, d := range domains {
|
||||||
|
rows[i] = domainRow{Domain: d, DNS: health.StatusUnknown}
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
record, err := s.domains.DKIMRecord(d)
|
||||||
|
if err != nil {
|
||||||
|
// Without the expected key there is nothing to compare DNS
|
||||||
|
// against; leave the row unknown rather than accusing the
|
||||||
|
// domain of a misconfiguration this server caused.
|
||||||
|
logf("panel: dashboard: domain %d: dkim record: %v", d.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dns, _ := s.domainDNS(d, record, false)
|
||||||
|
rows[i].DNS = dns.Overall
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
// dashboardFlash maps a fixed redirect flag to a fixed message, so status text
|
// dashboardFlash maps a fixed redirect flag to a fixed message, so status text
|
||||||
// after a redirect is never attacker-influenced.
|
// after a redirect is never attacker-influenced.
|
||||||
func dashboardFlash(r *http.Request) string {
|
func dashboardFlash(r *http.Request) string {
|
||||||
|
|||||||
@@ -336,6 +336,11 @@ button.danger:hover, a.danger:hover { background: #912018; }
|
|||||||
.st-warn { background: var(--st-warn-bg); color: var(--st-warn-fg); border-color: var(--st-warn-border); }
|
.st-warn { background: var(--st-warn-bg); color: var(--st-warn-fg); border-color: var(--st-warn-border); }
|
||||||
.st-error { background: var(--st-error-bg); color: var(--st-error-fg); border-color: var(--st-error-border); }
|
.st-error { background: var(--st-error-bg); color: var(--st-error-fg); border-color: var(--st-error-border); }
|
||||||
.st-unknown { background: var(--st-unknown-bg); color: var(--st-unknown-fg); border-color: var(--st-unknown-border); }
|
.st-unknown { background: var(--st-unknown-bg); color: var(--st-unknown-fg); border-color: var(--st-unknown-border); }
|
||||||
|
/* In the domain list the badge is also the link to that domain's DNS section,
|
||||||
|
so it must not pick up the link colour and underline that would fight with
|
||||||
|
the badge's own palette. */
|
||||||
|
a.st, a.st:hover { color: inherit; text-decoration: none; }
|
||||||
|
a.st:hover { filter: brightness(1.08); }
|
||||||
/* Usage bars on the status page's machine card. <meter> rather than a div sized
|
/* Usage bars on the status page's machine card. <meter> rather than a div sized
|
||||||
from the reading, because the CSP forbids inline styles (see the note at the
|
from the reading, because the CSP forbids inline styles (see the note at the
|
||||||
top of this file) and a bar's length has to travel on an attribute. The
|
top of this file) and a bar's length has to travel on an attribute. The
|
||||||
|
|||||||
@@ -22,12 +22,13 @@
|
|||||||
{{if .Domains}}
|
{{if .Domains}}
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Domain</th><th>Selector</th><th>Apps</th><th></th></tr>
|
<tr><th>Domain</th><th>DNS</th><th>Selector</th><th>Apps</th><th></th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Domains}}
|
{{range .Domains}}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="/domains/{{.ID}}">{{.Name}}</a></td>
|
<td><a href="/domains/{{.ID}}">{{.Name}}</a></td>
|
||||||
|
<td><a class="st st-{{.DNS}}" href="/domains/{{.ID}}#dns-status">{{.DNS}}</a></td>
|
||||||
<td class="muted">{{.DKIMSelector}}</td>
|
<td class="muted">{{.DKIMSelector}}</td>
|
||||||
<td>{{.AppCount}}</td>
|
<td>{{.AppCount}}</td>
|
||||||
<td class="actions"><a class="danger" href="/domains/{{.ID}}/delete">Delete</a></td>
|
<td class="actions"><a class="danger" href="/domains/{{.ID}}/delete">Delete</a></td>
|
||||||
@@ -35,6 +36,9 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<p class="muted">The <em>DNS</em> badge is the worst of the domain's DKIM, SPF
|
||||||
|
and DMARC checks. Results are cached for a few minutes; open a domain for the
|
||||||
|
details and a <em>Re-check</em> button.</p>
|
||||||
{{else}}
|
{{else}}
|
||||||
<p class="muted">No domains yet. Add one above to get started.</p>
|
<p class="muted">No domains yet. Add one above to get started.</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user