From b37ed1d349706ac36f87f54051b5c15938c581aa Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 4 Apr 2026 22:55:52 +0700 Subject: [PATCH] checkers: add HTML report rendering for observation providers Introduce CheckerHTMLReporter interface that observation providers can implement to render rich HTML documents from their data. The Zonemaster provider implements it with collapsible accordions and severity badges. Adds API endpoint GET .../observations/:obsKey/report, frontend stores for view mode switching (HTML/JSON), and wires the sidebar toggle buttons. --- internal/api/controller/checker_results.go | 58 ++++++ internal/api/controller/checker_test.go | 181 +++++++++++++++++- internal/api/route/checker.go | 1 + web/src/lib/api/checkers.ts | 24 +++ .../checkers/ExecutionDetailPage.svelte | 23 ++- .../checkers/ExecutionSidebarContent.svelte | 75 ++++++-- .../checkers/ObservationReportCard.svelte | 53 ++++- web/src/lib/stores/checkers.ts | 3 + 8 files changed, 392 insertions(+), 26 deletions(-) diff --git a/internal/api/controller/checker_results.go b/internal/api/controller/checker_results.go index cba59d01..0fbd6b7e 100644 --- a/internal/api/controller/checker_results.go +++ b/internal/api/controller/checker_results.go @@ -22,11 +22,14 @@ package controller import ( + "encoding/json" + "fmt" "net/http" "github.com/gin-gonic/gin" "git.happydns.org/happyDomain/internal/api/middleware" + checkerPkg "git.happydns.org/happyDomain/internal/checker" checkerUC "git.happydns.org/happyDomain/internal/usecase/checker" "git.happydns.org/happyDomain/model" ) @@ -266,3 +269,58 @@ func (cc *CheckerController) GetExecutionResult(c *gin.Context) { c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Rule result not found"}) } + +// GetExecutionHTMLReport returns the HTML report for a specific observation of an execution. +// +// @Summary Get execution observation HTML report +// @Description Returns the full HTML document generated from an observation's data. Only available for observation providers that implement HTML reporting. +// @Tags checkers +// @Produce html +// @Param checkerId path string true "Checker ID" +// @Param executionId path string true "Execution ID" +// @Param obsKey path string true "Observation key" +// @Param domain path string true "Domain identifier" +// @Param zoneid path string true "Zone identifier" +// @Param subdomain path string true "Subdomain" +// @Param serviceid path string true "Service identifier" +// @Success 200 {string} string "HTML document" +// @Failure 404 {object} happydns.ErrorResponse +// @Failure 500 {object} happydns.ErrorResponse +// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/observations/{obsKey}/report [get] +// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/observations/{obsKey}/report [get] +func (cc *CheckerController) GetExecutionHTMLReport(c *gin.Context) { + exec := c.MustGet("execution").(*happydns.Execution) + + snap, err := cc.statusUC.GetObservationsByExecution(targetFromContext(c), exec.Id) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Observations not available"}) + return + } + + obsKey := c.Param("obsKey") + val, ok := snap.Data[obsKey] + if !ok { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Observation key not found"}) + return + } + + raw, err := json.Marshal(val) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + + htmlContent, supported, err := checkerPkg.GetHTMLReport(obsKey, json.RawMessage(raw)) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + if !supported { + middleware.ErrorResponse(c, http.StatusNotFound, fmt.Errorf("observation %q does not support HTML reports", obsKey)) + return + } + + c.Header("Content-Security-Policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'self'") + c.Header("X-Content-Type-Options", "nosniff") + c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(htmlContent)) +} diff --git a/internal/api/controller/checker_test.go b/internal/api/controller/checker_test.go index c785c1bc..490913c7 100644 --- a/internal/api/controller/checker_test.go +++ b/internal/api/controller/checker_test.go @@ -34,6 +34,7 @@ import ( "github.com/gin-gonic/gin" checkerPkg "git.happydns.org/happyDomain/internal/checker" + "git.happydns.org/happyDomain/internal/storage" "git.happydns.org/happyDomain/internal/storage/inmemory" checkerUC "git.happydns.org/happyDomain/internal/usecase/checker" "git.happydns.org/happyDomain/model" @@ -91,6 +92,17 @@ func (p *testObservationProvider) Collect(ctx context.Context, opts happydns.Che return map[string]any{"v": 1}, nil } +// testHTMLObservationProvider implements CheckerHTMLReporter for HTML report tests. +type testHTMLObservationProvider struct{} + +func (p *testHTMLObservationProvider) Key() happydns.ObservationKey { return "test_html_obs" } +func (p *testHTMLObservationProvider) Collect(ctx context.Context, opts happydns.CheckerOptions) (any, error) { + return map[string]any{"html": true}, nil +} +func (p *testHTMLObservationProvider) GetHTMLReport(raw json.RawMessage) (string, error) { + return "test report", nil +} + // testCheckRule produces a fixed status. type testCheckRule struct { name string @@ -126,6 +138,12 @@ func registerTestChecker() string { // newTestController creates a CheckerController with in-memory storage. func newTestController(engine happydns.CheckerEngine) *CheckerController { + cc, _ := newTestControllerWithStorage(engine) + return cc +} + +// newTestControllerWithStorage creates a CheckerController and returns the underlying storage. +func newTestControllerWithStorage(engine happydns.CheckerEngine) (*CheckerController, storage.Storage) { store, err := inmemory.Instantiate() if err != nil { panic(err) @@ -133,7 +151,7 @@ func newTestController(engine happydns.CheckerEngine) *CheckerController { optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil) planUC := checkerUC.NewCheckPlanUsecase(store) statusUC := checkerUC.NewCheckStatusUsecase(store, store, store, store) - return NewCheckerController(engine, optionsUC, planUC, statusUC, nil) + return NewCheckerController(engine, optionsUC, planUC, statusUC, nil), store } // --- targetFromContext tests --- @@ -541,3 +559,164 @@ func TestPlanHandler_NotFound(t *testing.T) { t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String()) } } + +// --- GetExecutionHTMLReport tests --- + +// seedExecutionWithObservations creates an execution backed by a snapshot containing the given +// observation data. It returns the execution (with ID assigned by the store). +func seedExecutionWithObservations(t *testing.T, store storage.Storage, target happydns.CheckTarget, data map[happydns.ObservationKey]json.RawMessage) *happydns.Execution { + t.Helper() + + snap := &happydns.ObservationSnapshot{ + Target: target, + CollectedAt: time.Now(), + Data: data, + } + if err := store.CreateSnapshot(snap); err != nil { + t.Fatalf("CreateSnapshot: %v", err) + } + + eval := &happydns.CheckEvaluation{ + CheckerID: "html_test_checker", + Target: target, + SnapshotID: snap.Id, + } + if err := store.CreateEvaluation(eval); err != nil { + t.Fatalf("CreateEvaluation: %v", err) + } + + exec := &happydns.Execution{ + CheckerID: "html_test_checker", + Target: target, + Status: happydns.ExecutionDone, + EvaluationID: &eval.Id, + } + if err := store.CreateExecution(exec); err != nil { + t.Fatalf("CreateExecution: %v", err) + } + return exec +} + +func init() { + // Register the HTML observation provider once for tests. + checkerPkg.RegisterObservationProvider(&testHTMLObservationProvider{}) +} + +func TestGetExecutionHTMLReport_ObservationsNotAvailable(t *testing.T) { + cc := newTestController(&stubCheckerEngine{}) + + // Create an execution with no evaluation/snapshot backing. + fakeExecID, _ := happydns.NewRandomIdentifier() + exec := &happydns.Execution{ + Id: fakeExecID, + CheckerID: "html_test_checker", + Status: happydns.ExecutionDone, + } + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/report", nil) + c.Set("execution", exec) + c.Params = gin.Params{{Key: "obsKey", Value: "test_html_obs"}} + + cc.GetExecutionHTMLReport(c) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestGetExecutionHTMLReport_ObservationKeyNotFound(t *testing.T) { + cc, store := newTestControllerWithStorage(&stubCheckerEngine{}) + + target := happydns.CheckTarget{DomainId: "d1"} + exec := seedExecutionWithObservations(t, store, target, map[happydns.ObservationKey]json.RawMessage{ + "test_html_obs": json.RawMessage(`{"v":1}`), + }) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/report", nil) + c.Set("execution", exec) + c.Params = gin.Params{{Key: "obsKey", Value: "nonexistent_key"}} + + cc.GetExecutionHTMLReport(c) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String()) + } +} + +// testNoHTMLObservationProvider is a provider that does NOT implement CheckerHTMLReporter. +type testNoHTMLObservationProvider struct{} + +func (p *testNoHTMLObservationProvider) Key() happydns.ObservationKey { return "test_no_html_obs" } +func (p *testNoHTMLObservationProvider) Collect(ctx context.Context, opts happydns.CheckerOptions) (any, error) { + return map[string]any{"v": 1}, nil +} + +func init() { + checkerPkg.RegisterObservationProvider(&testNoHTMLObservationProvider{}) +} + +func TestGetExecutionHTMLReport_ProviderDoesNotSupportHTML(t *testing.T) { + cc, store := newTestControllerWithStorage(&stubCheckerEngine{}) + + target := happydns.CheckTarget{DomainId: "d1"} + exec := seedExecutionWithObservations(t, store, target, map[happydns.ObservationKey]json.RawMessage{ + "test_no_html_obs": json.RawMessage(`{"v":1}`), + }) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/report", nil) + c.Set("execution", exec) + c.Params = gin.Params{{Key: "obsKey", Value: "test_no_html_obs"}} + + cc.GetExecutionHTMLReport(c) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404 (unsupported), got %d: %s", w.Code, w.Body.String()) + } +} + +func TestGetExecutionHTMLReport_Success(t *testing.T) { + cc, store := newTestControllerWithStorage(&stubCheckerEngine{}) + + target := happydns.CheckTarget{DomainId: "d1"} + exec := seedExecutionWithObservations(t, store, target, map[happydns.ObservationKey]json.RawMessage{ + "test_html_obs": json.RawMessage(`{"v":1}`), + }) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/report", nil) + c.Set("execution", exec) + c.Params = gin.Params{{Key: "obsKey", Value: "test_html_obs"}} + + cc.GetExecutionHTMLReport(c) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + body := w.Body.String() + if body != "test report" { + t.Errorf("unexpected body: %s", body) + } + + ct := w.Header().Get("Content-Type") + if ct != "text/html; charset=utf-8" { + t.Errorf("expected Content-Type text/html, got %q", ct) + } + + csp := w.Header().Get("Content-Security-Policy") + if csp == "" { + t.Error("expected Content-Security-Policy header to be set") + } + + xcto := w.Header().Get("X-Content-Type-Options") + if xcto != "nosniff" { + t.Errorf("expected X-Content-Type-Options nosniff, got %q", xcto) + } +} diff --git a/internal/api/route/checker.go b/internal/api/route/checker.go index bd94f181..ff4984df 100644 --- a/internal/api/route/checker.go +++ b/internal/api/route/checker.go @@ -98,6 +98,7 @@ func DeclareScopedCheckerRoutes(scopedRouter *gin.RouterGroup, cc *controller.Ch // Observations (under execution). executionID.GET("/observations", cc.GetExecutionObservations) executionID.GET("/observations/:obsKey", cc.GetExecutionObservation) + executionID.GET("/observations/:obsKey/report", cc.GetExecutionHTMLReport) // Results (under execution). executionID.GET("/results", cc.GetExecutionResults) diff --git a/web/src/lib/api/checkers.ts b/web/src/lib/api/checkers.ts index ab8d3a1a..ab5eb639 100644 --- a/web/src/lib/api/checkers.ts +++ b/web/src/lib/api/checkers.ts @@ -31,6 +31,7 @@ import { deleteDomainsByDomainCheckersByCheckerIdExecutionsByExecutionId, getDomainsByDomainCheckersByCheckerIdExecutionsByExecutionId, getDomainsByDomainCheckersByCheckerIdExecutionsByExecutionIdObservations, + getDomainsByDomainCheckersByCheckerIdExecutionsByExecutionIdObservationsByObsKeyReport, getDomainsByDomainCheckersByCheckerIdExecutionsByExecutionIdResults, getDomainsByDomainCheckersByCheckerIdOptions, putDomainsByDomainCheckersByCheckerIdOptions, @@ -44,6 +45,7 @@ import { deleteDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionId, getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionId, getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionIdObservations, + getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionIdObservationsByObsKeyReport, getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionIdResults, getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdOptions, putDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdOptions, @@ -368,3 +370,25 @@ export async function updateScopedCheckPlan( }), ) as HappydnsCheckPlan; } + +// HTML report functions + +export async function getScopedExecutionHTMLReport( + scope: CheckerScope, + checkerId: string, + executionId: string, + obsKey: string, +): Promise { + if (isServiceScope(scope)) { + return unwrapSdkResponse( + await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionIdObservationsByObsKeyReport({ + path: { domain: scope.domainId, zoneid: scope.zoneId, subdomain: scope.subdomain, serviceid: scope.serviceId, checkerId, executionId, obsKey }, + }), + ) as string; + } + return unwrapSdkResponse( + await getDomainsByDomainCheckersByCheckerIdExecutionsByExecutionIdObservationsByObsKeyReport({ + path: { domain: scope.domainId, checkerId, executionId, obsKey }, + }), + ) as string; +} diff --git a/web/src/lib/components/checkers/ExecutionDetailPage.svelte b/web/src/lib/components/checkers/ExecutionDetailPage.svelte index 9c04c046..365ec25f 100644 --- a/web/src/lib/components/checkers/ExecutionDetailPage.svelte +++ b/web/src/lib/components/checkers/ExecutionDetailPage.svelte @@ -32,7 +32,7 @@ getScopedExecutionObservations, getCheckStatus, } from "$lib/api/checkers"; - import { currentExecution, currentCheckInfo, currentObservations } from "$lib/stores/checkers"; + import { currentExecution, currentCheckInfo, currentObservations, reportViewMode, cachedHTMLReport } from "$lib/stores/checkers"; import ObservationReportCard from "./ObservationReportCard.svelte"; interface Props { @@ -50,6 +50,7 @@ $effect(() => { loading = true; error = undefined; + cachedHTMLReport.set(null); Promise.all([ getScopedExecution(scope, checkerId, execId), @@ -61,6 +62,17 @@ currentCheckInfo.set(checkerInfo); currentObservations.set(observations); checkerName = checkerInfo.name ?? checkerId; + // Default to metrics view if supported, then HTML, then JSON + if (checkerInfo.has_metrics) { + reportViewMode.set("metrics"); + getScopedExecutionMetrics(scope, checkerId, execId) + .then((m) => (metricsData = m)) + .catch(() => {}); + } else if (checkerInfo.has_html_report) { + reportViewMode.set("html"); + } else { + reportViewMode.set("json"); + } loading = false; }, (err) => { @@ -74,6 +86,8 @@ currentExecution.set(undefined); currentCheckInfo.set(undefined); currentObservations.set(undefined); + reportViewMode.set("json"); + cachedHTMLReport.set(null); }); @@ -98,5 +112,10 @@ {:else if $currentObservations} - + {/if} diff --git a/web/src/lib/components/checkers/ExecutionSidebarContent.svelte b/web/src/lib/components/checkers/ExecutionSidebarContent.svelte index d36253f2..bc71a4cb 100644 --- a/web/src/lib/components/checkers/ExecutionSidebarContent.svelte +++ b/web/src/lib/components/checkers/ExecutionSidebarContent.svelte @@ -34,12 +34,13 @@ } from "@sveltestrap/sveltestrap"; import { navigate } from "$lib/stores/config"; - import { currentExecution, currentCheckInfo, currentObservations } from "$lib/stores/checkers"; + import { currentExecution, currentCheckInfo, currentObservations, reportViewMode, cachedHTMLReport } from "$lib/stores/checkers"; import { toasts } from "$lib/stores/toasts"; import type { CheckerScope } from "$lib/api/checkers"; import { triggerScopedCheck, deleteScopedExecution, + getScopedExecutionHTMLReport, } from "$lib/api/checkers"; import { getExecutionStatusColor, @@ -114,6 +115,20 @@ "application/json", ); } + + async function downloadHTML() { + if (!$currentObservations?.data) return; + const keys = Object.keys($currentObservations.data); + if (keys.length === 0) return; + try { + const html = $cachedHTMLReport ?? await getScopedExecutionHTMLReport(scope, checkerId, execId, keys[0]); + downloadBlob(html, `${checkerId}-${execId}.html`, "text/html"); + } catch (error: any) { + toasts.addErrorToast({ + message: error.message || "Failed to download HTML report", + }); + } + } {#if $currentExecution} @@ -188,27 +203,55 @@
- - - - + {/if} + {#if $currentCheckInfo?.has_html_report} + + {/if} + - - + {#if $currentCheckInfo?.has_html_report} + + {/if}