Initial commit

This commit is contained in:
nemunaire 2026-04-26 11:49:13 +07:00
commit 2d98ed1b5d
33 changed files with 4644 additions and 0 deletions

733
checker/report.go Normal file
View file

@ -0,0 +1,733 @@
package checker
import (
"bytes"
"encoding/json"
"fmt"
"html/template"
"sort"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// GetHTMLReport implements sdk.CheckerHTMLReporter.
//
// The report is laid out top-down by decreasing importance:
// 1. a "Fix these first" banner listing the common failures (drift,
// DNSSEC, NXDOMAIN, SERVFAIL, regional split, etc.) with a plain-English
// remediation for each;
// 2. a per-RRset consensus table that shows which answers dominate and
// which resolvers disagree: the meat of the check;
// 3. a per-region matrix (consensus / drift / error per region × RRset);
// 4. a detailed per-resolver table for operators who want the raw data.
func (p *resolverPropagationProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
var data ResolverPropagationData
if raw := ctx.Data(); len(raw) > 0 {
if err := json.Unmarshal(raw, &data); err != nil {
return "", fmt.Errorf("parse resolver-propagation data: %w", err)
}
}
deriveView(&data)
findings := statesToFindings(ctx.States())
view := buildReportView(&data, findings)
buf := &bytes.Buffer{}
if err := reportTmpl.Execute(buf, view); err != nil {
return "", err
}
return buf.String(), nil
}
// topFailureOrder is the priority used by the "Fix these first" banner.
// Items at the top reflect more impactful / more actionable issues so the
// reader has a triage path.
var topFailureOrder = []string{
CodeAllResolversDown,
CodeUnexpectedSERVFAIL,
CodeDNSSECFailure,
CodeAnswerDrift,
CodeUnexpectedNXDOMAIN,
CodeSerialDrift,
CodeRegionalSplit,
CodePartialPropagation,
CodeDNSSECUnvalidated,
CodeStaleCache,
CodeResolverRewrote,
CodeResolverUnreachable,
CodeResolverHighLatency,
CodeResolverFilteredHit,
CodeNoResolvers,
}
// reportView is the flattened shape the HTML template consumes.
type reportView struct {
Zone string
OverallStatus string
OverallClass string
OverallMessage string
Stats Stats
TopFailures []topFailure
OtherFindings []Finding
RRsets []rrsetRow
Regions []regionRow
Resolvers []resolverRow
}
type topFailure struct {
Code string
Severity string
Message string
Remedy string
Count int
Class string
Headline string // short, human-readable label for the card
}
type rrsetRow struct {
Key string
Name string
Type string
MatchesExpected bool
Expected []string
HasExpected bool
Groups []groupRow
Agreeing int
Dissenting int
StatusClass string
StatusLabel string
}
type groupRow struct {
Rcode string
Records []string
Resolvers []string
IsConsensus bool
}
type regionRow struct {
Region string
Label string
Resolvers int
Reachable int
Agreeing int
Disagreeing int
Errored int
}
type resolverRow struct {
ID string
Name string
IP string
Region string
Transport string
Filtered bool
Reachable bool
AvgMs int64
Probes []probeRow
}
type probeRow struct {
Key string
Rcode string
Records []string
MinTTL uint32
AD bool
AgreesWithConsensus bool
Error string
LatencyMs int64
}
func buildReportView(d *ResolverPropagationData, findings []Finding) *reportView {
v := &reportView{
Zone: d.Zone,
Stats: d.Stats,
}
// Overall banner: worst severity drives colour.
worst := ""
for _, f := range findings {
switch f.Severity {
case SeverityCrit:
worst = "crit"
case SeverityWarn:
if worst == "" {
worst = "warn"
}
case SeverityInfo:
if worst == "" {
worst = "info"
}
}
if worst == "crit" {
break
}
}
switch worst {
case "crit":
v.OverallStatus = "Critical issues"
v.OverallClass = "banner-crit"
v.OverallMessage = fmt.Sprintf("%s is not propagating correctly across public resolvers.", d.Zone)
case "warn":
v.OverallStatus = "Warnings"
v.OverallClass = "banner-warn"
v.OverallMessage = fmt.Sprintf("%s is propagating, but some resolvers or resource sets disagree.", d.Zone)
case "info":
v.OverallStatus = "Informational"
v.OverallClass = "banner-info"
v.OverallMessage = fmt.Sprintf("%s looks healthy; a few advisory notes below.", d.Zone)
default:
v.OverallStatus = "OK"
v.OverallClass = "banner-ok"
v.OverallMessage = fmt.Sprintf("%s is propagated consistently across %d of %d unfiltered resolvers.",
d.Zone, d.Stats.UnfilteredAgreeing, d.Stats.UnfilteredProbed)
}
// Top failures: bucket findings by code, keep each code's most severe
// occurrence, render in topFailureOrder.
byCode := map[string][]Finding{}
for _, f := range findings {
byCode[f.Code] = append(byCode[f.Code], f)
}
order := map[string]int{}
for i, c := range topFailureOrder {
order[c] = i + 1
}
used := map[string]bool{}
for _, code := range topFailureOrder {
list, ok := byCode[code]
if !ok {
continue
}
used[code] = true
f := list[0]
tf := topFailure{
Code: code,
Severity: string(f.Severity),
Message: f.Message,
Remedy: f.Remedy,
Count: len(list),
Class: "severity-" + string(f.Severity),
Headline: headlineFor(code),
}
v.TopFailures = append(v.TopFailures, tf)
}
// Anything else → "Other findings"
for code, list := range byCode {
if used[code] {
continue
}
for _, f := range list {
v.OtherFindings = append(v.OtherFindings, f)
}
}
sort.SliceStable(v.OtherFindings, func(i, j int) bool {
return severityRank(v.OtherFindings[i].Severity) > severityRank(v.OtherFindings[j].Severity)
})
// RRset rows, sorted by "name/type".
keys := make([]string, 0, len(d.RRsets))
for k := range d.RRsets {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
rv := d.RRsets[k]
row := rrsetRow{
Key: k,
Name: rv.Name,
Type: rv.Type,
MatchesExpected: rv.MatchesExpected,
Expected: rv.ExpectedRecords,
HasExpected: rv.Expected != "",
Agreeing: len(rv.Agreeing),
Dissenting: len(rv.Dissenting),
}
for _, g := range rv.Groups {
row.Groups = append(row.Groups, groupRow{
Rcode: g.Rcode,
Records: g.Records,
Resolvers: g.Resolvers,
IsConsensus: g.Signature == rv.ConsensusSig,
})
}
switch {
case rv.Expected != "" && !rv.MatchesExpected:
row.StatusClass = "pill-crit"
row.StatusLabel = "drift"
case len(rv.Groups) > 1:
row.StatusClass = "pill-warn"
row.StatusLabel = "partial"
case len(rv.Groups) == 1:
row.StatusClass = "pill-ok"
row.StatusLabel = "consensus"
default:
row.StatusClass = "pill-info"
row.StatusLabel = "no data"
}
v.RRsets = append(v.RRsets, row)
}
// Per-region rollup.
byRegion := map[string]*regionRow{}
for _, rv := range d.Resolvers {
r, ok := byRegion[rv.Region]
if !ok {
r = &regionRow{Region: rv.Region, Label: regionLabel(rv.Region)}
byRegion[rv.Region] = r
}
r.Resolvers++
if rv.Reachable {
r.Reachable++
}
if rv.Reachable && !rv.Filtered {
ok := true
for key, p := range rv.Probes {
if p == nil || p.Error != "" {
r.Errored++
ok = false
break
}
cv := d.RRsets[key]
if cv == nil || cv.ConsensusSig == "" {
continue
}
if p.Signature != cv.ConsensusSig {
ok = false
break
}
}
if ok {
r.Agreeing++
} else {
r.Disagreeing++
}
}
}
for _, r := range byRegion {
v.Regions = append(v.Regions, *r)
}
sort.Slice(v.Regions, func(i, j int) bool { return v.Regions[i].Label < v.Regions[j].Label })
// Per-resolver rows.
rids := make([]string, 0, len(d.Resolvers))
for k := range d.Resolvers {
rids = append(rids, k)
}
sort.Strings(rids)
for _, rid := range rids {
rv := d.Resolvers[rid]
var total, n int64
probes := []probeRow{}
pkeys := make([]string, 0, len(rv.Probes))
for k := range rv.Probes {
pkeys = append(pkeys, k)
}
sort.Strings(pkeys)
for _, k := range pkeys {
p := rv.Probes[k]
pr := probeRow{
Key: k,
Rcode: p.Rcode,
Records: p.Records,
MinTTL: p.MinTTL,
AD: p.AD,
Error: p.Error,
LatencyMs: p.LatencyMs,
}
if cv := d.RRsets[k]; cv != nil && cv.ConsensusSig != "" {
pr.AgreesWithConsensus = p.Signature == cv.ConsensusSig
}
if p.Error == "" {
total += p.LatencyMs
n++
}
probes = append(probes, pr)
}
avg := int64(0)
if n > 0 {
avg = total / n
}
v.Resolvers = append(v.Resolvers, resolverRow{
ID: rv.ID,
Name: rv.Name,
IP: rv.IP,
Region: regionLabel(rv.Region),
Transport: string(rv.Transport),
Filtered: rv.Filtered,
Reachable: rv.Reachable,
AvgMs: avg,
Probes: probes,
})
}
return v
}
// Kept here (not in rules) so user-facing wording lives in one layer.
func headlineFor(code string) string {
switch code {
case CodeAllResolversDown:
return "No resolver could be reached"
case CodeUnexpectedSERVFAIL:
return "A resolver returns SERVFAIL"
case CodeDNSSECFailure:
return "DNSSEC validation fails"
case CodeAnswerDrift:
return "Public resolvers disagree with your authoritative answer"
case CodeUnexpectedNXDOMAIN:
return "A resolver sees your zone as non-existent"
case CodeSerialDrift:
return "SOA serial differs between resolvers"
case CodeRegionalSplit:
return "A whole region sees a different answer"
case CodePartialPropagation:
return "Change is mid-propagation"
case CodeDNSSECUnvalidated:
return "Validating resolver did not set AD"
case CodeStaleCache:
return "Resolvers still serve the previous SOA serial"
case CodeResolverRewrote:
return "Resolver rewrote the answer"
case CodeResolverUnreachable:
return "Resolver unreachable from the checker"
case CodeResolverHighLatency:
return "Slow resolver"
case CodeResolverFilteredHit:
return "Filtered resolver is blocking your zone"
case CodeNoResolvers:
return "No resolver matched the current selection"
default:
return code
}
}
// View-layer translation only: rules own severity/code/message, report adds remedy + subject scoping.
func statesToFindings(states []sdk.CheckState) []Finding {
if len(states) == 0 {
return nil
}
var out []Finding
for _, st := range states {
sev, ok := severityFromStatus(st.Status)
if !ok {
continue
}
f := Finding{
Code: st.Code,
Severity: sev,
Message: st.Message,
Remedy: remedyFor(st.Code),
}
if isResolverScopedCode(st.Code) {
f.Resolver = st.Subject
} else if st.Subject != "" && strings.Contains(st.Subject, "/") {
f.RRset = st.Subject
}
out = append(out, f)
}
sort.SliceStable(out, func(i, j int) bool {
if a, b := severityRank(out[i].Severity), severityRank(out[j].Severity); a != b {
return a > b
}
if out[i].Code != out[j].Code {
return out[i].Code < out[j].Code
}
if out[i].RRset != out[j].RRset {
return out[i].RRset < out[j].RRset
}
return out[i].Resolver < out[j].Resolver
})
return out
}
func severityFromStatus(s sdk.Status) (Severity, bool) {
switch s {
case sdk.StatusCrit:
return SeverityCrit, true
case sdk.StatusWarn:
return SeverityWarn, true
case sdk.StatusInfo:
return SeverityInfo, true
}
return "", false
}
func isResolverScopedCode(code string) bool {
switch code {
case CodeResolverUnreachable, CodeResolverTimeout, CodeResolverRewrote,
CodeResolverFilteredHit, CodeResolverHighLatency,
CodeDNSSECFailure, CodeDNSSECUnvalidated:
return true
}
return false
}
// Wording lives here, not in rules: severity is judgment, copy is presentation.
func remedyFor(code string) string {
switch code {
case CodeNoResolvers:
return "loosen the region filter or reset the allowlist in the checker options"
case CodeAllResolversDown:
return "retry later, or verify the checker host's outgoing UDP/53 connectivity"
case CodeSerialDrift:
return "usually transient caching right after a zone push"
case CodeStaleCache:
return "the resolvers cached the previous zone version"
case CodeDNSSECFailure:
return "check that the DS record at the parent matches the DNSKEY at the zone apex"
case CodeDNSSECUnvalidated:
return "enable DNSSEC signing at your provider to get full validation downstream"
case CodeRegionalSplit:
return "possible GeoDNS misconfiguration or regional censorship"
case CodePartialPropagation:
return "wait up to the previous TTL for the old cached answer to expire everywhere"
case CodeAnswerDrift:
return "wait for the old TTL to expire or force a flush on the affected resolvers"
case CodeUnexpectedNXDOMAIN:
return "a resolver returning NXDOMAIN while others return NOERROR usually means a poisoned cache or lame delegation"
case CodeUnexpectedSERVFAIL:
return "check DNSSEC signatures and that every authoritative NS is reachable over UDP and TCP"
case CodeResolverUnreachable:
return "the resolver might be blocking the checker's traffic, firewalled, or temporarily down"
case CodeResolverRewrote:
return "the resolver appears to rewrite answers; users relying on it will see a different zone"
case CodeResolverFilteredHit:
return "normal for a filtered resolver when the zone is on a blocklist"
case CodeResolverHighLatency:
return "usually reflects the checker-to-resolver network path"
}
return ""
}
// severityRank orders severities for sorting; higher = more severe.
func severityRank(s Severity) int {
switch s {
case SeverityCrit:
return 3
case SeverityWarn:
return 2
case SeverityInfo:
return 1
}
return 0
}
// reportFuncs exposes small helpers to the template so it can stay concise.
var reportFuncs = template.FuncMap{
"join": func(sep string, s []string) string { return strings.Join(s, sep) },
"len": func(s []string) int { return len(s) },
}
var reportTmpl = template.Must(template.New("report").Funcs(reportFuncs).Parse(reportTemplateHTML))
const reportTemplateHTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Resolver propagation report {{.Zone}}</title>
<style>
:root {
color-scheme: light dark;
--bg: #f6f7fb;
--fg: #1c1f2a;
--muted: #6c7488;
--card: #ffffff;
--border: #e3e6ef;
--ok: #1e8a4a;
--info: #2563eb;
--warn: #b7791f;
--crit: #c0392b;
--code-bg: #f0f2f8;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #11131a;
--fg: #eceff6;
--muted: #96a0b5;
--card: #181b24;
--border: #252a36;
--code-bg: #1f2330;
}
}
body { margin: 0; padding: 24px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu, sans-serif; background: var(--bg); color: var(--fg); line-height: 1.45; }
h1 { font-size: 1.5rem; margin: 0 0 4px 0; }
h2 { font-size: 1.1rem; margin: 24px 0 12px 0; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
h3 { font-size: 0.95rem; margin: 16px 0 6px 0; }
.muted { color: var(--muted); }
.banner { padding: 14px 16px; border-radius: 10px; margin-bottom: 16px; }
.banner-ok { background: rgba(30,138,74,.1); border-left: 4px solid var(--ok); }
.banner-info { background: rgba(37,99,235,.1); border-left: 4px solid var(--info); }
.banner-warn { background: rgba(183,121,31,.12); border-left: 4px solid var(--warn); }
.banner-crit { background: rgba(192,57,43,.12); border-left: 4px solid var(--crit); }
.banner strong { display:block; font-size: 1.05rem; margin-bottom: 2px; }
.stats { display: flex; flex-wrap: wrap; gap: 12px; margin: 16px 0; }
.stat { background: var(--card); border:1px solid var(--border); border-radius:8px; padding: 10px 14px; min-width: 120px; }
.stat .n { font-size: 1.4rem; font-weight: 600; }
.stat .l { color: var(--muted); font-size: .8rem; text-transform: uppercase; letter-spacing: .04em; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; margin-bottom: 12px; }
.card.severity-crit { border-left: 4px solid var(--crit); }
.card.severity-warn { border-left: 4px solid var(--warn); }
.card.severity-info { border-left: 4px solid var(--info); }
.card-title { display:flex; justify-content:space-between; align-items:center; gap: 12px; }
.card-title .title { font-weight: 600; }
.count-pill { background: var(--code-bg); border-radius: 999px; padding: 2px 10px; font-size: .8rem; }
.remedy { background: var(--code-bg); padding: 8px 10px; border-radius: 6px; margin-top: 8px; font-size: .9rem; }
table { width: 100%; border-collapse: collapse; margin: 8px 0 16px 0; font-size: .9rem; }
th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); vertical-align: top; }
th { font-weight: 600; color: var(--muted); text-transform: uppercase; font-size: .75rem; letter-spacing: .04em; }
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
code { background: var(--code-bg); padding: 1px 5px; border-radius: 4px; font-size: .85em; }
pre { background: var(--code-bg); padding: 10px; border-radius: 6px; overflow-x: auto; font-size: .8em; }
.pill { display:inline-block; padding: 1px 8px; border-radius: 999px; font-size: .75rem; font-weight: 600; }
.pill-ok { background: rgba(30,138,74,.15); color: var(--ok); }
.pill-warn { background: rgba(183,121,31,.18); color: var(--warn); }
.pill-crit { background: rgba(192,57,43,.18); color: var(--crit); }
.pill-info { background: rgba(37,99,235,.15); color: var(--info); }
.group-consensus { background: rgba(30,138,74,.07); }
.tag { display:inline-block; padding: 1px 6px; border-radius: 4px; font-size: .7rem; margin-right: 4px; background: var(--code-bg); color: var(--muted); }
.filtered { opacity: .7; }
details > summary { cursor: pointer; color: var(--muted); font-size: .85rem; }
.ok { color: var(--ok); }
.crit { color: var(--crit); }
.warn { color: var(--warn); }
.info { color: var(--info); }
.records { margin: 0; padding-left: 16px; font-family: ui-monospace, monospace; font-size: .8rem; }
.resolver-list { font-size: .75rem; color: var(--muted); }
</style>
</head>
<body>
<h1>Worldwide DNS propagation <code>{{.Zone}}</code></h1>
<div class="muted">Probe across public recursive resolvers; consensus compared to the zone's own authoritative answer.</div>
<div class="banner {{.OverallClass}}">
<strong>{{.OverallStatus}}</strong>
{{.OverallMessage}}
</div>
<div class="stats">
<div class="stat"><div class="n">{{.Stats.ReachableResolvers}} / {{.Stats.TotalResolvers}}</div><div class="l">Resolvers reachable</div></div>
<div class="stat"><div class="n">{{.Stats.UnfilteredAgreeing}} / {{.Stats.UnfilteredProbed}}</div><div class="l">Unfiltered agreeing</div></div>
<div class="stat"><div class="n">{{.Stats.CountriesCovered}}</div><div class="l">Regions covered</div></div>
{{if .Stats.FilteredProbed}}<div class="stat"><div class="n">{{.Stats.FilteredProbed}}</div><div class="l">Filtered probed</div></div>{{end}}
</div>
{{if .TopFailures}}
<h2>Fix these first</h2>
{{range .TopFailures}}
<div class="card {{.Class}}">
<div class="card-title">
<span class="title">{{.Headline}}</span>
<span class="count-pill">{{.Count}}× · <span class="{{.Severity}}">{{.Severity}}</span></span>
</div>
<div>{{.Message}}</div>
{{if .Remedy}}<div class="remedy"><strong>What to do:</strong> {{.Remedy}}</div>{{end}}
</div>
{{end}}
{{end}}
<h2>Per-RRset consensus</h2>
<table>
<thead><tr><th>Record</th><th>Status</th><th>Expected (authoritative)</th><th>What resolvers see</th></tr></thead>
<tbody>
{{range .RRsets}}
<tr>
<td><code>{{.Name}}</code><br><span class="tag">{{.Type}}</span></td>
<td><span class="pill {{.StatusClass}}">{{.StatusLabel}}</span><br>
<span class="muted">{{.Agreeing}} ok · {{.Dissenting}} diff</span></td>
<td>
{{if .HasExpected}}
{{if .Expected}}<ul class="records">{{range .Expected}}<li>{{.}}</li>{{end}}</ul>{{else}}<span class="muted">(no data / NODATA)</span>{{end}}
{{else}}
<span class="muted">(auth unreachable)</span>
{{end}}
</td>
<td>
{{range .Groups}}
<div class="{{if .IsConsensus}}group-consensus{{end}}" style="margin-bottom:8px; padding:6px 8px; border-radius:6px;">
<span class="pill {{if eq .Rcode "NOERROR"}}pill-ok{{else if eq .Rcode "NXDOMAIN"}}pill-crit{{else if eq .Rcode "SERVFAIL"}}pill-crit{{else}}pill-warn{{end}}">{{.Rcode}}</span>
{{if .IsConsensus}}<span class="pill pill-info">consensus</span>{{end}}
{{if .Records}}<ul class="records">{{range .Records}}<li>{{.}}</li>{{end}}</ul>{{else}}<span class="muted">(empty)</span>{{end}}
<div class="resolver-list">{{len .Resolvers}} resolver(s): {{join ", " .Resolvers}}</div>
</div>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
<h2>Per-region view</h2>
<table>
<thead><tr><th>Region</th><th>Reachable</th><th>Agreeing</th><th>Disagreeing</th><th>Errored</th></tr></thead>
<tbody>
{{range .Regions}}
<tr>
<td>{{.Label}}</td>
<td>{{.Reachable}} / {{.Resolvers}}</td>
<td><span class="ok">{{.Agreeing}}</span></td>
<td>{{if .Disagreeing}}<span class="warn">{{.Disagreeing}}</span>{{else}}0{{end}}</td>
<td>{{if .Errored}}<span class="crit">{{.Errored}}</span>{{else}}0{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
<h2>Per-resolver details</h2>
<table>
<thead><tr><th>Resolver</th><th>Region</th><th>Transport</th><th>Avg&nbsp;ms</th><th>Answers</th></tr></thead>
<tbody>
{{range .Resolvers}}
<tr class="{{if .Filtered}}filtered{{end}}">
<td>
<strong>{{.Name}}</strong>{{if .Filtered}} <span class="tag">filtered</span>{{end}}<br>
<span class="muted"><code>{{.IP}}</code> · {{.ID}}</span>
</td>
<td>{{.Region}}</td>
<td>{{.Transport}}</td>
<td>{{if .Reachable}}{{.AvgMs}}{{else}}<span class="crit">unreachable</span>{{end}}</td>
<td>
{{range .Probes}}
<details>
<summary>
<code>{{.Key}}</code>
<span class="pill {{if .Error}}pill-crit{{else if eq .Rcode "NOERROR"}}{{if .AgreesWithConsensus}}pill-ok{{else}}pill-warn{{end}}{{else}}pill-crit{{end}}">
{{if .Error}}error{{else}}{{.Rcode}}{{if .AgreesWithConsensus}} · {{else}} · {{end}}{{end}}
</span>
{{if .AD}}<span class="tag">AD</span>{{end}}
<span class="muted">{{.LatencyMs}}ms{{if .MinTTL}} · TTL {{.MinTTL}}{{end}}</span>
</summary>
{{if .Error}}<pre>{{.Error}}</pre>{{else if .Records}}<ul class="records">{{range .Records}}<li>{{.}}</li>{{end}}</ul>{{else}}<span class="muted">(empty answer)</span>{{end}}
</details>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{if .OtherFindings}}
<h2>Other findings</h2>
<table>
<thead><tr><th>Severity</th><th>Code</th><th>Message</th><th>Remedy</th></tr></thead>
<tbody>
{{range .OtherFindings}}
<tr>
<td><span class="{{.Severity}}">{{.Severity}}</span></td>
<td><code>{{.Code}}</code></td>
<td>{{.Message}}</td>
<td class="muted">{{.Remedy}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
</body>
</html>
`