Add CheckerHTMLReporter interface and Zonemaster HTML report

Introduces an optional CheckerHTMLReporter interface that checkers can
implement to expose a rich HTML document built from their stored Report
field.  The Zonemaster checker implements it, rendering results grouped
by module in collapsible accordions with color-coded severity badges.
This commit is contained in:
nemunaire 2026-03-01 15:14:21 +07:00
commit 7b094d919c
9 changed files with 515 additions and 27 deletions

View file

@ -25,6 +25,7 @@
package checks // import "git.happydns.org/happyDomain/checks"
import (
"encoding/json"
"fmt"
"log"
@ -70,3 +71,14 @@ func GetCheckInterval(checker happydns.Checker) *happydns.CheckIntervalSpec {
spec := ip.CheckInterval()
return &spec
}
// GetHTMLReport renders an HTML report for the given checker and raw JSON report data.
// Returns (html, true, nil) if the checker supports HTML reports, or ("", false, nil) if not.
func GetHTMLReport(checker happydns.Checker, raw json.RawMessage) (string, bool, error) {
hr, ok := checker.(happydns.CheckerHTMLReporter)
if !ok {
return "", false, nil
}
html, err := hr.GetHTMLReport(raw)
return html, true, err
}

View file

@ -5,8 +5,10 @@ import (
"context"
"encoding/json"
"fmt"
"html/template"
"io"
"net/http"
"sort"
"strings"
"time"
@ -136,6 +138,7 @@ type testResult struct {
type zonemasterResults struct {
CreatedAt string `json:"created_at"`
HashID string `json:"hash_id"`
Language string `json:"language,omitempty"`
Params map[string]any `json:"params"`
Results []testResult `json:"results"`
TestcaseDescriptions map[string]string `json:"testcase_descriptions,omitempty"`
@ -221,7 +224,7 @@ func (p *ZonemasterCheck) RunCheck(ctx context.Context, options happydns.Checker
}
var testID string
if err := json.Unmarshal(result, &testID); err != nil {
if err = json.Unmarshal(result, &testID); err != nil {
return nil, fmt.Errorf("failed to parse test ID: %w", err)
}
@ -272,6 +275,7 @@ testComplete:
if err := json.Unmarshal(result, &results); err != nil {
return nil, fmt.Errorf("failed to parse results: %w", err)
}
results.Language = language
// Analyze results to determine overall status
var (
@ -319,3 +323,293 @@ testComplete:
Report: results,
}, nil
}
// ── HTML report ───────────────────────────────────────────────────────────────
// zmLevelDisplayOrder defines the severity order used for sorting and display.
var zmLevelDisplayOrder = []string{"CRITICAL", "ERROR", "WARNING", "NOTICE", "INFO", "DEBUG"}
var zmLevelRank = func() map[string]int {
m := make(map[string]int, len(zmLevelDisplayOrder))
for i, l := range zmLevelDisplayOrder {
m[l] = len(zmLevelDisplayOrder) - i
}
return m
}()
type zmLevelCount struct {
Level string
Count int
}
type zmModuleGroup struct {
Name string
Position int // first-seen index, used as tiebreaker in sort
Results []testResult
Levels []zmLevelCount // sorted by severity desc, zeros omitted
Worst string
Open bool
}
type zmTemplateData struct {
Domain string
CreatedAt string
HashID string
Language string
Modules []zmModuleGroup
Totals []zmLevelCount // sorted by severity desc, zeros omitted
}
var zonemasterHTMLTemplate = template.Must(
template.New("zonemaster").
Funcs(template.FuncMap{
"badgeClass": func(level string) string {
switch strings.ToUpper(level) {
case "CRITICAL":
return "badge-critical"
case "ERROR":
return "badge-error"
case "WARNING":
return "badge-warning"
case "NOTICE":
return "badge-notice"
case "INFO":
return "badge-info"
default:
return "badge-debug"
}
},
}).
Parse(`<!DOCTYPE html>
<html lang="{{.Language}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Zonemaster{{if .Domain}} {{.Domain}}{{end}}</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
:root {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
line-height: 1.5;
color: #1f2937;
background: #f3f4f6;
}
body { margin: 0; padding: 1rem; }
a { color: inherit; }
code { font-family: ui-monospace, monospace; font-size: .9em; }
/* Header card */
.hd {
background: #fff;
border-radius: 10px;
padding: 1rem 1.25rem 1.1rem;
margin-bottom: .75rem;
box-shadow: 0 1px 3px rgba(0,0,0,.08);
}
.hd h1 { margin: 0 0 .2rem; font-size: 1.15rem; font-weight: 700; }
.hd .meta { color: #6b7280; font-size: .82rem; margin-bottom: .6rem; }
.totals { display: flex; gap: .35rem; flex-wrap: wrap; }
/* Badges */
.badge {
display: inline-flex; align-items: center;
padding: .18em .55em;
border-radius: 9999px;
font-size: .72rem; font-weight: 700;
letter-spacing: .02em; white-space: nowrap;
}
.badge-critical { background: #fee2e2; color: #991b1b; }
.badge-error { background: #ffedd5; color: #9a3412; }
.badge-warning { background: #fef3c7; color: #92400e; }
.badge-notice { background: #e0f2fe; color: #075985; }
.badge-info { background: #dbeafe; color: #1e40af; }
.badge-debug { background: #f3f4f6; color: #4b5563; }
/* Accordion */
details {
background: #fff;
border-radius: 8px;
margin-bottom: .45rem;
box-shadow: 0 1px 3px rgba(0,0,0,.07);
overflow: hidden;
}
summary {
display: flex; align-items: center; gap: .5rem;
padding: .65rem 1rem;
cursor: pointer;
user-select: none;
list-style: none;
}
summary::-webkit-details-marker { display: none; }
summary::before {
content: "▶";
font-size: .65rem;
color: #9ca3af;
transition: transform .15s;
flex-shrink: 0;
}
details[open] > summary::before { transform: rotate(90deg); }
.mod-name { font-weight: 600; flex: 1; font-size: .9rem; }
.mod-badges { display: flex; gap: .25rem; flex-wrap: wrap; }
/* Result rows */
.results { border-top: 1px solid #f3f4f6; }
.row {
display: grid;
grid-template-columns: max-content 1fr;
gap: .6rem;
padding: .45rem 1rem;
border-bottom: 1px solid #f9fafb;
align-items: start;
}
.row:last-child { border-bottom: none; }
.row-msg { color: #374151; }
.row-tc { font-size: .75rem; color: #9ca3af; }
</style>
</head>
<body>
<div class="hd">
<h1>Zonemaster{{if .Domain}} <code>{{.Domain}}</code>{{end}}</h1>
<div class="meta">
{{- if .CreatedAt}}Run at {{.CreatedAt}}{{end -}}
{{- if and .CreatedAt .HashID}} &middot; {{end -}}
{{- if .HashID}}ID: <code>{{.HashID}}</code>{{end -}}
</div>
<div class="totals">
{{- range .Totals}}
<span class="badge {{badgeClass .Level}}">{{.Level}}&nbsp;{{.Count}}</span>
{{- end}}
</div>
</div>
{{range .Modules -}}
<details{{if .Open}} open{{end}}>
<summary>
<span class="mod-name">{{.Name}}</span>
<span class="mod-badges">
{{- range .Levels}}
<span class="badge {{badgeClass .Level}}">{{.Count}}</span>
{{- end}}
</span>
</summary>
<div class="results">
{{- range .Results}}
<div class="row">
<span class="badge {{badgeClass .Level}}">{{.Level}}</span>
<div>
<div class="row-msg">{{.Message}}</div>
{{- if .Testcase}}<div class="row-tc">{{.Testcase}}</div>{{end}}
</div>
</div>
{{- end}}
</div>
</details>
{{end -}}
</body>
</html>`),
)
// GetHTMLReport implements happydns.CheckerHTMLReporter.
func (p *ZonemasterCheck) GetHTMLReport(raw json.RawMessage) (string, error) {
var results zonemasterResults
if err := json.Unmarshal(raw, &results); err != nil {
return "", fmt.Errorf("failed to unmarshal zonemaster results: %w", err)
}
// Group results by module, preserving first-seen order.
moduleOrder := []string{}
moduleMap := map[string][]testResult{}
for _, r := range results.Results {
if _, seen := moduleMap[r.Module]; !seen {
moduleOrder = append(moduleOrder, r.Module)
}
moduleMap[r.Module] = append(moduleMap[r.Module], r)
}
totalCounts := map[string]int{}
var modules []zmModuleGroup
for _, name := range moduleOrder {
rs := moduleMap[name]
counts := map[string]int{}
for _, r := range rs {
lvl := strings.ToUpper(r.Level)
counts[lvl]++
totalCounts[lvl]++
}
// Find worst level and build sorted level-count slice.
worst := ""
worstRank := -1
var levels []zmLevelCount
for _, l := range zmLevelDisplayOrder {
if n, ok := counts[l]; ok && n > 0 {
levels = append(levels, zmLevelCount{Level: l, Count: n})
if zmLevelRank[l] > worstRank {
worstRank = zmLevelRank[l]
worst = l
}
}
}
// Append any unknown levels last.
for l, n := range counts {
if _, known := zmLevelRank[l]; !known {
levels = append(levels, zmLevelCount{Level: l, Count: n})
}
}
modules = append(modules, zmModuleGroup{
Name: name,
Position: len(modules),
Results: rs,
Levels: levels,
Worst: worst,
Open: worst == "CRITICAL" || worst == "ERROR",
})
}
// Sort modules: most severe first, then by original appearance order.
sort.Slice(modules, func(i, j int) bool {
ri, rj := zmLevelRank[modules[i].Worst], zmLevelRank[modules[j].Worst]
if ri != rj {
return ri > rj
}
return modules[i].Position < modules[j].Position
})
// Build sorted totals slice.
var totals []zmLevelCount
for _, l := range zmLevelDisplayOrder {
if n, ok := totalCounts[l]; ok && n > 0 {
totals = append(totals, zmLevelCount{Level: l, Count: n})
}
}
domain := ""
if d, ok := results.Params["domain"]; ok {
domain = fmt.Sprintf("%v", d)
}
lang := results.Language
if lang == "" {
lang = "en"
}
data := zmTemplateData{
Domain: domain,
CreatedAt: results.CreatedAt,
HashID: results.HashID,
Language: lang,
Modules: modules,
Totals: totals,
}
var buf strings.Builder
if err := zonemasterHTMLTemplate.Execute(&buf, data); err != nil {
return "", fmt.Errorf("failed to render zonemaster HTML report: %w", err)
}
return buf.String(), nil
}

View file

@ -59,12 +59,14 @@ func (bc *BaseCheckerController) ListCheckers(c *gin.Context) {
res := map[string]happydns.CheckerResponse{}
for name, checker := range *checkers {
_, hasHTML := checker.(happydns.CheckerHTMLReporter)
res[name] = happydns.CheckerResponse{
ID: name,
Name: checker.Name(),
Availability: checker.Availability(),
Options: checker.Options(),
Interval: checks.GetCheckInterval(checker),
ID: name,
Name: checker.Name(),
Availability: checker.Availability(),
Options: checker.Options(),
Interval: checks.GetCheckInterval(checker),
HasHTMLReport: hasHTML,
}
}
@ -75,12 +77,14 @@ func (bc *BaseCheckerController) ListCheckers(c *gin.Context) {
func (bc *BaseCheckerController) GetCheckerStatus(c *gin.Context) {
checker := c.MustGet("checker").(happydns.Checker)
_, hasHTML := checker.(happydns.CheckerHTMLReporter)
c.JSON(http.StatusOK, happydns.CheckerResponse{
ID: checker.ID(),
Name: checker.Name(),
Availability: checker.Availability(),
Options: checker.Options(),
Interval: checks.GetCheckInterval(checker),
ID: checker.ID(),
Name: checker.Name(),
Availability: checker.Availability(),
Options: checker.Options(),
Interval: checks.GetCheckInterval(checker),
HasHTMLReport: hasHTML,
})
}

View file

@ -22,11 +22,13 @@
package controller
import (
"encoding/json"
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/checks"
"git.happydns.org/happyDomain/internal/api/middleware"
"git.happydns.org/happyDomain/model"
)
@ -295,6 +297,65 @@ func (tc *CheckResultController) GetCheckResult(c *gin.Context) {
c.JSON(http.StatusOK, result)
}
// GetCheckResultHTMLReport returns the HTML report for a specific check result
//
// @Summary Get check result HTML report
// @Description Returns the full HTML document generated from the check result's report data. Only available for checkers that implement HTML reporting.
// @Tags checks
// @Produce html
// @Param domain path string true "Domain identifier"
// @Param cname path string true "Check plugin name"
// @Param result_id path string true "Result ID"
// @Success 200 {string} string "HTML document"
// @Failure 404 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checks/{cname}/results/{result_id}/report [get]
func (tc *CheckResultController) GetCheckResultHTMLReport(c *gin.Context) {
checkName := c.Param("cname")
resultIDStr := c.Param("result_id")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
resultID, err := happydns.NewIdentifierFromString(resultIDStr)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid result ID"))
return
}
result, err := tc.checkResultUC.GetCheckResult(checkName, tc.scope, targetID, resultID)
if err != nil {
middleware.ErrorResponse(c, http.StatusNotFound, err)
return
}
checker, err := tc.checkerUC.GetChecker(checkName)
if err != nil {
middleware.ErrorResponse(c, http.StatusNotFound, err)
return
}
raw, err := json.Marshal(result.Report)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
htmlContent, supported, err := checks.GetHTMLReport(checker, json.RawMessage(raw))
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
if !supported {
middleware.ErrorResponse(c, http.StatusNotFound, fmt.Errorf("checker %q does not support HTML reports", checkName))
return
}
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(htmlContent))
}
// DropCheckResult deletes a specific check result
//
// @Summary Delete check result

View file

@ -37,5 +37,6 @@ func DeclareScopedCheckResultRoutes(apiChecksRoutes *gin.RouterGroup, tc *contro
{
apiCheckResultsRoutes.GET("", tc.GetCheckResult)
apiCheckResultsRoutes.DELETE("", tc.DropCheckResult)
apiCheckResultsRoutes.GET("/report", tc.GetCheckResultHTMLReport)
}
}

View file

@ -23,6 +23,7 @@ package happydns
import (
"context"
"encoding/json"
"time"
)
@ -84,12 +85,23 @@ type CheckerIntervalProvider interface {
CheckInterval() CheckIntervalSpec
}
// CheckerHTMLReporter is an optional interface checkers can implement
// to render their stored report as a full HTML document (for iframe embedding).
// Detect support with a type assertion: _, ok := checker.(CheckerHTMLReporter)
type CheckerHTMLReporter interface {
// GetHTMLReport generates an HTML document from the JSON-encoded report data
// stored in CheckResult.Report.
// The raw parameter contains the JSON bytes of the Report field as stored.
GetHTMLReport(raw json.RawMessage) (string, error)
}
type CheckerResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Availability CheckerAvailability `json:"availability"`
Options CheckerOptionsDocumentation `json:"options"`
Interval *CheckIntervalSpec `json:"interval"`
ID string `json:"id"`
Name string `json:"name"`
Availability CheckerAvailability `json:"availability"`
Options CheckerOptionsDocumentation `json:"options"`
Interval *CheckIntervalSpec `json:"interval"`
HasHTMLReport bool `json:"has_html_report,omitempty"`
}
type SetCheckerOptionsRequest struct {

View file

@ -34,6 +34,7 @@ import {
getDomainsByDomainChecks,
getDomainsByDomainChecksByCnameResults,
getDomainsByDomainChecksByCnameResultsByResultId,
getDomainsByDomainChecksByCnameResultsByResultIdReport,
deleteDomainsByDomainChecksByCnameResults,
deleteDomainsByDomainChecksByCnameResultsByResultId,
getDomainsByDomainChecksByCnameExecutionsByExecutionId,
@ -259,6 +260,18 @@ export async function deleteAllCheckResults(domainId: string, checkName: string)
});
}
export async function getCheckResultHTMLReport(
domainId: string,
checkName: string,
resultId: string,
): Promise<string> {
return unwrapSdkResponse(
await getDomainsByDomainChecksByCnameResultsByResultIdReport({
path: { domain: domainId, cname: checkName, result_id: resultId },
}),
) as string;
}
export async function getCheckExecution(
domainId: string,
checkName: string,

View file

@ -715,7 +715,11 @@
"status": "Status:",
"status-message": "Message:",
"error": "Error:"
}
},
"view-html": "HTML Report",
"view-json": "Raw JSON",
"download-html": "Download HTML",
"download-json": "Download JSON"
},
"title": "Checkers",
"description": "Configure automated checks for your domains",

