package checker import ( "bytes" "encoding/json" "fmt" "html/template" sdk "git.happydns.org/checker-sdk-go/checker" ) // GetHTMLReport renders the legacy-records observation as a self-contained // HTML page suitable for iframe embedding. // // Cards are built from ctx.States(): each finding state carries the full // metadata (reason, replacement, how-to-fix, locations) in CheckState.Meta. // The rule already emits states in descending severity order, so the first // card is always the top priority without re-sorting here. func (p *legacyProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) { var data LegacyData if raw := ctx.Data(); len(raw) > 0 { if err := json.Unmarshal(raw, &data); err != nil { return "", fmt.Errorf("parse legacy-records data: %w", err) } } view := buildReportView(&data, ctx.States()) buf := &bytes.Buffer{} if err := reportTmpl.Execute(buf, view); err != nil { return "", err } return buf.String(), nil } type reportView struct { Zone string ServicesScanned int Total int OverallStatus string OverallText string OverallClass string Top *findingCard Others []findingCard CollectErrors []string } type findingCard struct { TypeName string Reason string Replacement string HowToFix string Severity string SeverityCSS string Count int Locations []FindingLocation } func buildReportView(data *LegacyData, states []sdk.CheckState) *reportView { v := &reportView{ Zone: data.Zone, ServicesScanned: data.ServicesScanned, Total: len(data.Findings), CollectErrors: data.CollectErrors, } if len(states) == 0 { // No rule output yet: data-only rendering with a neutral headline. v.OverallStatus = "ok" v.OverallText = fmt.Sprintf("Data collected — %d service(s) scanned.", data.ServicesScanned) v.OverallClass = "status-ok" return v } var cards []findingCard for _, st := range states { c, ok := cardFromState(st) if !ok { continue } cards = append(cards, c) } if len(cards) > 0 { v.Top = &cards[0] v.Others = cards[1:] worst := worstFindingStatus(states) v.OverallStatus, v.OverallText, v.OverallClass = overallFromStatus(worst) } else if errState, ok := firstErrorState(states); ok { v.OverallStatus = "error" v.OverallText = errState.Message v.OverallClass = "status-crit" } else { v.OverallStatus = "ok" v.OverallClass = "status-ok" v.OverallText = fmt.Sprintf("No legacy record types found across %d service(s).", data.ServicesScanned) for _, st := range states { if st.Code == "legacy_records_clean" { v.OverallText = st.Message break } } } return v } // cardFromState builds a findingCard from a finding CheckState. States that // carry no "locations" metadata (clean / error / skip states) return ok=false. func cardFromState(st sdk.CheckState) (findingCard, bool) { if st.Meta == nil { return findingCard{}, false } rawLocs, ok := st.Meta["locations"] if !ok { return findingCard{}, false } locations := decodeLocations(rawLocs) typeName, _ := st.Meta["type"].(string) if typeName == "" { typeName = st.Subject } reason, _ := st.Meta["reason"].(string) replacement, _ := st.Meta["replacement"].(string) howToFix, _ := st.Meta["how_to_fix"].(string) sevLabel, sevCSS := severityFromStatus(st.Status) return findingCard{ TypeName: typeName, Reason: reason, Replacement: replacement, HowToFix: howToFix, Severity: sevLabel, SeverityCSS: sevCSS, Count: len(locations), Locations: locations, }, true } // decodeLocations handles the JSON round-trip: Meta values are stored as any // but pass through json.Marshal/Unmarshal when transmitted, so []FindingLocation // arrives as []interface{} of map[string]interface{}. Re-marshaling restores // the typed slice. func decodeLocations(v any) []FindingLocation { b, err := json.Marshal(v) if err != nil { return nil } var locs []FindingLocation if err := json.Unmarshal(b, &locs); err != nil { return nil } return locs } func severityFromStatus(s sdk.Status) (label, css string) { switch s { case sdk.StatusCrit, sdk.StatusError: return "Critical", "crit" case sdk.StatusWarn: return "Warning", "warn" default: return "Informational", "info" } } func worstFindingStatus(states []sdk.CheckState) sdk.Status { worst := sdk.StatusInfo for _, st := range states { switch st.Status { case sdk.StatusCrit, sdk.StatusError: return sdk.StatusCrit case sdk.StatusWarn: worst = sdk.StatusWarn } } return worst } func overallFromStatus(s sdk.Status) (status, text, css string) { switch s { case sdk.StatusCrit, sdk.StatusError: return "crit", "Legacy records require urgent migration", "status-crit" case sdk.StatusWarn: return "warn", "Legacy records should be migrated", "status-warn" default: return "info", "Only informational legacy records found", "status-info" } } func firstErrorState(states []sdk.CheckState) (sdk.CheckState, bool) { for i := range states { if states[i].Status == sdk.StatusError { return states[i], true } } return sdk.CheckState{}, false } var reportTmpl = template.Must(template.New("legacy-records-report").Funcs(template.FuncMap{ "display": func(s string) string { if s == "" || s == "@" { return "@" } return s }, }).Parse(reportTemplate)) const reportTemplate = ` Legacy DNS records — {{if .Zone}}{{.Zone}}{{else}}zone report{{end}}

Legacy DNS records

{{if .Zone}}Zone: {{.Zone}} · {{end}}{{.ServicesScanned}} service(s) scanned · {{.Total}} legacy record(s) found
{{.OverallText}}
{{if .Top}}Most severe: {{.Top.TypeName}} ({{.Top.Severity}}){{else}}No legacy records detected{{end}}
{{if .Top}}

Fix this first

{{.Top.TypeName}} {{.Top.Severity}} {{.Top.Count}} occurrence{{if ne .Top.Count 1}}s{{end}}

{{.Top.Reason}}{{if .Top.Replacement}} · use {{.Top.Replacement}} instead{{end}}
How to fix {{.Top.HowToFix}}
{{if .Top.Locations}} {{range .Top.Locations}} {{end}}
SubdomainOwnerService
{{display .Subdomain}} {{if .Name}}{{.Name}}{{else}}{{end}} {{if .ServiceType}}{{.ServiceType}}{{else}}{{end}}
{{end}}
{{end}} {{if .Others}}

Other legacy records

{{range .Others}}

{{.TypeName}} {{.Severity}} {{.Count}} occurrence{{if ne .Count 1}}s{{end}}

{{.Reason}}{{if .Replacement}} · use {{.Replacement}} instead{{end}}
How to fix: {{.HowToFix}}
{{if .Locations}}
Owners: {{range $i, $l := .Locations}}{{if $i}}, {{end}}{{display $l.Subdomain}}{{end}}
{{end}}
{{end}} {{end}} {{if .CollectErrors}}
{{len .CollectErrors}} service(s) skipped during scan
{{end}} `