web: Add frontend for domain tests browsing and execution

Add test API client, data models, Svelte store, and pages to list
available tests per domain, view results, and trigger test runs via a
dedicated modal. Also refactor plugins page to use a shared store.
This commit is contained in:
nemunaire 2026-02-12 12:01:25 +07:00
commit 0afd77151c
19 changed files with 1850 additions and 35 deletions

View file

@ -126,7 +126,7 @@ func (tc *CheckResultController) ListAvailableChecks(c *gin.Context) {
info := happydns.CheckerStatus{
CheckerName: checkername,
Enabled: false,
Enabled: true, // enabled by default unless explicitly disabled via a schedule
}
// Check if there's a schedule

View file

@ -131,6 +131,7 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, captchaVerifi
router.GET("/service-worker.js", serveFile)
// Routes to virtual content
router.GET("/checks/*_", serveIndex)
router.GET("/domains/*_", serveIndex)
router.GET("/email-validation", serveIndex)
router.GET("/forgotten-password", serveIndex)

View file

@ -0,0 +1,204 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-2026 happyDomain
Authors: Pierre-Olivier Mercier, et al.
This program is offered under a commercial and under the AGPL license.
For commercial licensing, contact us at <contact@happydomain.org>.
For AGPL licensing:
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import {
Alert,
Button,
Form,
FormGroup,
Icon,
Modal,
ModalBody,
ModalFooter,
ModalHeader,
Spinner,
} from "@sveltestrap/sveltestrap";
import { triggerCheck, getDomainCheckOptions, getCheckStatus } from "$lib/api/checks";
import type { CheckerOptions } from "$lib/model/check";
import Resource from "$lib/components/inputs/Resource.svelte";
import { toasts } from "$lib/stores/toasts";
import { t } from "$lib/translations";
interface Props {
domainId: string;
onCheckTriggered?: (execution_id: string, checker_name: string) => void;
}
let { domainId, onCheckTriggered }: Props = $props();
let isOpen = $state(false);
let checkName = $state<string>("");
let checkDisplayName = $state<string>("");
let checkStatusPromise = $state<Promise<any> | null>(null);
let domainOptionsPromise = $state<Promise<CheckerOptions> | null>(null);
let runOptions = $state<Record<string, any>>({});
let triggering = $state(false);
let showAdvanced = $state(false);
const toggle = () => (isOpen = !isOpen);
export function open(name: string, displayName: string) {
checkName = name;
checkDisplayName = displayName;
runOptions = {};
checkStatusPromise = getCheckStatus(name);
domainOptionsPromise = getDomainCheckOptions(domainId, name);
isOpen = true;
// Pre-populate with domain options when they load
domainOptionsPromise.then((options) => {
runOptions = { ...(options || {}) };
});
}
async function handleRunCheck() {
triggering = true;
try {
const result = await triggerCheck(domainId, checkName, runOptions);
toasts.addToast({
message: $t("checks.run-check.triggered-success", { id: result.execution_id }),
type: "success",
timeout: 5000,
});
isOpen = false;
if (onCheckTriggered && result.execution_id) {
onCheckTriggered(result.execution_id, checkName);
}
} catch (error) {
toasts.addErrorToast({
message: $t("checks.run-check.trigger-failed", { error: String(error) }),
timeout: 10000,
});
} finally {
triggering = false;
}
}
</script>
<Modal {isOpen} {toggle} size="lg">
<ModalHeader {toggle}>
{$t("checks.run-check.title")}: {checkDisplayName}
</ModalHeader>
<ModalBody>
{#if checkStatusPromise && domainOptionsPromise}
{#await Promise.all([checkStatusPromise, domainOptionsPromise])}
<div class="text-center py-3">
<Spinner />
<p class="mt-2">{$t("checks.run-check.loading-options")}</p>
</div>
{:then [status, _domainOpts]}
{@const runOpts = status.options?.runOpts || []}
{#if runOpts.length > 0}
<p>
{$t("checks.run-check.configure-info")}
</p>
<Form
id="run-test-modal"
on:submit={(e) => {
e.preventDefault();
handleRunCheck();
}}
>
{#each runOpts as optDoc}
{#if optDoc.id}
{@const optName = optDoc.id}
<FormGroup>
<Resource
edit={true}
index={optName}
specs={optDoc}
type={optDoc.type || "string"}
readonly={!!optDoc.autoFill}
bind:value={runOptions[optName]}
/>
</FormGroup>
{/if}
{/each}
{@const otherOpts = [
...(status.options?.adminOpts || []),
...(status.options?.userOpts || []),
...(status.options?.domainOpts || []),
...(status.options?.serviceOpts || []),
].filter((o) => o.id)}
{#if otherOpts.length > 0}
<button
type="button"
class="btn btn-link btn-sm px-0 mb-2 text-muted d-flex align-items-center gap-1 text-decoration-none"
onclick={() => (showAdvanced = !showAdvanced)}
>
<Icon name={showAdvanced ? "chevron-down" : "chevron-right"} />
{$t("checks.run-check.advanced-options")}
</button>
{#if showAdvanced}
{#each otherOpts as optDoc}
{@const optName = optDoc.id}
<FormGroup>
<Resource
edit={true}
index={optName}
specs={optDoc}
type={optDoc.type || "string"}
readonly={true}
bind:value={runOptions[optName]}
/>
</FormGroup>
{/each}
{/if}
{/if}
</Form>
{:else}
<Alert color="info" class="mb-0">
<Icon name="info-circle"></Icon>
{$t("checks.run-check.no-options")}
</Alert>
{/if}
{:catch error}
<Alert color="danger">
<Icon name="exclamation-triangle-fill"></Icon>
{$t("checks.run-check.error-loading-options", { error: error.message })}
</Alert>
{/await}
{/if}
</ModalBody>
<ModalFooter>
<Button type="button" color="secondary" onclick={toggle} disabled={triggering}>
{$t("common.cancel")}
</Button>
<Button
type="submit"
form="run-test-modal"
color="primary"
onclick={handleRunCheck}
disabled={triggering}
>
{#if triggering}
<Spinner size="sm" class="me-1" />
{:else}
<Icon name="play-fill"></Icon>
{/if}
{$t("checks.run-check.run-button")}
</Button>
</ModalFooter>
</Modal>

View file

@ -82,6 +82,7 @@
"share": "Share the zone…",
"upload": "Import a zone file",
"view": "View my zone",
"view-checks": "View checks",
"others": "More actions on {{domain}}"
},
"alert": {
@ -540,6 +541,131 @@
"showDNSSEC": "Show DNSSEC records in answer (if any)"
},
"checks": {
"run-check": {
"title": "Run Check",
"loading-options": "Loading checker options...",
"configure-info": "Configure checker options below. Pre-filled values are from domain-level settings.",
"no-options": "This check has no configurable options. Click \"Run Check\" to execute with default settings.",
"error-loading-options": "Error loading checker options: {{error}}",
"run-button": "Run Check",
"triggered-success": "Check triggered successfully! Execution ID: {{id}}",
"trigger-failed": "Failed to trigger check: {{error}}",
"advanced-options": "Advanced options"
},
"never": "Never",
"na": "N/A",
"relative": {
"in-less-than-a-minute": "in less than a minute",
"just-now": "just now",
"in": "in {{label}}",
"ago": "{{label}} ago"
},
"status": {
"ok": "OK",
"info": "Info",
"warning": "Warning",
"error": "Error",
"unknown": "Unknown",
"pending": "Pending",
"not-run": "Not run"
},
"list": {
"title": "Checks for ",
"loading": "Loading checkers...",
"loading-checkers": "Loading checker information...",
"no-checks": "No checks available for this domain.",
"run-check": "Run Check",
"view-results": "View Results",
"configure": "Configure",
"error-loading": "Error loading checkers: {{error}}",
"unknown-version": "Unknown",
"table": {
"checker": "Checker",
"status": "Status",
"last-run": "Last Run",
"schedule": "Schedule",
"actions": "Actions"
},
"schedule": {
"enabled": "Enabled",
"disabled": "Disabled"
}
},
"schedule": {
"title": "Schedule",
"card-title": "Automatic scheduling",
"auto-enabled": "Run automatically",
"auto-disabled": "Disabled (run manually only)",
"interval-label": "Check interval",
"hours": "hours",
"interval-hint": "Minimum 1 hour. The check will run once per interval.",
"next-run": "Next scheduled run",
"last-run": "Last run",
"no-schedule-yet": "No schedule created yet. Save to create one.",
"save": "Save",
"save-failed": "Failed to save schedule",
"saved": "Schedule saved successfully."
},
"results": {
"loading": "Loading check results...",
"no-results": "No check results yet. Click \"Run Check Now\" to execute the check.",
"title": "Check Results ({{count}})",
"run-check-now": "Run Check Now",
"back-to-checks": "Back to checks",
"delete-all": "Delete All",
"delete-confirm": "Are you sure you want to delete this check result?",
"delete-all-confirm": "Are you sure you want to delete ALL check results for this check? This cannot be undone.",
"delete-failed": "Failed to delete result",
"delete-all-failed": "Failed to delete results",
"configure": "Configure",
"domain-level": "Domain-level",
"error-loading": "Error loading check results: {{error}}",
"table": {
"executed-at": "Executed At",
"status": "Status",
"message": "Message",
"duration": "Duration",
"type": "Type",
"actions": "Actions"
},
"type": {
"scheduled": "Scheduled",
"manual": "Manual"
},
"pending": {
"queued": "Queued",
"queued-description": "Queued, waiting to run…",
"running": "Running",
"running-description": "Check is currently running…"
},
"view": "View"
},
"result": {
"title": "Check Result Details",
"loading": "Loading check result...",
"relaunch": "Relaunch Check",
"delete": "Delete Result",
"relaunch-failed": "Failed to relaunch check",
"delete-confirm": "Are you sure you want to delete this check result?",
"delete-failed": "Failed to delete result",
"error-loading": "Error loading check result: {{error}}",
"milliseconds": "milliseconds",
"seconds": "seconds",
"type": {
"scheduled": "Scheduled Check",
"manual": "Manual Check"
},
"check-options": "Check Options",
"full-report": "Full Report",
"field": {
"domain": "Domain:",
"executed-at": "Executed At:",
"duration": "Duration:",
"status": "Status:",
"status-message": "Status Message:",
"error": "Error:"
}
},
"title": "Domain Checkers",
"description": "Configure automated checks for your domains",
"available-count": "Available: {{count}} checks",
@ -600,6 +726,8 @@
"upload": "Import a zone",
"import-text": "Import from text",
"import-file": "Import from file",
"return-to": "Go to the zone"
"return-to": "Go to the zone",
"return-to-results": "Back to results",
"return-to-checks": "Back to checks"
}
}

View file

@ -0,0 +1,76 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2022-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import type {
HappydnsCheckerAvailability,
HappydnsCheckerOptionDocumentation,
HappydnsCheckerOptionsDocumentation,
HappydnsCheckerOptions,
HappydnsCheckerResponse,
HappydnsCheckerSchedule,
HappydnsCheckResult,
HappydnsCheckExecution,
} from "$lib/api-base/types.gen";
// Re-export auto-generated types with better names
export type CheckerAvailability = HappydnsCheckerAvailability;
export type CheckerInfo = HappydnsCheckerResponse;
export type CheckerList = { [key: string]: HappydnsCheckerResponse };
export type CheckerOptions = HappydnsCheckerOptions;
export type CheckerOptionsDocumentation = HappydnsCheckerOptionsDocumentation;
export type CheckerSchedule = HappydnsCheckerSchedule;
export type CheckResult = HappydnsCheckResult;
export type CheckExecution = HappydnsCheckExecution;
// Make 'id' required for CheckerOptionDocumentation
export interface CheckerOptionDocumentation extends Omit<HappydnsCheckerOptionDocumentation, "id"> {
id: string;
}
// Enums for named access to numeric status/scope values
export enum CheckResultStatus {
KO = 0,
Warn = 1,
Info = 2,
OK = 3,
}
export enum CheckScopeType {
CheckScopeInstance = 0,
CheckScopeUser = 1,
CheckScopeDomain = 2,
CheckScopeService = 3,
CheckScopeOnDemand = 4,
}
export enum CheckExecutionStatus {
CheckExecutionPending = 0,
CheckExecutionRunning = 1,
CheckExecutionCompleted = 2,
CheckExecutionFailed = 3,
}
export interface AvailableCheck {
checker_name: string;
enabled: boolean;
schedule?: CheckerSchedule;
last_result?: CheckResult;
}

106
web/src/lib/model/test.ts Normal file
View file

@ -0,0 +1,106 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2022-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import type { CheckerOptions } from "./check";
export enum TestScopeType {
TestScopeInstance = 0,
TestScopeUser = 1,
TestScopeDomain = 2,
TestScopeZone = 3,
TestScopeService = 4,
TestScopeOnDemand = 5,
}
export enum TestExecutionStatus {
TestExecutionPending = 0,
TestExecutionRunning = 1,
TestExecutionCompleted = 2,
TestExecutionFailed = 3,
}
export enum PluginResultStatus {
KO = 0,
Warn = 1,
Info = 2,
OK = 3,
}
export interface TestResult {
id: string;
plugin_name: string;
test_type: TestScopeType;
target_id: string;
user_id: string;
executed_at: string;
scheduled_test: boolean;
options?: CheckerOptions;
status: PluginResultStatus;
status_line: string;
report?: any;
duration?: number;
error?: string;
}
export interface TestSchedule {
id: string;
plugin_name: string;
user_id: string;
target_type: TestScopeType;
target_id: string;
interval: number;
enabled: boolean;
last_run?: string;
next_run: string;
options?: CheckerOptions;
}
export interface TestExecution {
id: string;
schedule_id?: string;
plugin_name: string;
user_id: string;
target_id: string;
status: TestExecutionStatus;
started_at: string;
completed_at?: string;
result_id?: string;
}
export interface AvailableTest {
plugin_name: string;
enabled: boolean;
schedule?: TestSchedule;
last_result?: TestResult;
}
export interface TriggerTestRequest {
options?: CheckerOptions;
}
export interface CreateScheduleRequest {
plugin_name: string;
target_type: TestScopeType;
target_id: string;
interval: number;
enabled: boolean;
options?: CheckerOptions;
}

View file

@ -0,0 +1,32 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2022-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { listChecks } from "$lib/api/checks";
import type { CheckerList } from "$lib/model/check";
import { writable, type Writable } from "svelte/store";
export const checks: Writable<CheckerList | undefined> = writable(undefined);
export async function refreshChecks() {
const data = await listChecks();
checks.set(data);
return data;
}

View file

@ -40,6 +40,11 @@ interface Params {
max?: number;
suggestion?: string;
key?: string;
error?: string;
providers?: string;
services?: string;
options?: string;
label?: string;
// add more parameters that are used here
}

View file

@ -0,0 +1,39 @@
import type { HappydnsCheckResultStatus } from "$lib/api-base/types.gen";
import { CheckResultStatus } from "$lib/model/check";
export function getStatusColor(status: CheckResultStatus | HappydnsCheckResultStatus | undefined): string {
switch (status) {
case CheckResultStatus.OK:
return "success";
case CheckResultStatus.Info:
return "info";
case CheckResultStatus.Warn:
return "warning";
case CheckResultStatus.KO:
return "danger";
default:
return "secondary";
}
}
export function getStatusKey(status: CheckResultStatus | HappydnsCheckResultStatus | undefined): string {
switch (status) {
case CheckResultStatus.OK:
return "checks.status.ok";
case CheckResultStatus.Info:
return "checks.status.info";
case CheckResultStatus.Warn:
return "checks.status.warning";
case CheckResultStatus.KO:
return "checks.status.error";
default:
return "checks.status.unknown";
}
}
export function formatDuration(duration: number | undefined, t: (k: string) => string): string {
if (!duration) return t("checks.na");
const seconds = duration / 1000000000;
if (seconds < 1) return `${(seconds * 1000).toFixed(0)} ${t("checks.result.milliseconds")}`;
return `${seconds.toFixed(2)} ${t("checks.result.seconds")}`;
}

View file

@ -31,3 +31,61 @@ export function fromDatetimeLocal(datetimeLocal: string): string | null {
return null;
}
}
/**
* Format a date string for display in check UI
* @param dateString ISO date string or undefined
* @param style Display style: "short", "medium", or "long"
* @param t i18n translation function
* @returns Formatted date string, or $t("checks.never") if undefined/invalid
*/
export function formatCheckDate(
dateString: string | undefined,
style: "short" | "medium" | "long",
t: (k: string) => string,
): string {
if (!dateString) return t("checks.never");
const d = new Date(dateString);
if (isNaN(d.getTime())) return t("checks.never");
return new Intl.DateTimeFormat(undefined, {
dateStyle: style,
timeStyle: "short",
}).format(d);
}
/**
* Format a date string as a relative time (e.g. "in 3h 20m" or "5m ago")
* @param dateString ISO date string or undefined
* @param t i18n translation function
* @returns Relative time string, or empty string if undefined/invalid
*/
export function formatRelative(dateString: string | undefined, t: (k: string, params?: Record<string, string>) => string): string {
if (!dateString) return "";
const d = new Date(dateString);
if (isNaN(d.getTime())) return "";
const now = new Date();
const diffMs = d.getTime() - now.getTime();
const absDiffMs = Math.abs(diffMs);
if (absDiffMs < 60_000)
return diffMs > 0
? t("checks.relative.in-less-than-a-minute")
: t("checks.relative.just-now");
const minutes = Math.floor(absDiffMs / 60_000);
const hours = Math.floor(absDiffMs / 3_600_000);
const days = Math.floor(absDiffMs / 86_400_000);
let label: string;
if (days > 0) {
label = `${days}d ${hours % 24}h`;
} else if (hours > 0) {
label = `${hours}h ${minutes % 60}m`;
} else {
label = `${minutes}m`;
}
return diffMs > 0
? t("checks.relative.in", { label })
: t("checks.relative.ago", { label });
}

View file

@ -2,4 +2,5 @@
* Centralized utility exports
*/
export { toDatetimeLocal, fromDatetimeLocal } from './datetime';
export { toDatetimeLocal, fromDatetimeLocal, formatCheckDate, formatRelative } from "./datetime";
export { getStatusColor, getStatusKey, formatDuration } from "./check";

View file

@ -36,11 +36,16 @@
} from "@sveltestrap/sveltestrap";
import { t } from "$lib/translations";
import { listChecks } from "$lib/api/checks";
let checksPromise = $state(listChecks());
import { checks, refreshChecks } from "$lib/stores/checks";
let searchQuery = $state("");
// Load checks if not already loaded
$effect(() => {
if ($checks === undefined) {
refreshChecks();
}
});
</script>
<svelte:head>
@ -58,13 +63,13 @@
<span class="lead">
{$t("checks.description")}
</span>
{#await checksPromise then checkers}
<span
>{$t("checks.available-count", {
count: Object.keys(checkers ?? {}).length,
})}</span
>
{/await}
{#if $checks}
<span>
{$t("checks.available-count", {
count: Object.keys($checks).length,
})}
</span>
{/if}
</p>
</Col>
</Row>
@ -84,14 +89,14 @@
</Col>
</Row>
{#await checksPromise}
{#if !$checks}
<Card body>
<p class="text-center mb-0">
<span class="spinner-border spinner-border-sm me-2"></span>
{$t("checks.loading")}
</p>
</Card>
{:then checks}
{:else}
<div class="table-responsive">
<Table hover bordered>
<thead>
@ -102,39 +107,39 @@
</tr>
</thead>
<tbody>
{#if !checks || Object.keys(checks).length == 0}
{#if Object.keys($checks).length == 0}
<tr>
<td colspan="4" class="text-center text-muted py-4">
{$t("checks.no-checkers")}
</td>
</tr>
{:else}
{#each Object.entries(checks ?? {}).filter(([name, _info]) => name
{#each Object.entries($checks).filter(([name, _info]) => name
.toLowerCase()
.indexOf(searchQuery.toLowerCase()) > -1) as [checkerName, checkerInfo]}
<tr>
<td><strong>{checkerInfo.name || checkerName}</strong></td>
<td>
{#if checkerInfo.availableOn}
{#if checkerInfo.availableOn.applyToDomain}
{#if checkerInfo.availability}
{#if checkerInfo.availability.applyToDomain}
<Badge color="success"
>{$t("checks.availability.domain")}</Badge
>
{/if}
{#if checkerInfo.availableOn.limitToProviders && checkerInfo.availableOn.limitToProviders.length > 0}
{#if checkerInfo.availability.limitToProviders && checkerInfo.availability.limitToProviders.length > 0}
<Badge
color="primary"
title={checkerInfo.availableOn.limitToProviders.join(
title={checkerInfo.availability.limitToProviders.join(
", ",
)}
>
{$t("checks.availability.provider-specific")}
</Badge>
{/if}
{#if checkerInfo.availableOn.limitToServices && checkerInfo.availableOn.limitToServices.length > 0}
{#if checkerInfo.availability.limitToServices && checkerInfo.availability.limitToServices.length > 0}
<Badge
color="info"
title={checkerInfo.availableOn.limitToServices.join(
title={checkerInfo.availability.limitToServices.join(
", ",
)}
>
@ -159,12 +164,5 @@
</tbody>
</Table>
</div>
{:catch error}
<Card body color="danger">
<p class="mb-0">
<Icon name="exclamation-triangle-fill"></Icon>
{$t("checks.error-loading", { error: error.message })}
</p>
</Card>
{/await}
{/if}
</Container>

View file

@ -106,8 +106,15 @@
}
}
function getOrphanedOptions(userOpts: any[]): string[] {
function getOrphanedOptions(userOpts: any[], readOnlyOptGroups: any[]): string[] {
const validOptIds = new Set(userOpts.map((opt) => opt.id));
for (const group of readOnlyOptGroups) {
for (const opt of group.opts) {
validOptIds.add(opt.id);
}
}
return Object.keys(optionValues).filter((key) => !validOptIds.has(key));
}
</script>
@ -232,7 +239,7 @@
]}
{@const hasAnyOpts =
userOpts.length > 0 || readOnlyOptGroups.some((g) => g.opts.length > 0)}
{@const orphanedOpts = getOrphanedOptions(userOpts)}
{@const orphanedOpts = getOrphanedOptions(userOpts, readOnlyOptGroups)}
{#if orphanedOpts.length > 0}
<Alert color="warning" class="mb-3">

View file

@ -82,7 +82,11 @@
? "/logs"
: page.route.id.startsWith("/domains/[dn]/history")
? "/history"
: ""
: page.route.id.startsWith("/domains/[dn]/checks/[cname]")
? `/checks/${page.params.cname!}`
: page.route.id.startsWith("/domains/[dn]/checks")
? "/checks"
: ""
: ""),
);
}
@ -173,7 +177,35 @@
<SelectDomain bind:selectedDomain />
</div>
{#if page.route.id && (page.route.id.startsWith("/domains/[dn]/history") || page.route.id.startsWith("/domains/[dn]/logs"))}
{#if page.route.id && page.route.id.startsWith("/domains/[dn]/checks/[cname]")}
{#if page.route.id.startsWith("/domains/[dn]/checks/[cname]/results/")}
<Button
class="mt-2"
outline
color="primary"
href={"/domains/" +
encodeURIComponent(domainLink(selectedDomain)) +
"/checks/" +
encodeURIComponent(page.params.cname!) +
"/results"}
>
<Icon name="chevron-left" />
{$t("zones.return-to-results")}
</Button>
{:else}
<Button
class="mt-2"
outline
color="primary"
href={"/domains/" +
encodeURIComponent(domainLink(selectedDomain)) +
"/checks"}
>
<Icon name="chevron-left" />
{$t("zones.return-to-checks")}
</Button>
{/if}
{:else if page.route.id && (page.route.id.startsWith("/domains/[dn]/history") || page.route.id.startsWith("/domains/[dn]/logs") || page.route.id.startsWith("/domains/[dn]/checks"))}
<Button
class="mt-2"
outline
@ -227,6 +259,9 @@
<DropdownItem href={`/domains/${domainLink(selectedDomain)}/logs`}>
{$t("domains.actions.audit")}
</DropdownItem>
<DropdownItem href={`/domains/${domainLink(selectedDomain)}/checks`}>
{$t("domains.actions.view-checks")}
</DropdownItem>
<DropdownItem divider />
<DropdownItem on:click={viewZone} disabled={!$sortedDomains}>
{$t("domains.actions.view")}

View file

@ -0,0 +1,17 @@
import { type Load } from "@sveltejs/kit";
import { checks, refreshChecks } from "$lib/stores/checks";
import { get } from "svelte/store";
export const load: Load = async ({ parent }) => {
const data = await parent();
if (get(checks) === undefined) {
refreshChecks();
}
return {
...data,
isTestsPage: true,
};
};

View file

@ -0,0 +1,217 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-2026 happyDomain
Authors: Pierre-Olivier Mercier, et al.
This program is offered under a commercial and under the AGPL license.
For commercial licensing, contact us at <contact@happydomain.org>.
For AGPL licensing:
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import { navigate } from "$lib/stores/config";
import { page } from "$app/state";
import { Card, Icon, Table, Badge, Button, Spinner } from "@sveltestrap/sveltestrap";
import { t } from "$lib/translations";
import { listAvailableChecks, updateCheckSchedule, createCheckSchedule } from "$lib/api/checks";
import type { Domain } from "$lib/model/domain";
import { CheckScopeType, type AvailableCheck } from "$lib/model/check";
import { checks } from "$lib/stores/checks";
import { toasts } from "$lib/stores/toasts";
import RunCheckModal from "$lib/components/modals/RunCheckModal.svelte";
import { getStatusColor, getStatusKey, formatCheckDate } from "$lib/utils";
interface Props {
data: { domain: Domain };
}
let { data }: Props = $props();
let checksPromise = $derived(listAvailableChecks(data.domain.id));
let runCheckModal: RunCheckModal;
let togglingChecks = $state(new Set<string>());
function handleCheckTriggered(_: string, checkName: string) {
// Refresh the check list to show updated status
checksPromise = listAvailableChecks(data.domain.id);
navigate(`/domains/${page.params.dn!}/checks/${checkName}/results`);
}
async function handleToggleEnabled(check: AvailableCheck) {
const next = new Set(togglingChecks);
next.add(check.checker_name);
togglingChecks = next;
try {
const newEnabled = !check.enabled;
if (check.schedule) {
await updateCheckSchedule(check.schedule.id!, {
...check.schedule,
enabled: newEnabled,
});
} else {
// No schedule record yet — create one to persist the disabled state.
// (Enabled → Enabled needs no action since that's the implicit default.)
await createCheckSchedule({
checker_name: check.checker_name,
target_type: CheckScopeType.CheckScopeDomain,
target_id: data.domain.id,
interval: 0,
enabled: newEnabled,
});
}
checksPromise = listAvailableChecks(data.domain.id);
} catch (e: any) {
toasts.addErrorToast({ title: $t("checks.list.error-loading", { error: e.message }) });
} finally {
const after = new Set(togglingChecks);
after.delete(check.checker_name);
togglingChecks = after;
}
}
</script>
<svelte:head>
<title>Checks - {data.domain.domain} - happyDomain</title>
</svelte:head>
<div class="flex-fill pb-4 pt-2">
<h2>
{$t("checks.list.title")}<span class="font-monospace">{data.domain.domain}</span>
</h2>
{#await checksPromise}
<div class="mt-5 text-center flex-fill">
<Spinner />
<p>{$t("checks.list.loading")}</p>
</div>
{:then availableChecks}
{#if !$checks}
<div class="mt-5 text-center flex-fill">
<Spinner />
<p>{$t("checks.list.loading-checks")}</p>
</div>
{:else if !availableChecks || availableChecks.length === 0}
<Card body class="mt-3">
<p class="text-center text-muted mb-0">
<Icon name="info-circle"></Icon>
{$t("checks.list.no-checks")}
</p>
</Card>
{:else}
<Table hover striped class="mt-3">
<thead>
<tr>
<th>{$t("checks.list.table.checker")}</th>
<th>{$t("checks.list.table.status")}</th>
<th>{$t("checks.list.table.last-run")}</th>
<th>{$t("checks.list.table.schedule")}</th>
<th>{$t("checks.list.table.actions")}</th>
</tr>
</thead>
<tbody>
{#each availableChecks as check}
{@const checkInfo = $checks[check.checker_name]}
<tr>
<td class="align-middle">
<strong>{checkInfo?.name || check.checker_name}</strong>
</td>
<td class="align-middle text-center">
{#if check.last_result !== undefined}
<Badge color={getStatusColor(check.last_result.status)}>
{$t(getStatusKey(check.last_result.status))}
</Badge>
{:else}
<Badge color="secondary">{$t("checks.status.not-run")}</Badge>
{/if}
</td>
<td class="align-middle">
{formatCheckDate(check.last_result?.executed_at, "short", $t)}
</td>
<td class="align-middle">
<div class="form-check form-switch mb-0">
<input
class="form-check-input"
type="checkbox"
role="switch"
id="toggle-{check.checker_name}"
checked={check.enabled}
disabled={togglingChecks.has(check.checker_name)}
onchange={() => handleToggleEnabled(check)}
/>
<label
class="form-check-label small"
for="toggle-{check.checker_name}"
>
{check.enabled
? $t("checks.list.schedule.enabled")
: $t("checks.list.schedule.disabled")}
</label>
</div>
</td>
<td class="align-middle">
<div class="d-flex gap-2">
<Button
size="sm"
color="primary"
onclick={() =>
runCheckModal.open(
check.checker_name,
checkInfo?.name || check.checker_name,
)}
>
<Icon name="play-fill"></Icon>
{$t("checks.list.run-check")}
</Button>
<Button
size="sm"
color="info"
href={`/domains/${encodeURIComponent(data.domain.domain)}/checks/${encodeURIComponent(check.checker_name)}/results`}
>
<Icon name="bar-chart-fill"></Icon>
{$t("checks.list.view-results")}
</Button>
<Button
size="sm"
color="dark"
href={`/domains/${encodeURIComponent(data.domain.domain)}/checks/${encodeURIComponent(check.checker_name)}`}
title={$t("checks.list.configure")}
>
<Icon name="gear"></Icon>
</Button>
</div>
</td>
</tr>
{/each}
</tbody>
</Table>
{/if}
{:catch error}
<Card body color="danger" class="mt-3">
<p class="mb-0">
<Icon name="exclamation-triangle-fill"></Icon>
{$t("checks.list.error-loading", { error: error.message })}
</p>
</Card>
{/await}
</div>
<RunCheckModal
domainId={data.domain.id}
onCheckTriggered={handleCheckTriggered}
bind:this={runCheckModal}
/>

View file

@ -0,0 +1,266 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-2026 happyDomain
Authors: Pierre-Olivier Mercier, et al.
This program is offered under a commercial and under the AGPL license.
For commercial licensing, contact us at <contact@happydomain.org>.
For AGPL licensing:
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import { page } from "$app/state";
import {
Badge,
Button,
Card,
CardBody,
CardHeader,
Icon,
Input,
Spinner,
} from "@sveltestrap/sveltestrap";
import { t } from "$lib/translations";
import { listAvailableChecks, updateCheckSchedule, createCheckSchedule } from "$lib/api/checks";
import type { Domain } from "$lib/model/domain";
import { CheckScopeType, type AvailableCheck } from "$lib/model/check";
import { checks } from "$lib/stores/checks";
import { toasts } from "$lib/stores/toasts";
import { formatCheckDate, formatRelative } from "$lib/utils";
interface Props {
data: { domain: Domain };
}
let { data }: Props = $props();
const checkName = $derived(page.params.cname || "");
const checkDisplayName = $derived($checks?.[checkName]?.name || checkName);
// Resolved check data
let check = $state<AvailableCheck | null>(null);
let loading = $state(true);
let loadError = $state<string | null>(null);
// Form state
let formEnabled = $state(true);
let formIntervalHours = $state(24);
let saving = $state(false);
async function loadCheck() {
loading = true;
loadError = null;
try {
const checks = await listAvailableChecks(data.domain.id);
const found = checks?.find((c) => c.checker_name === checkName) ?? null;
check = found;
if (found) {
formEnabled = found.enabled;
formIntervalHours =
found.schedule && found.schedule.interval !== undefined && found.schedule.interval > 0
? found.schedule.interval / (3600 * 1e9)
: 24;
}
} catch (e: any) {
loadError = e.message;
} finally {
loading = false;
}
}
loadCheck();
async function handleSave() {
if (!check) return;
saving = true;
try {
const intervalNs = Math.max(formIntervalHours, 1) * 3600 * 1e9;
if (check.schedule) {
await updateCheckSchedule(check.schedule.id!, {
...check.schedule,
enabled: formEnabled,
interval: intervalNs,
});
} else {
await createCheckSchedule({
checker_name: check.checker_name,
target_type: CheckScopeType.CheckScopeDomain,
target_id: data.domain.id,
interval: intervalNs,
enabled: formEnabled,
});
}
toasts.addToast({ title: $t("checks.schedule.saved"), type: "success", timeout: 3000 });
await loadCheck();
} catch (e: any) {
toasts.addErrorToast({ title: $t("checks.schedule.save-failed"), message: e.message });
} finally {
saving = false;
}
}
</script>
<svelte:head>
<title>
{checkName} - {$t("checks.schedule.title")} - {data.domain.domain} - happyDomain
</title>
</svelte:head>
<div class="flex-fill pb-4 pt-2">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>
<span class="font-monospace">{data.domain.domain}</span>
&ndash;
{checkDisplayName}
&ndash; {$t("checks.schedule.title")}
</h2>
<div class="d-flex gap-2">
<Button
color="info"
href={`/domains/${encodeURIComponent(data.domain.domain)}/checks/${encodeURIComponent(checkName)}/results`}
>
<Icon name="bar-chart-fill"></Icon>
{$t("checks.list.view-results")}
</Button>
</div>
</div>
{#if loading}
<div class="mt-5 text-center flex-fill">
<Spinner />
<p>{$t("checks.list.loading")}</p>
</div>
{:else if loadError}
<Card body color="danger">
<p class="mb-0">
<Icon name="exclamation-triangle-fill"></Icon>
{$t("checks.list.error-loading", { error: loadError })}
</p>
</Card>
{:else if !check}
<Card body>
<p class="text-center text-muted mb-0">
<Icon name="info-circle"></Icon>
{$t("checks.list.no-checks")}
</p>
</Card>
{:else}
<Card class="mb-4">
<CardHeader>
<h4 class="mb-0">
<Icon name="clock-history"></Icon>
{$t("checks.schedule.card-title")}
</h4>
</CardHeader>
<CardBody>
<div class="mb-4">
<div class="form-check form-switch">
<input
class="form-check-input"
type="checkbox"
role="switch"
id="schedule-enabled"
bind:checked={formEnabled}
disabled={saving}
/>
<label class="form-check-label" for="schedule-enabled">
{#if formEnabled}
<Badge color="success">{$t("checks.schedule.auto-enabled")}</Badge>
{:else}
<Badge color="secondary">{$t("checks.schedule.auto-disabled")}</Badge
>
{/if}
</label>
</div>
</div>
{#if formEnabled}
<div class="mb-4">
<label for="schedule-interval" class="form-label fw-semibold">
{$t("checks.schedule.interval-label")}
</label>
<div class="input-group" style="max-width: 300px;">
<Input
type="number"
id="schedule-interval"
min={1}
step={1}
bind:value={formIntervalHours}
disabled={saving}
/>
<span class="input-group-text">
{$t("checks.schedule.hours")}
</span>
</div>
<div class="form-text">
{$t("checks.schedule.interval-hint")}
</div>
</div>
{/if}
{#if check.schedule}
<div class="mb-4">
<div class="row g-3">
{#if check.schedule.last_run}
<div class="col-auto">
<span class="text-muted fw-semibold">
{$t("checks.schedule.last-run")}:
</span>
<span>
{formatCheckDate(check.schedule.last_run, "medium", $t)}
<small class="text-muted">
({formatRelative(check.schedule.last_run, $t)})
</small>
</span>
</div>
{/if}
{#if check.enabled && check.schedule.next_run}
<div class="col-auto">
<span class="text-muted fw-semibold">
{$t("checks.schedule.next-run")}:
</span>
<span>
{formatCheckDate(check.schedule.next_run, "medium", $t)}
<small class="text-muted">
({formatRelative(check.schedule.next_run, $t)})
</small>
</span>
</div>
{/if}
</div>
</div>
{:else}
<p class="text-muted">
<Icon name="info-circle"></Icon>
{$t("checks.schedule.no-schedule-yet")}
</p>
{/if}
<Button color="primary" disabled={saving} onclick={handleSave}>
{#if saving}
<Spinner size="sm" class="me-1" />
{/if}
<Icon name="check-lg"></Icon>
{$t("checks.schedule.save")}
</Button>
</CardBody>
</Card>
{/if}
</div>

View file

@ -0,0 +1,320 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-2026 happyDomain
Authors: Pierre-Olivier Mercier, et al.
This program is offered under a commercial and under the AGPL license.
For commercial licensing, contact us at <contact@happydomain.org>.
For AGPL licensing:
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import {
Card,
Alert,
Icon,
Table,
Badge,
Button,
Spinner,
ButtonGroup,
} from "@sveltestrap/sveltestrap";
import { onDestroy } from "svelte";
import { t } from "$lib/translations";
import { page } from "$app/state";
import {
listCheckResults,
deleteCheckResult,
deleteAllCheckResults,
getCheckExecution,
} from "$lib/api/checks";
import { getCheckStatus } from "$lib/api/checks";
import type { Domain } from "$lib/model/domain";
import type { CheckExecution } from "$lib/model/check";
import { CheckExecutionStatus, CheckScopeType } from "$lib/model/check";
import RunCheckModal from "$lib/components/modals/RunCheckModal.svelte";
import { getStatusColor, getStatusKey, formatDuration, formatCheckDate } from "$lib/utils";
interface Props {
data: { domain: Domain };
}
let { data }: Props = $props();
const checkName = $derived(page.params.cname || "");
let resultsPromise = $derived(listCheckResults(data.domain.id, checkName));
let checkPromise = $derived(getCheckStatus(checkName));
let runCheckModal: RunCheckModal;
let errorMessage = $state<string | null>(null);
let pendingExecutions = $state<CheckExecution[]>([]);
const pollingIntervals = new Map<string, ReturnType<typeof setInterval>>();
onDestroy(() => {
for (const id of pollingIntervals.values()) clearInterval(id);
});
function handleCheckTriggered(execution_id: string) {
const placeholder: CheckExecution = {
id: execution_id,
checker_name: checkName,
owner_id: "",
target_type: CheckScopeType.CheckScopeDomain,
target_id: data.domain.id,
status: CheckExecutionStatus.CheckExecutionPending,
started_at: new Date().toISOString(),
};
pendingExecutions = [...pendingExecutions, placeholder];
const intervalId = setInterval(async () => {
try {
const exec = await getCheckExecution(data.domain.id, checkName, execution_id);
pendingExecutions = pendingExecutions.map((e) =>
e.id === execution_id ? exec : e,
);
if (
exec.status === CheckExecutionStatus.CheckExecutionCompleted ||
exec.status === CheckExecutionStatus.CheckExecutionFailed
) {
clearInterval(intervalId);
pollingIntervals.delete(execution_id);
pendingExecutions = pendingExecutions.filter((e) => e.id !== execution_id);
resultsPromise = listCheckResults(data.domain.id, checkName);
}
} catch {
clearInterval(intervalId);
pollingIntervals.delete(execution_id);
pendingExecutions = pendingExecutions.filter((e) => e.id !== execution_id);
}
}, 2000);
pollingIntervals.set(execution_id, intervalId);
}
async function handleDeleteResult(resultId: string) {
if (!confirm($t("checks.results.delete-confirm"))) {
return;
}
try {
await deleteCheckResult(data.domain.id, checkName, resultId);
resultsPromise = listCheckResults(data.domain.id, checkName);
} catch (error: any) {
errorMessage = error.message || $t("checks.results.delete-failed");
}
}
async function handleDeleteAll() {
if (!confirm($t("checks.results.delete-all-confirm"))) {
return;
}
try {
await deleteAllCheckResults(data.domain.id, checkName);
resultsPromise = listCheckResults(data.domain.id, checkName);
} catch (error: any) {
errorMessage = error.message || $t("checks.results.delete-all-failed");
}
}
</script>
<svelte:head>
<title>{checkName} Results - {data.domain.domain} - happyDomain</title>
</svelte:head>
<div class="flex-fill pb-4 pt-2">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>
<span class="font-monospace">{data.domain.domain}</span>
&ndash;
{#await checkPromise then check}
{check.name || checkName}
{:catch}
{checkName}
{/await}
</h2>
<div class="d-flex gap-2">
<Button
color="dark"
href={`/domains/${encodeURIComponent(data.domain.domain)}/checks/${encodeURIComponent(checkName)}`}
>
<Icon name="gear-fill"></Icon>
{$t("checks.results.configure")}
</Button>
{#await checkPromise then check}
<Button
color="primary"
onclick={() => runCheckModal.open(checkName, check.name || checkName)}
>
<Icon name="play-fill"></Icon>
{$t("checks.results.run-check-now")}
</Button>
{/await}
</div>
</div>
{#if errorMessage}
{#key errorMessage}
<Alert color="danger" dismissible>
<Icon name="exclamation-triangle-fill"></Icon>
{errorMessage}
</Alert>
{/key}
{/if}
{#await resultsPromise}
<div class="mt-5 text-center flex-fill">
<Spinner />
<p>{$t("checks.results.loading")}</p>
</div>
{:then results}
{#if (!results || results.length === 0) && pendingExecutions.length === 0}
<Card body>
<p class="text-center text-muted mb-0">
<Icon name="info-circle"></Icon>
{$t("checks.results.no-results")}
</p>
</Card>
{:else}
<div class="d-flex justify-content-between align-items-center mb-2">
<h4>{$t("checks.results.title", { count: results?.length ?? 0 })}</h4>
{#if results?.length}
<Button size="sm" color="danger" outline onclick={handleDeleteAll}>
<Icon name="trash"></Icon>
{$t("checks.results.delete-all")}
</Button>
{/if}
</div>
<Table hover striped>
<thead>
<tr>
<th>{$t("checks.results.table.executed-at")}</th>
<th class="text-center">{$t("checks.results.table.status")}</th>
<th>{$t("checks.results.table.message")}</th>
<th>{$t("checks.results.table.duration")}</th>
<th class="text-center">{$t("checks.results.table.type")}</th>
<th>{$t("checks.results.table.actions")}</th>
</tr>
</thead>
<tbody>
{#each pendingExecutions as exec (exec.id)}
<tr class="table-warning">
<td class="align-middle">
{formatCheckDate(exec.started_at, "short", $t)}
</td>
<td class="align-middle text-center">
<Badge
color={exec.status === CheckExecutionStatus.CheckExecutionRunning
? "info"
: "secondary"}
>
{exec.status === CheckExecutionStatus.CheckExecutionRunning
? $t("checks.results.pending.running")
: $t("checks.results.pending.queued")}
</Badge>
</td>
<td class="align-middle text-muted">
{exec.status === CheckExecutionStatus.CheckExecutionRunning
? $t("checks.results.pending.running-description")
: $t("checks.results.pending.queued-description")}
</td>
<td class="align-middle"></td>
<td class="align-middle text-center">
<Badge color="secondary">
{#if exec.schedule_id}
<Icon name="clock"></Icon>
{$t("checks.results.type.scheduled")}
{:else}
<Icon name="hand-index"></Icon>
{$t("checks.results.type.manual")}
{/if}
</Badge>
</td>
<td class="align-middle"></td>
</tr>
{/each}
{#each results ?? [] as result}
<tr>
<td class="align-middle">
{formatCheckDate(result.executed_at, "short", $t)}
</td>
<td class="align-middle text-center">
<Badge color={getStatusColor(result.status)}>
{$t(getStatusKey(result.status))}
</Badge>
</td>
<td class="align-middle">
{result.status_line}
{#if result.error}
<br />
<small class="text-danger">{result.error}</small>
{/if}
</td>
<td class="align-middle">
{formatDuration(result.duration, $t)}
</td>
<td class="align-middle text-center">
<Badge color="secondary">
{#if result.scheduled_check}
<Icon name="clock"></Icon>
{$t("checks.results.type.scheduled")}
{:else}
<Icon name="hand-index"></Icon>
{$t("checks.results.type.manual")}
{/if}
</Badge>
</td>
<td class="align-middle">
<ButtonGroup size="sm">
<Button
color="primary"
href={`/domains/${encodeURIComponent(data.domain.domain)}/checks/${encodeURIComponent(checkName)}/results/${encodeURIComponent(result.id!)}`}
>
<Icon name="eye-fill"></Icon>
{$t("checks.results.view")}
</Button>
<Button
color="danger"
outline
onclick={() => handleDeleteResult(result.id!)}
>
<Icon name="trash"></Icon>
</Button>
</ButtonGroup>
</td>
</tr>
{/each}
</tbody>
</Table>
{/if}
{:catch error}
<Card body color="danger">
<p class="mb-0">
<Icon name="exclamation-triangle-fill"></Icon>
{$t("checks.results.error-loading", { error: error.message })}
</p>
</Card>
{/await}
</div>
<RunCheckModal
domainId={data.domain.id}
onCheckTriggered={handleCheckTriggered}
bind:this={runCheckModal}
/>

View file

@ -0,0 +1,305 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-2026 happyDomain
Authors: Pierre-Olivier Mercier, et al.
This program is offered under a commercial and under the AGPL license.
For commercial licensing, contact us at <contact@happydomain.org>.
For AGPL licensing:
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import {
Alert,
Badge,
Button,
Card,
CardBody,
CardHeader,
Col,
Icon,
Row,
Spinner,
Table,
} from "@sveltestrap/sveltestrap";
import { t } from "$lib/translations";
import { page } from "$app/state";
import { navigate } from "$lib/stores/config";
import {
getCheckStatus,
getCheckResult,
deleteCheckResult,
triggerCheck,
} from "$lib/api/checks";
import type { Domain } from "$lib/model/domain";
import type { CheckResult } from "$lib/model/check";
import { getStatusColor, getStatusKey, formatDuration, formatCheckDate } from "$lib/utils";
interface Props {
data: { domain: Domain };
}
let { data }: Props = $props();
const checkName = $derived(page.params.cname || "");
const resultId = $derived(page.params.rid || "");
let resultPromise = $derived(getCheckResult(data.domain.id, checkName, resultId));
let checkPromise = $derived(getCheckStatus(checkName));
let errorMessage = $state<string | null>(null);
let resolvedResult = $state<CheckResult | null>(null);
let isRelaunching = $state(false);
$effect(() => {
resultPromise.then((r) => {
resolvedResult = r;
});
});
async function handleRelaunch() {
if (!resolvedResult) return;
isRelaunching = true;
try {
await triggerCheck(data.domain.id, checkName, resolvedResult.options);
navigate(
`/domains/${encodeURIComponent(data.domain.domain)}/checks/${encodeURIComponent(checkName)}`,
);
} catch (error: any) {
errorMessage = error.message || $t("checks.result.relaunch-failed");
} finally {
isRelaunching = false;
}
}
async function handleDelete() {
if (!confirm($t("checks.result.delete-confirm"))) {
return;
}
try {
await deleteCheckResult(data.domain.id, checkName, resultId);
navigate(
`/domains/${encodeURIComponent(data.domain.domain)}/checks/${encodeURIComponent(checkName)}`,
);
} catch (error: any) {
errorMessage = error.message || $t("checks.result.delete-failed");
}
}
</script>
<svelte:head>
<title>
Check Result - {checkName} - {data.domain.domain} - happyDomain
</title>
</svelte:head>
<div class="flex-fill pb-4 pt-2 mw-100">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="text-truncate">
<span class="font-monospace">{data.domain.domain}</span>
&ndash;
{$t("checks.result.title")}
</h2>
<div class="d-flex gap-2">
<Button
color="primary"
outline
onclick={handleRelaunch}
disabled={!resolvedResult || isRelaunching}
>
{#if isRelaunching}
<Spinner size="sm" />
{:else}
<Icon name="arrow-repeat"></Icon>
{/if}
<span class="d-none d-lg-inline">
{$t("checks.result.relaunch")}
</span>
</Button>
<Button color="danger" outline onclick={handleDelete} disabled={!resolvedResult}>
<Icon name="trash"></Icon>
<span class="d-none d-lg-inline">
{$t("checks.result.delete")}
</span>
</Button>
</div>
</div>
{#if errorMessage}
{#key errorMessage}
<Alert color="danger" dismissible>
<Icon name="exclamation-triangle-fill"></Icon>
{errorMessage}
</Alert>
{/key}
{/if}
{#await Promise.all([resultPromise, checkPromise])}
<div class="mt-5 text-center flex-fill">
<Spinner />
<p>{$t("checks.result.loading")}</p>
</div>
{:then [result, check]}
<Row>
<Col lg>
<Card class="mb-3">
<CardHeader>
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-end gap-2">
<h4 class="mb-0">
{check.name || checkName}
</h4>
</div>
{#if result.scheduled_check}
<Badge color="info">
<Icon name="clock"></Icon>
{$t("checks.result.type.scheduled")}
</Badge>
{:else}
<Badge color="secondary">
<Icon name="hand-index"></Icon>
{$t("checks.result.type.manual")}
</Badge>
{/if}
</div>
</CardHeader>
<CardBody class="p-2">
<Table borderless size="sm" class="mb-0">
<tbody>
<tr>
<th style="width: 200px">{$t("checks.result.field.domain")}</th>
<td class="font-monospace">{data.domain.domain}</td>
</tr>
<tr>
<th>{$t("checks.result.field.executed-at")}</th>
<td>{formatCheckDate(result.executed_at, "long", $t)}</td>
</tr>
<tr>
<th>{$t("checks.result.field.duration")}</th>
<td>{formatDuration(result.duration, $t)}</td>
</tr>
<tr>
<th>{$t("checks.result.field.status")}</th>
<td>
<Badge color={getStatusColor(result.status)}>
{$t(getStatusKey(result.status))}
</Badge>
</td>
</tr>
<tr>
<th>{$t("checks.result.field.status-message")}</th>
<td>{result.status_line}</td>
</tr>
{#if result.error}
<tr>
<th>{$t("checks.result.field.error")}</th>
<td class="text-danger">{result.error}</td>
</tr>
{/if}
</tbody>
</Table>
</CardBody>
</Card>
</Col>
{#if result.options && Object.keys(result.options).length > 0}
<Col lg>
<Card class="mb-3">
<CardHeader>
<h5 class="mb-0">
<Icon name="sliders"></Icon>
{$t("checks.result.check-options")}
</h5>
</CardHeader>
<CardBody class="p-2">
<Table borderless size="sm" class="mb-0">
<tbody>
{#each Object.entries(check.options ?? {}) as [optKey, optVals]}
{#each optVals as option}
{@const value =
(option.id
? result.options[option.id]
: undefined) ||
option.default ||
option.placeholder ||
""}
<tr>
<th
class="text-truncate"
style="max-width: min(200px, 40vw)"
title={option.label}
>
{option.label}:
</th>
<td class:text-truncate={typeof value !== "object"}>
{#if typeof value === "object"}
<pre class="mb-0"><code
>{JSON.stringify(
value,
null,
2,
)}</code
></pre>
{:else}
{value}
{/if}
</td>
</tr>
{/each}
{/each}
</tbody>
</Table>
</CardBody>
</Card>
</Col>
{/if}
</Row>
{#if result.report}
<Card>
<CardHeader>
<h5 class="mb-0">
<Icon name="file-earmark-text"></Icon>
{$t("checks.result.full-report")}
</h5>
</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>
{:else}
<pre class="bg-light p-3 rounded mb-0"><code
>{JSON.stringify(result.report, null, 2)}</code
></pre>
{/if}
</CardBody>
</Card>
{/if}
{:catch error}
<Card body color="danger">
<p class="mb-0">
<Icon name="exclamation-triangle-fill"></Icon>
{$t("checks.result.error-loading", { error: error.message })}
</p>
</Card>
{/await}
</div>
<style>
pre {
overflow-x: scroll;
}
</style>