View file

@ -26,6 +26,7 @@
Alert,
Badge,
Button,
ButtonGroup,
Card,
CardBody,
CardHeader,
@ -42,6 +43,7 @@
import {
getCheckStatus,
getCheckResult,
getCheckResultHTMLReport,
deleteCheckResult,
triggerCheck,
} from "$lib/api/checkers";
@ -60,9 +62,34 @@
let resultPromise = $derived(getCheckResult(data.domain.id, checkName, resultId));
let checkPromise = $derived(getCheckStatus(checkName));
let htmlReportPromise = $derived(getCheckResultHTMLReport(data.domain.id, checkName, resultId));
let errorMessage = $state<string | null>(null);
let resolvedResult = $state<CheckResult | null>(null);
let isRelaunching = $state(false);
let showHTML = $state(true);
function downloadBlob(content: string, filename: string, mime: string) {
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
function downloadJSON(result: CheckResult) {
downloadBlob(
JSON.stringify(result.report, null, 2),
`${checkName}-${resultId}.json`,
"application/json",
);
}
async function downloadHTML() {
const html = await htmlReportPromise;
downloadBlob(html, `${checkName}-${resultId}.html`, "text/html");
}
$effect(() => {
resultPromise.then((r) => {
@ -271,19 +298,79 @@
{/if}
</Row>
{#if result.report}
<Card>
{#if result.report || check.has_html_report}
<Card class="mt-3">
<CardHeader>
<h5 class="mb-0">
<Icon name="file-earmark-text"></Icon>
{$t("checkers.result.full-report")}
</h5>
<div class="d-flex justify-content-between align-items-center gap-2 flex-wrap">
<h5 class="mb-0">
<Icon name="file-earmark-text"></Icon>
{$t("checkers.result.full-report")}
</h5>
<div class="d-flex gap-2 align-items-center flex-wrap">
{#if check.has_html_report}
<ButtonGroup size="sm">
<Button
color="secondary"
outline
active={showHTML}
onclick={() => (showHTML = true)}
>
<Icon name="file-earmark-richtext"></Icon>
{$t("checkers.result.view-html")}
</Button>
<Button
color="secondary"
outline
active={!showHTML}
onclick={() => (showHTML = false)}
>
<Icon name="braces"></Icon>
{$t("checkers.result.view-json")}
</Button>
</ButtonGroup>
{/if}
<ButtonGroup size="sm">
{#if check.has_html_report}
<Button color="outline-secondary" onclick={downloadHTML}>
<Icon name="download"></Icon>
{$t("checkers.result.download-html")}
</Button>
{/if}
{#if result.report != null}
<Button
color="outline-secondary"
onclick={() => downloadJSON(result)}
>
<Icon name="download"></Icon>
{$t("checkers.result.download-json")}
</Button>
{/if}
</ButtonGroup>
</div>
</div>
</CardHeader>
<CardBody class="text-truncate p-0">
{#if typeof result.report === "string"}
<pre class="bg-light p-3 rounded mb-0"><code>{result.report}</code></pre>
<CardBody class="p-0">
{#if check.has_html_report && showHTML}
{#await htmlReportPromise}
<div class="text-center p-4"><Spinner /></div>
{:then html}
<iframe
srcdoc={html}
sandbox=""
title={$t("checkers.result.full-report")}
style="width: 100%; min-height: 600px; border: none; display: block;"
></iframe>
{:catch}
<pre class="bg-light p-3 rounded mb-0 overflow-x-scroll"><code
>{JSON.stringify(result.report, null, 2)}</code
></pre>
{/await}
{:else if typeof result.report === "string"}
<pre class="bg-light p-3 rounded mb-0 overflow-x-scroll"><code
>{result.report}</code
></pre>
{:else}
<pre class="bg-light p-3 rounded mb-0"><code
<pre class="bg-light p-3 rounded mb-0 overflow-x-scroll"><code
>{JSON.stringify(result.report, null, 2)}</code
></pre>
{/if}