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 aab23b2847
21 changed files with 1923 additions and 313 deletions

View file

@ -147,7 +147,7 @@ func (tc *TestResultController) ListAvailableTests(c *gin.Context) {
info := TestInfo{
PluginName: pluginNames[0],
Enabled: false,
Enabled: true, // enabled by default unless explicitly disabled via a schedule
}
// Check if there's a schedule

View file

@ -1,285 +0,0 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-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/>.
package testresult
import (
"fmt"
"time"
"git.happydns.org/happyDomain/model"
)
const (
// Default test intervals
DefaultDomainTestInterval = 24 * time.Hour // 24 hours for domain tests
DefaultServiceTestInterval = 1 * time.Hour // 1 hour for service tests
MinimumTestInterval = 5 * time.Minute // Minimum interval allowed
)
// TestScheduleUsecase implements business logic for test schedules
type TestScheduleUsecase struct {
storage TestResultStorage
options *happydns.Options
}
// NewTestScheduleUsecase creates a new test schedule usecase
func NewTestScheduleUsecase(storage TestResultStorage, options *happydns.Options) *TestScheduleUsecase {
return &TestScheduleUsecase{
storage: storage,
options: options,
}
}
// ListUserSchedules retrieves all schedules for a specific user
func (u *TestScheduleUsecase) ListUserSchedules(userId happydns.Identifier) ([]*happydns.TestSchedule, error) {
return u.storage.ListTestSchedulesByUser(userId)
}
// ListSchedulesByTarget retrieves all schedules for a specific target
func (u *TestScheduleUsecase) ListSchedulesByTarget(targetType happydns.TestScopeType, targetId happydns.Identifier) ([]*happydns.TestSchedule, error) {
return u.storage.ListTestSchedulesByTarget(targetType, targetId)
}
// GetSchedule retrieves a specific schedule by ID
func (u *TestScheduleUsecase) GetSchedule(scheduleId happydns.Identifier) (*happydns.TestSchedule, error) {
return u.storage.GetTestSchedule(scheduleId)
}
// CreateSchedule creates a new test schedule with validation
func (u *TestScheduleUsecase) CreateSchedule(schedule *happydns.TestSchedule) error {
// Validate interval
if schedule.Interval < MinimumTestInterval {
return fmt.Errorf("test interval must be at least %v", MinimumTestInterval)
}
// Set default interval if not specified
if schedule.Interval == 0 {
schedule.Interval = u.getDefaultInterval(schedule.TargetType)
}
// Calculate next run time
if schedule.NextRun.IsZero() {
schedule.NextRun = time.Now().Add(schedule.Interval)
}
// Enable by default if not specified
if !schedule.Enabled {
schedule.Enabled = true
}
return u.storage.CreateTestSchedule(schedule)
}
// UpdateSchedule updates an existing schedule
func (u *TestScheduleUsecase) UpdateSchedule(schedule *happydns.TestSchedule) error {
// Validate interval
if schedule.Interval < MinimumTestInterval {
return fmt.Errorf("test interval must be at least %v", MinimumTestInterval)
}
// Get existing schedule to preserve certain fields
existing, err := u.storage.GetTestSchedule(schedule.Id)
if err != nil {
return err
}
// Preserve LastRun if not explicitly changed
if schedule.LastRun == nil {
schedule.LastRun = existing.LastRun
}
// Recalculate next run time if interval changed
if schedule.Interval != existing.Interval {
if schedule.LastRun != nil {
schedule.NextRun = schedule.LastRun.Add(schedule.Interval)
} else {
schedule.NextRun = time.Now().Add(schedule.Interval)
}
}
return u.storage.UpdateTestSchedule(schedule)
}
// DeleteSchedule removes a schedule
func (u *TestScheduleUsecase) DeleteSchedule(scheduleId happydns.Identifier) error {
return u.storage.DeleteTestSchedule(scheduleId)
}
// EnableSchedule enables a schedule
func (u *TestScheduleUsecase) EnableSchedule(scheduleId happydns.Identifier) error {
schedule, err := u.storage.GetTestSchedule(scheduleId)
if err != nil {
return err
}
schedule.Enabled = true
// Reset next run time if it's in the past
if schedule.NextRun.Before(time.Now()) {
schedule.NextRun = time.Now().Add(schedule.Interval)
}
return u.storage.UpdateTestSchedule(schedule)
}
// DisableSchedule disables a schedule
func (u *TestScheduleUsecase) DisableSchedule(scheduleId happydns.Identifier) error {
schedule, err := u.storage.GetTestSchedule(scheduleId)
if err != nil {
return err
}
schedule.Enabled = false
return u.storage.UpdateTestSchedule(schedule)
}
// UpdateScheduleAfterRun updates a schedule after it has been executed
func (u *TestScheduleUsecase) UpdateScheduleAfterRun(scheduleId happydns.Identifier) error {
schedule, err := u.storage.GetTestSchedule(scheduleId)
if err != nil {
return err
}
now := time.Now()
schedule.LastRun = &now
schedule.NextRun = now.Add(schedule.Interval)
return u.storage.UpdateTestSchedule(schedule)
}
// ListDueSchedules retrieves all enabled schedules that are due to run
func (u *TestScheduleUsecase) ListDueSchedules() ([]*happydns.TestSchedule, error) {
schedules, err := u.storage.ListEnabledTestSchedules()
if err != nil {
return nil, err
}
now := time.Now()
var dueSchedules []*happydns.TestSchedule
for _, schedule := range schedules {
if schedule.Enabled && schedule.NextRun.Before(now) {
dueSchedules = append(dueSchedules, schedule)
}
}
return dueSchedules, nil
}
// getDefaultInterval returns the default test interval based on target type
func (u *TestScheduleUsecase) getDefaultInterval(targetType happydns.TestScopeType) time.Duration {
switch targetType {
case happydns.TestScopeDomain:
return DefaultDomainTestInterval
case happydns.TestScopeService:
return DefaultServiceTestInterval
case happydns.TestScopeZone:
return DefaultDomainTestInterval
default:
return DefaultDomainTestInterval
}
}
// MergePluginOptions merges plugin options from different scopes
// Priority: schedule options > domain options > user options > global options
func (u *TestScheduleUsecase) MergePluginOptions(
globalOpts happydns.PluginOptions,
userOpts happydns.PluginOptions,
domainOpts happydns.PluginOptions,
scheduleOpts happydns.PluginOptions,
) happydns.PluginOptions {
merged := make(happydns.PluginOptions)
// Start with global options
for k, v := range globalOpts {
merged[k] = v
}
// Override with user options
for k, v := range userOpts {
merged[k] = v
}
// Override with domain options
for k, v := range domainOpts {
merged[k] = v
}
// Override with schedule options (highest priority)
for k, v := range scheduleOpts {
merged[k] = v
}
return merged
}
// ValidateScheduleOwnership checks if a user owns a schedule
func (u *TestScheduleUsecase) ValidateScheduleOwnership(scheduleId happydns.Identifier, userId happydns.Identifier) error {
schedule, err := u.storage.GetTestSchedule(scheduleId)
if err != nil {
return err
}
if !schedule.UserId.Equals(userId) {
return fmt.Errorf("user does not own this schedule")
}
return nil
}
// CreateDefaultSchedulesForTarget creates default schedules for a new target
func (u *TestScheduleUsecase) CreateDefaultSchedulesForTarget(
pluginName string,
targetType happydns.TestScopeType,
targetId happydns.Identifier,
userId happydns.Identifier,
enabled bool,
) error {
schedule := &happydns.TestSchedule{
PluginName: pluginName,
UserId: userId,
TargetType: targetType,
TargetId: targetId,
Interval: u.getDefaultInterval(targetType),
Enabled: enabled,
NextRun: time.Now().Add(u.getDefaultInterval(targetType)),
Options: make(happydns.PluginOptions),
}
return u.CreateSchedule(schedule)
}
// DeleteSchedulesForTarget removes all schedules for a target
func (u *TestScheduleUsecase) DeleteSchedulesForTarget(targetType happydns.TestScopeType, targetId happydns.Identifier) error {
schedules, err := u.storage.ListTestSchedulesByTarget(targetType, targetId)
if err != nil {
return err
}
for _, schedule := range schedules {
if err := u.storage.DeleteTestSchedule(schedule.Id); err != nil {
return err
}
}
return nil
}

190
web/src/lib/api/tests.ts Normal file
View file

@ -0,0 +1,190 @@
// 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 { PostDomainsByDomainTestsByTnameResponse } from "$lib/api-base/types.gen";
import {
getDomainsByDomainTests,
getDomainsByDomainTestsByTname,
postDomainsByDomainTestsByTname,
getDomainsByDomainTestsByTnameExecutionsByExecutionId,
getDomainsByDomainTestsByTnameOptions,
putDomainsByDomainTestsByTnameOptions,
getDomainsByDomainTestsByTnameResults,
getDomainsByDomainTestsByTnameResultsByResultId,
deleteDomainsByDomainTestsByTnameResultsByResultId,
deleteDomainsByDomainTestsByTnameResults,
getPluginsTestsSchedules,
getPluginsTestsSchedulesByScheduleId,
postPluginsTestsSchedules,
putPluginsTestsSchedulesByScheduleId,
deletePluginsTestsSchedulesByScheduleId,
} from "$lib/api-base/sdk.gen";
import type {
TestResult,
TestExecution,
TestSchedule,
AvailableTest,
CreateScheduleRequest,
} from "$lib/model/test";
import type { PluginOptions } from "$lib/model/plugin";
import { unwrapSdkResponse, unwrapEmptyResponse } from "./errors";
// Domain test operations
export async function listAvailableTests(domainId: string): Promise<AvailableTest[]> {
return unwrapSdkResponse(
await getDomainsByDomainTests({ path: { domain: domainId } }),
) as unknown as AvailableTest[];
}
export async function listTestResults(
domainId: string,
testName: string,
limit?: number,
): Promise<TestResult[]> {
return unwrapSdkResponse(
await getDomainsByDomainTestsByTnameResults({
path: { domain: domainId, tname: testName },
query: limit !== undefined ? { limit } : undefined,
}),
) as TestResult[];
}
export async function getLatestTestResults(
domainId: string,
testName: string,
): Promise<TestResult[]> {
return unwrapSdkResponse(
await getDomainsByDomainTestsByTname({ path: { domain: domainId, tname: testName } }),
) as TestResult[];
}
export async function triggerTest(
domainId: string,
testName: string,
options?: PluginOptions,
): Promise<PostDomainsByDomainTestsByTnameResponse> {
return unwrapSdkResponse(
await postDomainsByDomainTestsByTname({
path: { domain: domainId, tname: testName },
body: { options } as any,
}),
) as PostDomainsByDomainTestsByTnameResponse;
}
export async function getTestExecution(
domainId: string,
testName: string,
executionId: string,
): Promise<TestExecution> {
return unwrapSdkResponse(
await getDomainsByDomainTestsByTnameExecutionsByExecutionId({
path: { domain: domainId, tname: testName, execution_id: executionId },
}),
) as TestExecution;
}
export async function getTestResult(
domainId: string,
testName: string,
resultId: string,
): Promise<TestResult> {
return unwrapSdkResponse(
await getDomainsByDomainTestsByTnameResultsByResultId({
path: { domain: domainId, tname: testName, result_id: resultId },
}),
) as TestResult;
}
export async function deleteTestResult(
domainId: string,
testName: string,
resultId: string,
): Promise<void> {
unwrapEmptyResponse(
await deleteDomainsByDomainTestsByTnameResultsByResultId({
path: { domain: domainId, tname: testName, result_id: resultId },
}),
);
}
export async function deleteAllTestResults(domainId: string, testName: string): Promise<void> {
unwrapEmptyResponse(
await deleteDomainsByDomainTestsByTnameResults({
path: { domain: domainId, tname: testName },
}),
);
}
export async function getTestOptions(domainId: string, testName: string): Promise<PluginOptions> {
return unwrapSdkResponse(
await getDomainsByDomainTestsByTnameOptions({
path: { domain: domainId, tname: testName },
}),
) as PluginOptions;
}
export async function updateTestOptions(
domainId: string,
testName: string,
options: PluginOptions,
): Promise<void> {
unwrapEmptyResponse(
await putDomainsByDomainTestsByTnameOptions({
path: { domain: domainId, tname: testName },
body: { options } as any,
}),
);
}
// Schedule operations
export async function listUserSchedules(): Promise<TestSchedule[]> {
return unwrapSdkResponse(await getPluginsTestsSchedules()) as TestSchedule[];
}
export async function getTestSchedule(scheduleId: string): Promise<TestSchedule> {
return unwrapSdkResponse(
await getPluginsTestsSchedulesByScheduleId({ path: { schedule_id: scheduleId } }),
) as TestSchedule;
}
export async function createTestSchedule(schedule: CreateScheduleRequest): Promise<TestSchedule> {
return unwrapSdkResponse(
await postPluginsTestsSchedules({ body: schedule as any }),
) as TestSchedule;
}
export async function updateTestSchedule(
scheduleId: string,
schedule: Partial<TestSchedule>,
): Promise<void> {
unwrapEmptyResponse(
await putPluginsTestsSchedulesByScheduleId({
path: { schedule_id: scheduleId },
body: schedule as any,
}),
);
}
export async function deleteTestSchedule(scheduleId: string): Promise<void> {
unwrapEmptyResponse(
await deletePluginsTestsSchedulesByScheduleId({ path: { schedule_id: scheduleId } }),
);
}

View file

@ -0,0 +1,172 @@
<!--
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 { triggerTest, getTestOptions } from "$lib/api/tests";
import { getPluginStatus } from "$lib/api/plugins";
import type { PluginOptions } from "$lib/model/plugin";
import Resource from "$lib/components/inputs/Resource.svelte";
import { toasts } from "$lib/stores/toasts";
import { t } from "$lib/translations";
interface Props {
domainId: string;
onTestTriggered?: (execution_id: string, plugin_name: string) => void;
}
let { domainId, onTestTriggered }: Props = $props();
let isOpen = $state(false);
let pluginName = $state<string>("");
let pluginDisplayName = $state<string>("");
let pluginStatusPromise = $state<Promise<any> | null>(null);
let domainOptionsPromise = $state<Promise<PluginOptions> | null>(null);
let runOptions = $state<Record<string, any>>({});
let triggering = $state(false);
const toggle = () => (isOpen = !isOpen);
export function open(testPluginName: string, testDisplayName: string) {
pluginName = testPluginName;
pluginDisplayName = testDisplayName;
runOptions = {};
pluginStatusPromise = getPluginStatus(testPluginName);
domainOptionsPromise = getTestOptions(domainId, testPluginName);
isOpen = true;
// Pre-populate with domain options when they load
domainOptionsPromise.then((options) => {
runOptions = { ...(options || {}) };
});
}
async function handleRunTest() {
triggering = true;
try {
const result = await triggerTest(domainId, pluginName, runOptions);
toasts.addToast({
message: $t("tests.run-test.triggered-success", { id: result.execution_id }),
type: "success",
timeout: 5000,
});
isOpen = false;
if (onTestTriggered && result.execution_id) {
onTestTriggered(result.execution_id, pluginName);
}
} catch (error) {
toasts.addErrorToast({
message: $t("tests.run-test.trigger-failed", { error: String(error) }),
timeout: 10000,
});
} finally {
triggering = false;
}
}
</script>
<Modal {isOpen} {toggle} size="lg">
<ModalHeader {toggle}>
{$t("tests.run-test.title")}: {pluginDisplayName}
</ModalHeader>
<ModalBody>
{#if pluginStatusPromise && domainOptionsPromise}
{#await Promise.all([pluginStatusPromise, domainOptionsPromise])}
<div class="text-center py-3">
<Spinner />
<p class="mt-2">{$t("tests.run-test.loading-options")}</p>
</div>
{:then [status, _domainOpts]}
{@const runOpts = status.options?.runOpts || []}
{#if runOpts.length > 0}
<p>
{$t("tests.run-test.configure-info")}
</p>
<Form
id="run-test-modal"
on:submit={(e) => {
e.preventDefault();
handleRunTest();
}}
>
{#each runOpts as optDoc}
{#if optDoc.id}
{@const optName = optDoc.id}
<FormGroup>
<Resource
edit={true}
index={optName}
specs={optDoc}
type={optDoc.type || "string"}
bind:value={runOptions[optName]}
/>
</FormGroup>
{/if}
{/each}
</Form>
{:else}
<Alert color="info" class="mb-0">
<Icon name="info-circle"></Icon>
{$t("tests.run-test.no-options")}
</Alert>
{/if}
{:catch error}
<Alert color="danger">
<Icon name="exclamation-triangle-fill"></Icon>
{$t("tests.run-test.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={handleRunTest}
disabled={triggering}
>
{#if triggering}
<Spinner size="sm" class="me-1" />
{:else}
<Icon name="play-fill"></Icon>
{/if}
{$t("tests.run-test.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-tests": "View tests",
"others": "More actions on {{domain}}"
},
"alert": {
@ -536,6 +537,126 @@
"ttl": "Remaining time in cache",
"showDNSSEC": "Show DNSSEC records in answer (if any)"
},
"tests": {
"run-test": {
"title": "Run Test",
"loading-options": "Loading test options...",
"configure-info": "Configure test options below. Pre-filled values are from domain-level settings.",
"no-options": "This test has no configurable options. Click \"Run Test\" to execute with default settings.",
"error-loading-options": "Error loading test options: {{error}}",
"run-button": "Run Test",
"triggered-success": "Test triggered successfully! Execution ID: {{id}}",
"trigger-failed": "Failed to trigger test: {{error}}"
},
"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",
"not-run": "Not run"
},
"list": {
"title": "Tests for ",
"loading": "Loading tests...",
"loading-plugins": "Loading plugin information...",
"no-tests": "No tests available for this domain.",
"run-test": "Run Test",
"view-results": "View Results",
"error-loading": "Error loading tests: {{error}}",
"unknown-version": "Unknown",
"table": {
"plugin": "Test Plugin",
"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 test 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 test results...",
"no-results": "No test results yet. Click \"Run Test Now\" to execute the test.",
"title": "Test Results ({{count}})",
"run-test-now": "Run Test Now",
"back-to-tests": "Back to Tests",
"delete-all": "Delete All",
"delete-confirm": "Are you sure you want to delete this test result?",
"delete-all-confirm": "Are you sure you want to delete ALL test results for this test? 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 test results: {{error}}",
"table": {
"executed-at": "Executed At",
"status": "Status",
"message": "Message",
"duration": "Duration",
"type": "Type",
"actions": "Actions"
},
"type": {
"scheduled": "Scheduled",
"manual": "Manual"
},
"view": "View"
},
"result": {
"title": "Test Result Details",
"loading": "Loading test result...",
"relaunch": "Relaunch Test",
"delete": "Delete Result",
"back-to-results": "Back to Results",
"relaunch-failed": "Failed to relaunch test",
"delete-confirm": "Are you sure you want to delete this test result?",
"delete-failed": "Failed to delete result",
"error-loading": "Error loading test result: {{error}}",
"milliseconds": "milliseconds",
"seconds": "seconds",
"type": {
"scheduled": "Scheduled Test",
"manual": "Manual Test"
},
"test-options": "Test Options",
"full-report": "Full Report",
"field": {
"domain": "Domain:",
"executed-at": "Executed At:",
"duration": "Duration:",
"status": "Status:",
"status-message": "Status Message:",
"error": "Error:",
"plugin-version": "Plugin Version:"
}
}
},
"plugins": {
"tests": {
"title": "Domain Tests",
@ -548,7 +669,6 @@
"error-loading": "Error loading tests: {{error}}",
"error-loading-test": "Error loading test: {{error}}",
"test-info-not-found": "Error: Test information not found",
"back-to-tests": "Back to Tests",
"table": {
"name": "Test Name",
"version": "Version",
@ -594,13 +714,16 @@
"options-cleaned": "Orphaned options removed successfully",
"update-failed": "Failed to update options: {{error}}",
"clean-failed": "Failed to clean options: {{error}}"
}
},
"back-button": "Back to Plugins"
}
},
"zones": {
"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-tests": "Back to Tests"
}
}

View file

@ -473,6 +473,24 @@
"no-group": "Divers",
"title": "Vos groupes"
},
"tests": {
"run-test": {
"title": "Lancer le test",
"loading-options": "Chargement des options du test...",
"configure-info": "Configurez les options du test ci-dessous. Les valeurs préremplies proviennent des paramètres au niveau du domaine.",
"no-options": "Ce test n'a pas d'options configurables. Cliquez sur \"Lancer le test\" pour l'exécuter avec les paramètres par défaut.",
"error-loading-options": "Erreur lors du chargement des options du test : {{error}}",
"run-button": "Lancer le test"
},
"never": "Jamais",
"na": "N/A",
"relative": {
"in-less-than-a-minute": "dans moins d'une minute",
"just-now": "à l'instant",
"in": "dans {{label}}",
"ago": "il y a {{label}}"
}
},
"plugins": {
"tests": {
"title": "Tests de domaines",
@ -531,7 +549,8 @@
"options-cleaned": "Options orphelines supprimées avec succès",
"update-failed": "Échec de la mise à jour des options : {{error}}",
"clean-failed": "Échec du nettoyage des options : {{error}}"
}
},
"back-button": "Retour aux plugins"
}
},
"zones": {

View file

@ -0,0 +1,54 @@
// 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 {
HappydnsPluginAvailability,
HappydnsPluginOptionDocumentation,
HappydnsPluginOptionsDocumentation,
HappydnsPluginOptions,
} from "$lib/api-base/types.gen";
// Re-export auto-generated types with better names
export type PluginAvailability = HappydnsPluginAvailability;
export type PluginOptions = HappydnsPluginOptions;
export type PluginOptionsDocumentation = HappydnsPluginOptionsDocumentation;
// Make 'id' required for PluginOptionDocumentation
export interface PluginOptionDocumentation extends Omit<HappydnsPluginOptionDocumentation, "id"> {
id: string;
}
// Make 'name' and 'version' required for PluginVersionInfo
export interface PluginVersionInfo {
name: string;
version: string;
availableOn?: PluginAvailability;
}
// Make 'name' and 'version' required for PluginStatus
export interface PluginStatus {
name: string;
version: string;
availableOn?: PluginAvailability;
options?: PluginOptionsDocumentation;
}
export type PluginList = Record<string, PluginVersionInfo>;

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 { PluginOptions } from './plugin';
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?: PluginOptions;
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?: PluginOptions;
}
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?: PluginOptions;
}
export interface CreateScheduleRequest {
plugin_name: string;
target_type: TestScopeType;
target_id: string;
interval: number;
enabled: boolean;
options?: PluginOptions;
}

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 { listPlugins } from "$lib/api/plugins";
import type { PluginList } from "$lib/model/plugin";
import { writable, type Writable } from "svelte/store";
export const plugins: Writable<PluginList | undefined> = writable(undefined);
export async function refreshPlugins() {
const data = await listPlugins();
plugins.set(data);
return data;
}

View file

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

View file

@ -31,3 +31,59 @@ export function fromDatetimeLocal(datetimeLocal: string): string | null {
return null;
}
}
/**
* Format a date string for display in test 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("tests.never") if undefined/invalid
*/
export function formatTestDate(
dateString: string | undefined,
style: "short" | "medium" | "long",
t: (k: string) => string,
): string {
if (!dateString) return t("tests.never");
const d = new Date(dateString);
if (isNaN(d.getTime())) return t("tests.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) => 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("tests.relative.in-less-than-a-minute") : t("tests.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("tests.relative.in").replace("{{label}}", label)
: t("tests.relative.ago").replace("{{label}}", label);
}

View file

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

39
web/src/lib/utils/test.ts Normal file
View file

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

View file

@ -81,7 +81,11 @@
? "/logs"
: page.route.id.startsWith("/domains/[dn]/history")
? "/history"
: ""
: page.route.id.startsWith("/domains/[dn]/tests/[tname]")
? `/tests/${page.params.tname!}`
: page.route.id.startsWith("/domains/[dn]/tests")
? "/tests"
: ""
: ""),
);
}
@ -172,7 +176,34 @@
<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]/tests/[tname]")}
{#if page.route.id.startsWith("/domains/[dn]/tests/[tname]/results/")}
<Button
class="mt-2"
outline
color="primary"
href={"/domains/" +
encodeURIComponent(domainLink(selectedDomain)) +
"/tests/" +
encodeURIComponent(page.params.tname!)}
>
<Icon name="chevron-left" />
{$t("zones.return-to-results")}
</Button>
{:else}
<Button
class="mt-2"
outline
color="primary"
href={"/domains/" +
encodeURIComponent(domainLink(selectedDomain)) +
"/tests"}
>
<Icon name="chevron-left" />
{$t("zones.return-to-tests")}
</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]/tests"))}
<Button
class="mt-2"
outline
@ -226,6 +257,9 @@
<DropdownItem href={`/domains/${domainLink(selectedDomain)}/logs`}>
{$t("domains.actions.audit")}
</DropdownItem>
<DropdownItem href={`/domains/${domainLink(selectedDomain)}/tests`}>
{$t("domains.actions.view-tests")}
</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 { plugins, refreshPlugins } from "$lib/stores/plugins";
import { get } from "svelte/store";
export const load: Load = async ({ parent }) => {
const data = await parent();
if (get(plugins) === undefined) {
refreshPlugins();
}
return {
...data,
isTestsPage: true,
};
};

View file

@ -0,0 +1,221 @@
<!--
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 { goto } from "$app/navigation";
import { page } from "$app/state";
import { Card, Icon, Table, Badge, Button, Spinner } from "@sveltestrap/sveltestrap";
import { t } from "$lib/translations";
import { listAvailableTests, updateTestSchedule, createTestSchedule } from "$lib/api/tests";
import type { Domain } from "$lib/model/domain";
import { TestScopeType, type AvailableTest } from "$lib/model/test";
import { plugins } from "$lib/stores/plugins";
import { toasts } from "$lib/stores/toasts";
import RunTestModal from "$lib/components/modals/RunTestModal.svelte";
import { getStatusColor, getStatusKey, formatTestDate } from "$lib/utils";
interface Props {
data: { domain: Domain };
}
let { data }: Props = $props();
let testsPromise = $derived(listAvailableTests(data.domain.id));
let runTestModal: RunTestModal;
let togglingTests = $state(new Set<string>());
function handleTestTriggered(_: string, pluginName: string) {
// Refresh the test list to show updated status
testsPromise = listAvailableTests(data.domain.id);
goto(`/domains/${page.params.dn!}/tests/${pluginName}/results`);
}
async function handleToggleEnabled(test: AvailableTest) {
const next = new Set(togglingTests);
next.add(test.plugin_name);
togglingTests = next;
try {
const newEnabled = !test.enabled;
if (test.schedule) {
await updateTestSchedule(test.schedule.id, {
...test.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 createTestSchedule({
plugin_name: test.plugin_name,
target_type: TestScopeType.TestScopeDomain,
target_id: data.domain.id,
interval: 0,
enabled: newEnabled,
});
}
testsPromise = listAvailableTests(data.domain.id);
} catch (e: any) {
toasts.addErrorToast({ title: $t("tests.list.error-loading", { error: e.message }) });
} finally {
const after = new Set(togglingTests);
after.delete(test.plugin_name);
togglingTests = after;
}
}
</script>
<svelte:head>
<title>Tests - {data.domain.domain} - happyDomain</title>
</svelte:head>
<div class="flex-fill pb-4 pt-2">
<h2>
{$t("tests.list.title")}<span class="font-monospace">{data.domain.domain}</span>
</h2>
{#await testsPromise}
<div class="mt-5 text-center flex-fill">
<Spinner />
<p>{$t("tests.list.loading")}</p>
</div>
{:then tests}
{#if !$plugins}
<div class="mt-5 text-center flex-fill">
<Spinner />
<p>{$t("tests.list.loading-plugins")}</p>
</div>
{:else if !tests || tests.length === 0}
<Card body class="mt-3">
<p class="text-center text-muted mb-0">
<Icon name="info-circle"></Icon>
{$t("tests.list.no-tests")}
</p>
</Card>
{:else}
<Table hover striped class="mt-3">
<thead>
<tr>
<th>{$t("tests.list.table.plugin")}</th>
<th>{$t("tests.list.table.status")}</th>
<th>{$t("tests.list.table.last-run")}</th>
<th>{$t("tests.list.table.schedule")}</th>
<th>{$t("tests.list.table.actions")}</th>
</tr>
</thead>
<tbody>
{#each tests as test}
{@const pluginInfo = $plugins[test.plugin_name]}
<tr>
<td class="align-middle">
<strong>{pluginInfo?.name || test.plugin_name}</strong>
<small class="ms-1 text-muted">
{pluginInfo?.version || $t("tests.list.unknown-version")}
</small>
</td>
<td class="align-middle text-center">
{#if test.last_result !== undefined}
<Badge color={getStatusColor(test.last_result.status)}>
{$t(getStatusKey(test.last_result.status))}
</Badge>
{:else}
<Badge color="secondary">{$t("tests.status.not-run")}</Badge>
{/if}
</td>
<td class="align-middle">
{formatTestDate(test.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-{test.plugin_name}"
checked={test.enabled}
disabled={togglingTests.has(test.plugin_name)}
onchange={() => handleToggleEnabled(test)}
/>
<label
class="form-check-label small"
for="toggle-{test.plugin_name}"
>
{test.enabled
? $t("tests.list.schedule.enabled")
: $t("tests.list.schedule.disabled")}
</label>
</div>
</td>
<td class="align-middle">
<div class="d-flex gap-2">
<Button
size="sm"
color="primary"
onclick={() =>
runTestModal.open(
test.plugin_name,
pluginInfo?.name || test.plugin_name,
)}
>
<Icon name="play-fill"></Icon>
{$t("tests.list.run-test")}
</Button>
<Button
size="sm"
color="info"
href={`/domains/${encodeURIComponent(data.domain.domain)}/tests/${encodeURIComponent(test.plugin_name)}/results`}
>
<Icon name="bar-chart-fill"></Icon>
{$t("tests.list.view-results")}
</Button>
<Button
size="sm"
color="dark"
href={`/domains/${encodeURIComponent(data.domain.domain)}/tests/${encodeURIComponent(test.plugin_name)}`}
title={$t("tests.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("tests.list.error-loading", { error: error.message })}
</p>
</Card>
{/await}
</div>
<RunTestModal
domainId={data.domain.id}
onTestTriggered={handleTestTriggered}
bind:this={runTestModal}
/>

View file

@ -0,0 +1,276 @@
<!--
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 { listAvailableTests, updateTestSchedule, createTestSchedule } from "$lib/api/tests";
import type { Domain } from "$lib/model/domain";
import { TestScopeType, type AvailableTest } from "$lib/model/test";
import { plugins } from "$lib/stores/plugins";
import { toasts } from "$lib/stores/toasts";
import { formatTestDate, formatRelative } from "$lib/utils";
interface Props {
data: { domain: Domain };
}
let { data }: Props = $props();
const testName = $derived(page.params.tname || "");
const pluginName = $derived($plugins?.[testName]?.name || testName);
// Resolved test data
let test = $state<AvailableTest | 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 loadTest() {
loading = true;
loadError = null;
try {
const tests = await listAvailableTests(data.domain.id);
const found = tests?.find((t) => t.plugin_name === testName) ?? null;
test = found;
if (found) {
formEnabled = found.enabled;
formIntervalHours =
found.schedule && found.schedule.interval > 0
? found.schedule.interval / (3600 * 1e9)
: 24;
}
} catch (e: any) {
loadError = e.message;
} finally {
loading = false;
}
}
loadTest();
async function handleSave() {
if (!test) return;
saving = true;
try {
const intervalNs = Math.max(formIntervalHours, 1) * 3600 * 1e9;
if (test.schedule) {
await updateTestSchedule(test.schedule.id, {
...test.schedule,
enabled: formEnabled,
interval: intervalNs,
});
} else {
await createTestSchedule({
plugin_name: test.plugin_name,
target_type: TestScopeType.TestScopeDomain,
target_id: data.domain.id,
interval: intervalNs,
enabled: formEnabled,
});
}
toasts.addToast({ title: $t("tests.schedule.saved"), type: "success", timeout: 3000 });
await loadTest();
} catch (e: any) {
toasts.addErrorToast({ title: $t("tests.schedule.save-failed"), message: e.message });
} finally {
saving = false;
}
}
</script>
<svelte:head>
<title>
{testName} - {$t("tests.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;
{pluginName}
&ndash; {$t("tests.schedule.title")}
</h2>
<div class="d-flex gap-2">
<Button
color="secondary"
href={`/domains/${encodeURIComponent(data.domain.domain)}/tests`}
>
<Icon name="arrow-left"></Icon>
{$t("zones.return-to-tests")}
</Button>
<Button
color="info"
href={`/domains/${encodeURIComponent(data.domain.domain)}/tests/${encodeURIComponent(testName)}/results`}
>
<Icon name="bar-chart-fill"></Icon>
{$t("tests.list.view-results")}
</Button>
</div>
</div>
{#if loading}
<div class="mt-5 text-center flex-fill">
<Spinner />
<p>{$t("tests.list.loading")}</p>
</div>
{:else if loadError}
<Card body color="danger">
<p class="mb-0">
<Icon name="exclamation-triangle-fill"></Icon>
{$t("tests.list.error-loading", { error: loadError })}
</p>
</Card>
{:else if !test}
<Card body>
<p class="text-center text-muted mb-0">
<Icon name="info-circle"></Icon>
{$t("tests.list.no-tests")}
</p>
</Card>
{:else}
<Card class="mb-4">
<CardHeader>
<h4 class="mb-0">
<Icon name="clock-history"></Icon>
{$t("tests.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("tests.schedule.auto-enabled")}</Badge
>
{:else}
<Badge color="secondary"
>{$t("tests.schedule.auto-disabled")}</Badge
>
{/if}
</label>
</div>
</div>
{#if formEnabled}
<div class="mb-4">
<label for="schedule-interval" class="form-label fw-semibold">
{$t("tests.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("tests.schedule.hours")}
</span>
</div>
<div class="form-text">
{$t("tests.schedule.interval-hint")}
</div>
</div>
{/if}
{#if test.schedule}
<div class="mb-4">
<div class="row g-3">
{#if test.schedule.last_run}
<div class="col-auto">
<span class="text-muted fw-semibold">
{$t("tests.schedule.last-run")}:
</span>
<span>
{formatTestDate(test.schedule.last_run, "medium", $t)}
<small class="text-muted">
({formatRelative(test.schedule.last_run, $t)})
</small>
</span>
</div>
{/if}
{#if test.enabled && test.schedule.next_run}
<div class="col-auto">
<span class="text-muted fw-semibold">
{$t("tests.schedule.next-run")}:
</span>
<span>
{formatTestDate(test.schedule.next_run, "medium", $t)}
<small class="text-muted">
({formatRelative(test.schedule.next_run, $t)})
</small>
</span>
</div>
{/if}
</div>
</div>
{:else}
<p class="text-muted">
<Icon name="info-circle"></Icon>
{$t("tests.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("tests.schedule.save")}
</Button>
</CardBody>
</Card>
{/if}
</div>

View file

@ -0,0 +1,237 @@
<!--
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 { t } from "$lib/translations";
import { page } from "$app/state";
import { listTestResults, deleteTestResult, deleteAllTestResults } from "$lib/api/tests";
import { getPluginStatus } from "$lib/api/plugins";
import type { Domain } from "$lib/model/domain";
import RunTestModal from "$lib/components/modals/RunTestModal.svelte";
import { getStatusColor, getStatusKey, formatDuration, formatTestDate } from "$lib/utils";
interface Props {
data: { domain: Domain };
}
let { data }: Props = $props();
const testName = $derived(page.params.tname || "");
let resultsPromise = $derived(listTestResults(data.domain.id, testName));
let pluginPromise = $derived(getPluginStatus(testName));
let runTestModal: RunTestModal;
let errorMessage = $state<string | null>(null);
function handleTestTriggered() {
// Refresh results list after test is triggered
resultsPromise = listTestResults(data.domain.id, testName);
}
async function handleDeleteResult(resultId: string) {
if (!confirm($t("tests.results.delete-confirm"))) {
return;
}
try {
await deleteTestResult(data.domain.id, testName, resultId);
resultsPromise = listTestResults(data.domain.id, testName);
} catch (error: any) {
errorMessage = error.message || $t("tests.results.delete-failed");
}
}
async function handleDeleteAll() {
if (!confirm($t("tests.results.delete-all-confirm"))) {
return;
}
try {
await deleteAllTestResults(data.domain.id, testName);
resultsPromise = listTestResults(data.domain.id, testName);
} catch (error: any) {
errorMessage = error.message || $t("tests.results.delete-all-failed");
}
}
</script>
<svelte:head>
<title>{testName} 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 pluginPromise then plugin}
{plugin.name || testName}
{:catch}
{testName}
{/await}
</h2>
<div class="d-flex gap-2">
<Button
color="dark"
href={`/domains/${encodeURIComponent(data.domain.domain)}/tests/${encodeURIComponent(testName)}`}
>
<Icon name="gear-fill"></Icon>
{$t("tests.results.configure")}
</Button>
{#await pluginPromise then plugin}
<Button
color="primary"
onclick={() => runTestModal.open(testName, plugin.name || testName)}
>
<Icon name="play-fill"></Icon>
{$t("tests.results.run-test-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("tests.results.loading")}</p>
</div>
{:then results}
{#if !results || results.length === 0}
<Card body>
<p class="text-center text-muted mb-0">
<Icon name="info-circle"></Icon>
{$t("tests.results.no-results")}
</p>
</Card>
{:else}
<div class="d-flex justify-content-between align-items-center mb-2">
<h4>{$t("tests.results.title", { count: results.length })}</h4>
<Button size="sm" color="danger" outline onclick={handleDeleteAll}>
<Icon name="trash"></Icon>
{$t("tests.results.delete-all")}
</Button>
</div>
<Table hover striped>
<thead>
<tr>
<th>{$t("tests.results.table.executed-at")}</th>
<th class="text-center">{$t("tests.results.table.status")}</th>
<th>{$t("tests.results.table.message")}</th>
<th>{$t("tests.results.table.duration")}</th>
<th class="text-center">{$t("tests.results.table.type")}</th>
<th>{$t("tests.results.table.actions")}</th>
</tr>
</thead>
<tbody>
{#each results as result}
<tr>
<td class="align-middle">
{formatTestDate(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">
{#if result.scheduled_test}
<Badge color="info">
<Icon name="clock"></Icon>
{$t("tests.results.type.scheduled")}
</Badge>
{:else}
<Badge color="secondary">
<Icon name="hand-index"></Icon>
{$t("tests.results.type.manual")}
</Badge>
{/if}
</td>
<td class="align-middle">
<ButtonGroup size="sm">
<Button
color="primary"
href={`/domains/${encodeURIComponent(data.domain.domain)}/tests/${encodeURIComponent(testName)}/results/${encodeURIComponent(result.id)}`}
>
<Icon name="eye-fill"></Icon>
{$t("tests.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("tests.results.error-loading", { error: error.message })}
</p>
</Card>
{/await}
</div>
<RunTestModal
domainId={data.domain.id}
onTestTriggered={handleTestTriggered}
bind:this={runTestModal}
/>

View file

@ -0,0 +1,309 @@
<!--
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 { goto } from "$app/navigation";
import { getTestResult, deleteTestResult, triggerTest } from "$lib/api/tests";
import { getPluginStatus } from "$lib/api/plugins";
import type { Domain } from "$lib/model/domain";
import type { TestResult } from "$lib/model/test";
import { getStatusColor, getStatusKey, formatDuration, formatTestDate } from "$lib/utils";
interface Props {
data: { domain: Domain };
}
let { data }: Props = $props();
const testName = $derived(page.params.tname || "");
const resultId = $derived(page.params.rid || "");
let resultPromise = $derived(getTestResult(data.domain.id, testName, resultId));
let pluginPromise = $derived(getPluginStatus(testName));
let errorMessage = $state<string | null>(null);
let resolvedResult = $state<TestResult | null>(null);
let isRelaunching = $state(false);
$effect(() => {
resultPromise.then((r) => {
resolvedResult = r;
});
});
async function handleRelaunch() {
if (!resolvedResult) return;
isRelaunching = true;
try {
await triggerTest(data.domain.id, testName, resolvedResult.options);
goto(
`/domains/${encodeURIComponent(data.domain.domain)}/tests/${encodeURIComponent(testName)}`,
);
} catch (error: any) {
errorMessage = error.message || $t("tests.result.relaunch-failed");
} finally {
isRelaunching = false;
}
}
async function handleDelete() {
if (!confirm($t("tests.result.delete-confirm"))) {
return;
}
try {
await deleteTestResult(data.domain.id, testName, resultId);
goto(
`/domains/${encodeURIComponent(data.domain.domain)}/tests/${encodeURIComponent(testName)}`,
);
} catch (error: any) {
errorMessage = error.message || $t("tests.result.delete-failed");
}
}
</script>
<svelte:head>
<title>
Test Result - {testName} - {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("tests.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("tests.result.relaunch")}
</span>
</Button>
<Button color="danger" outline onclick={handleDelete} disabled={!resolvedResult}>
<Icon name="trash"></Icon>
<span class="d-none d-lg-inline">
{$t("tests.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, pluginPromise])}
<div class="mt-5 text-center flex-fill">
<Spinner />
<p>{$t("tests.result.loading")}</p>
</div>
{:then [result, plugin]}
<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">
{plugin.name || testName}
</h4>
{#if plugin.version}
<small
class="text-muted"
title={$t("tests.result.field.plugin-version")}
>
{plugin.version}
</small>
{/if}
</div>
{#if result.scheduled_test}
<Badge color="info">
<Icon name="clock"></Icon>
{$t("tests.result.type.scheduled")}
</Badge>
{:else}
<Badge color="secondary">
<Icon name="hand-index"></Icon>
{$t("tests.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("tests.result.field.domain")}</th>
<td class="font-monospace">{data.domain.domain}</td>
</tr>
<tr>
<th>{$t("tests.result.field.executed-at")}</th>
<td>{formatTestDate(result.executed_at, "long", $t)}</td>
</tr>
<tr>
<th>{$t("tests.result.field.duration")}</th>
<td>{formatDuration(result.duration, $t)}</td>
</tr>
<tr>
<th>{$t("tests.result.field.status")}</th>
<td>
<Badge color={getStatusColor(result.status)}>
{$t(getStatusKey(result.status))}
</Badge>
</td>
</tr>
<tr>
<th>{$t("tests.result.field.status-message")}</th>
<td>{result.status_line}</td>
</tr>
{#if result.error}
<tr>
<th>{$t("tests.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("tests.result.test-options")}
</h5>
</CardHeader>
<CardBody class="p-2">
<Table borderless size="sm" class="mb-0">
<tbody>
{#each Object.entries(plugin.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("tests.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("tests.result.error-loading", { error: error.message })}
</p>
</Card>
{/await}
</div>
<style>
pre {
overflow-x: scroll;
}
</style>

View file

@ -36,11 +36,16 @@
} from "@sveltestrap/sveltestrap";
import { t } from '$lib/translations';
import { listPlugins } from '$lib/api/plugins';
let pluginsPromise = $state(listPlugins());
import { plugins, refreshPlugins } from '$lib/stores/plugins';
let searchQuery = $state('');
// Load plugins if not already loaded
$effect(() => {
if ($plugins === undefined) {
refreshPlugins();
}
});
</script>
<svelte:head>
@ -58,9 +63,9 @@
<span class="lead">
{$t('plugins.tests.description')}
</span>
{#await pluginsPromise then plugins}
<span>{$t('plugins.tests.available-count', { count: Object.keys(plugins ?? {}).length })}</span>
{/await}
{#if $plugins}
<span>{$t('plugins.tests.available-count', { count: Object.keys($plugins).length })}</span>
{/if}
</p>
</Col>
</Row>
@ -80,14 +85,14 @@
</Col>
</Row>
{#await pluginsPromise}
{#if !$plugins}
<Card body>
<p class="text-center mb-0">
<span class="spinner-border spinner-border-sm me-2"></span>
{$t('plugins.tests.loading')}
</p>
</Card>
{:then plugins}
{:else}
<div class="table-responsive">
<Table hover bordered>
<thead>
@ -99,14 +104,14 @@
</tr>
</thead>
<tbody>
{#if !plugins || Object.keys(plugins).length == 0}
{#if Object.keys($plugins).length == 0}
<tr>
<td colspan="4" class="text-center text-muted py-4">
{$t('plugins.tests.no-tests')}
</td>
</tr>
{:else}
{#each Object.entries(plugins ?? {}).filter(([name, _info]) => name.toLowerCase().indexOf(searchQuery.toLowerCase()) > -1) as [pluginName, pluginInfo]}
{#each Object.entries($plugins).filter(([name, _info]) => name.toLowerCase().indexOf(searchQuery.toLowerCase()) > -1) as [pluginName, pluginInfo]}
<tr>
<td><strong>{pluginInfo.name || pluginName}</strong></td>
<td>{pluginInfo.version}</td>
@ -141,12 +146,5 @@
</tbody>
</Table>
</div>
{:catch error}
<Card body color="danger">
<p class="mb-0">
<Icon name="exclamation-triangle-fill"></Icon>
{$t('plugins.tests.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>
@ -239,7 +246,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">