// This file is part of the happyDomain (R) project.
// Copyright (c) 2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
package checker
import (
"bytes"
"encoding/json"
"fmt"
"html/template"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// GetHTMLReport renders an HTML document summarizing the last alias run.
// Critical findings are surfaced in a dedicated top section with fix hints;
// the chain is visualized as a stepped list; the full findings list sits
// below as a detailed table.
func (p *aliasProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
var data AliasData
if raw := ctx.Data(); len(raw) > 0 {
if err := json.Unmarshal(raw, &data); err != nil {
return "", fmt.Errorf("parse alias data: %w", err)
}
}
view := buildReportView(&data)
buf := &bytes.Buffer{}
if err := reportTmpl.Execute(buf, view); err != nil {
return "", err
}
return buf.String(), nil
}
// topFailureCodes lists the findings that deserve a dedicated "fix this first"
// card at the top of the report. Order matters: it drives the visual order.
var topFailureCodes = []string{
"alias_cname_at_apex",
"alias_coexisting_rrset",
"alias_loop",
"alias_chain_too_long",
"alias_target_unresolvable",
"alias_rcode",
"alias_cname_not_signed",
"alias_multiple_records",
}
// reportView is the template payload. We pre-compute everything the template
// needs so the template itself stays dumb.
type reportView struct {
Owner string
Apex string
FinalTarget string
FinalAddresses []string
OverallStatus string
OverallStatusText string
OverallClass string
ChainSteps []chainStep
DNAMEs []ChainHop
Coexisting []CoexistingRRset
ApexFlattening bool
ZoneSigned bool
CNAMESigned bool
TopFailures []topFailure
OtherFindings []AliasFinding
RawJSON string
}
type chainStep struct {
Index int
Owner string
Kind string
Target string
TTL uint32
Server string
IsLast bool
CSSKind string
}
type topFailure struct {
Code string
Title string
Severity string
Messages []string
Hint string
Subject string
}
func buildReportView(data *AliasData) *reportView {
v := &reportView{
Owner: data.Owner,
Apex: data.Apex,
FinalTarget: data.FinalTarget,
DNAMEs: data.DNAMESubstitutions,
Coexisting: data.Coexisting,
ApexFlattening: data.ApexFlattening,
ZoneSigned: data.ZoneSigned,
CNAMESigned: data.CNAMESigned,
}
v.FinalAddresses = append(v.FinalAddresses, data.FinalA...)
v.FinalAddresses = append(v.FinalAddresses, data.FinalAAAA...)
// Overall status = worst severity among findings.
worst := ""
for _, f := range data.Findings {
switch f.Severity {
case SeverityCrit:
worst = "crit"
case SeverityWarn:
if worst != "crit" {
worst = "warn"
}
case SeverityInfo:
if worst == "" {
worst = "info"
}
}
}
switch worst {
case "crit":
v.OverallStatus = "crit"
v.OverallStatusText = "Critical issues detected"
v.OverallClass = "status-crit"
case "warn":
v.OverallStatus = "warn"
v.OverallStatusText = "Warnings detected"
v.OverallClass = "status-warn"
case "info":
v.OverallStatus = "info"
v.OverallStatusText = "Informational notes"
v.OverallClass = "status-info"
default:
v.OverallStatus = "ok"
v.OverallStatusText = "Alias chain healthy"
v.OverallClass = "status-ok"
}
// Chain steps.
for i, h := range data.Chain {
step := chainStep{
Index: i + 1,
Owner: h.Owner,
Kind: string(h.Kind),
Target: h.Target,
TTL: h.TTL,
Server: h.Server,
IsLast: i == len(data.Chain)-1,
}
switch h.Kind {
case KindCNAME:
step.CSSKind = "kind-cname"
case KindDNAME:
step.CSSKind = "kind-dname"
case KindALIAS:
step.CSSKind = "kind-alias"
case KindTarget:
step.CSSKind = "kind-target"
}
v.ChainSteps = append(v.ChainSteps, step)
}
// Bucket findings: top failures (grouped by code) vs. the rest.
topIndex := map[string]int{}
for i, c := range topFailureCodes {
topIndex[c] = i
}
topMap := map[string]*topFailure{}
for _, f := range data.Findings {
if _, isTop := topIndex[f.Code]; isTop {
tf, ok := topMap[f.Code]
if !ok {
tf = &topFailure{
Code: f.Code,
Title: titleFor(f.Code),
Severity: string(f.Severity),
Hint: f.Hint,
Subject: f.Subject,
}
topMap[f.Code] = tf
}
tf.Messages = append(tf.Messages, f.Message)
if tf.Hint == "" {
tf.Hint = f.Hint
}
// Escalate severity to the worst among grouped findings.
if severityRank(f.Severity) > severityRank(Severity(tf.Severity)) {
tf.Severity = string(f.Severity)
}
continue
}
v.OtherFindings = append(v.OtherFindings, f)
}
for _, code := range topFailureCodes {
if tf, ok := topMap[code]; ok {
v.TopFailures = append(v.TopFailures, *tf)
}
}
if raw, err := json.MarshalIndent(data, "", " "); err == nil {
v.RawJSON = string(raw)
}
return v
}
func severityRank(s Severity) int {
switch s {
case SeverityCrit:
return 3
case SeverityWarn:
return 2
case SeverityInfo:
return 1
}
return 0
}
func titleFor(code string) string {
switch code {
case "alias_cname_at_apex":
return "CNAME at zone apex"
case "alias_coexisting_rrset":
return "CNAME coexists with other records"
case "alias_loop":
return "Alias chain loops"
case "alias_chain_too_long":
return "Alias chain too long"
case "alias_target_unresolvable":
return "Target does not resolve"
case "alias_rcode":
return "Alias lookup error"
case "alias_cname_not_signed":
return "CNAME not DNSSEC-signed"
case "alias_multiple_records":
return "Multiple CNAME records at the same name"
}
return strings.ReplaceAll(code, "_", " ")
}
var reportTmpl = template.Must(template.New("alias-report").Parse(reportTemplate))
// reportTemplate is the single-file HTML report. Styles are inlined so the
// report embeds cleanly in an iframe with no asset dependencies.
const reportTemplate = `
Alias chain report — {{.Owner}}
{{.OverallStatusText}}
for {{.Owner}}
{{if .FinalTarget}}final: {{.FinalTarget}}{{end}}
Apex
{{if .Apex}}{{.Apex}}{{else}}—{{end}}
Final target
{{if .FinalTarget}}{{.FinalTarget}}{{else}}—{{end}}
Final addresses
{{if .FinalAddresses}}{{range .FinalAddresses}}{{.}}
{{end}}{{else}}none{{end}}
DNSSEC
{{if .ZoneSigned}}signed zone{{else}}unsigned{{end}}
{{if .ZoneSigned}}{{if .CNAMESigned}}CNAME signed{{else}}CNAME unsigned{{end}}{{end}}
Apex flattening (ALIAS/ANAME)
{{if .ApexFlattening}}detected{{else}}not detected{{end}}
{{if .TopFailures}}
Fix these first
{{range .TopFailures}}
{{.Title}} {{.Severity}}
{{range .Messages}}- {{.}}
{{end}}
{{if .Hint}}
How to fix{{.Hint}}
{{end}}
{{end}}
{{end}}
{{if .ChainSteps}}
Resolution chain
{{range .ChainSteps}}
#{{.Index}}
{{.Kind}}
{{.Owner}}
{{if .Target}}→{{.Target}}{{end}}
{{if .TTL}}TTL {{.TTL}}s{{end}}
{{if .Server}} · {{.Server}}{{end}}
{{end}}
{{end}}
{{if .DNAMEs}}
DNAME substitutions
| Owner | Target | TTL | Server |
{{range .DNAMEs}}
{{.Owner}} |
{{.Target}} |
{{.TTL}} |
{{.Server}} |
{{end}}
{{end}}
{{if .Coexisting}}
Records coexisting with CNAME
| Type | TTL |
{{range .Coexisting}}
{{.Type}} | {{.TTL}} |
{{end}}
{{end}}
{{if .OtherFindings}}
Additional findings
| Severity | Code | Subject | Message |
{{range .OtherFindings}}
| {{.Severity}} |
{{.Code}} |
{{.Subject}} |
{{.Message}}{{if .Hint}} {{.Hint}}{{end}} |
{{end}}
{{end}}
{{if .RawJSON}}
Raw observation
Show raw JSON
{{.RawJSON}}
{{end}}
`