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.
This commit is contained in:
parent
7a4de13ac6
commit
b37ed1d349
8 changed files with 392 additions and 26 deletions
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 "<html><body>test report</body></html>", 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 != "<html><body>test report</body></html>" {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -98,5 +112,10 @@
|
|||
</Alert>
|
||||
</Container>
|
||||
{:else if $currentObservations}
|
||||
<ObservationReportCard observations={$currentObservations} />
|
||||
<ObservationReportCard
|
||||
observations={$currentObservations}
|
||||
{scope}
|
||||
{checkerId}
|
||||
{execId}
|
||||
/>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $currentExecution}
|
||||
|
|
@ -188,27 +203,55 @@
|
|||
|
||||
<div class="my-3 flex-fill"></div>
|
||||
|
||||
<!-- TODO: Metrics and HTML report not yet implemented -->
|
||||
<ButtonGroup class="w-100 mb-2">
|
||||
<Button size="sm" color="secondary" outline disabled title="Not yet available">
|
||||
<Icon name="graph-up"></Icon>
|
||||
{$t("checkers.result.view-metrics")}
|
||||
</Button>
|
||||
<Button size="sm" color="secondary" outline disabled title="Not yet available">
|
||||
<Icon name="file-earmark-richtext"></Icon>
|
||||
{$t("checkers.result.view-html")}
|
||||
</Button>
|
||||
<Button size="sm" color="secondary" outline active>
|
||||
{#if $currentCheckInfo?.has_metrics}
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
outline
|
||||
active={$reportViewMode === "metrics"}
|
||||
onclick={() => {
|
||||
reportViewMode.set("metrics");
|
||||
}}
|
||||
>
|
||||
<Icon name="graph-up"></Icon>
|
||||
{$t("checkers.result.view-metrics")}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if $currentCheckInfo?.has_html_report}
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
outline
|
||||
active={$reportViewMode === "html"}
|
||||
onclick={() => {
|
||||
reportViewMode.set("html");
|
||||
}}
|
||||
>
|
||||
<Icon name="file-earmark-richtext"></Icon>
|
||||
{$t("checkers.result.view-html")}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
outline
|
||||
active={$reportViewMode === "json"}
|
||||
onclick={() => {
|
||||
reportViewMode.set("json");
|
||||
}}
|
||||
>
|
||||
<Icon name="braces"></Icon>
|
||||
{$t("checkers.result.view-json")}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<ButtonGroup class="w-100">
|
||||
<!-- TODO: HTML report download not yet available -->
|
||||
<Button size="sm" color="outline-secondary" disabled title="Not yet available">
|
||||
<Icon name="download"></Icon>
|
||||
{$t("checkers.result.download-html")}
|
||||
</Button>
|
||||
{#if $currentCheckInfo?.has_html_report}
|
||||
<Button size="sm" color="outline-secondary" onclick={downloadHTML}>
|
||||
<Icon name="download"></Icon>
|
||||
{$t("checkers.result.download-html")}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
size="sm"
|
||||
color="outline-secondary"
|
||||
|
|
|
|||
|
|
@ -22,22 +22,61 @@
|
|||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import type { HappydnsObservationSnapshot } from "$lib/api-base/types.gen";
|
||||
import { Spinner } from "@sveltestrap/sveltestrap";
|
||||
|
||||
import { t } from "$lib/translations";
|
||||
import type { CheckerScope, CheckMetric, ObservationSnapshotWithData } from "$lib/api/checkers";
|
||||
import { getScopedExecutionHTMLReport } from "$lib/api/checkers";
|
||||
import { showHTMLReport, cachedHTMLReport } from "$lib/stores/checkers";
|
||||
|
||||
interface Props {
|
||||
observations: HappydnsObservationSnapshot;
|
||||
observations: ObservationSnapshotWithData;
|
||||
metrics?: CheckMetric[] | null;
|
||||
scope?: CheckerScope;
|
||||
checkerId?: string;
|
||||
execId?: string;
|
||||
}
|
||||
|
||||
let { observations }: Props = $props();
|
||||
let { observations, scope, checkerId, execId }: Props = $props();
|
||||
|
||||
let htmlReportPromise = $state<Promise<string> | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if ($showHTMLReport && scope && checkerId && execId && observations?.data) {
|
||||
const keys = Object.keys(observations.data);
|
||||
if (keys.length > 0) {
|
||||
const promise = getScopedExecutionHTMLReport(scope, checkerId, execId, keys[0]);
|
||||
promise.then((html) => cachedHTMLReport.set(html)).catch(() => {});
|
||||
htmlReportPromise = promise;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if observations?.data && Object.keys(observations.data).length > 0}
|
||||
<div
|
||||
class="flex-fill"
|
||||
class="flex-fill d-flex"
|
||||
style="overflow: auto; padding: var(--bs-card-spacer-y) var(--bs-card-spacer-x)"
|
||||
>
|
||||
<pre class="mb-0" style="width: 0; min-width: 100%"><code
|
||||
>{JSON.stringify(observations.data, null, 2)}</code
|
||||
></pre>
|
||||
{#if $showHTMLReport && htmlReportPromise}
|
||||
{#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="mb-0" style="width: 0; min-width: 100%"><code
|
||||
>{JSON.stringify(observations.data, null, 2)}</code
|
||||
></pre>
|
||||
{/await}
|
||||
{:else}
|
||||
<pre class="mb-0" style="width: 0; min-width: 100%"><code
|
||||
>{JSON.stringify(observations.data, null, 2)}</code
|
||||
></pre>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -44,3 +44,6 @@ export const currentObservations: Writable<ObservationSnapshotWithData | undefin
|
|||
export type ReportViewMode = "json" | "html" | "metrics";
|
||||
export const reportViewMode: Writable<ReportViewMode> = writable("json");
|
||||
export const showHTMLReport: Readable<boolean> = derived(reportViewMode, ($m) => $m === "html");
|
||||
|
||||
// Cached HTML report content, shared between the report card and sidebar download button.
|
||||
export const cachedHTMLReport: Writable<string | null> = writable(null);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue