Compare commits

..

13 commits

Author SHA1 Message Date
4c7c3b4568 Add admin API and frontend for scheduler management
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-11 12:43:03 +07:00
30fdbff8a3 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.
2026-02-11 12:42:15 +07:00
cde9d405f4 web: Replace page data flags with route ID checks for history/logs pages 2026-02-11 12:42:15 +07:00
262da7bb0e Implement tests scheduler 2026-02-11 12:42:13 +07:00
5b4dd01a13 Implement backend model for test results and schedule 2026-02-11 12:38:38 +07:00
cac9419947 Add plugin interface: api routes and frontend to manage user plugins 2026-02-10 10:51:51 +07:00
7c2ee9ad8f Add test plugin routes to API + refactor plugins controller 2026-02-10 10:34:43 +07:00
34d72cc178 web-admin: Implement plugins interface with option editor 2026-02-10 10:34:43 +07:00
cb8bb2f9a1 Implement plugin options retrieval 2026-02-10 10:34:43 +07:00
a256d2efe2 Add usescases to handle test plugins 2026-02-10 10:34:43 +07:00
8f8cf8db7c plugins: Refactor with intermediate structs 2026-02-10 10:34:43 +07:00
82e30391ee Add a test plugin for Matrix Federation 2026-02-10 10:34:43 +07:00
6a68bafdbb Load tests plugins 2026-02-10 10:34:43 +07:00
20 changed files with 339 additions and 413 deletions

View file

@ -42,14 +42,12 @@ func NewTestScheduleController(testScheduleUC happydns.TestScheduleUsecase) *Tes
}
}
// ListTestSchedules retrieves schedules for the authenticated user
// ListTestSchedules retrieves all schedules for the authenticated user
//
// @Summary List test schedules
// @Description Retrieves test schedules for the authenticated user with optional pagination
// @Description Retrieves all test schedules for the authenticated user
// @Tags test-schedules
// @Produce json
// @Param limit query int false "Maximum number of schedules to return (0 = all)"
// @Param offset query int false "Number of schedules to skip (default: 0)"
// @Success 200 {array} happydns.TestSchedule
// @Failure 500 {object} happydns.ErrorResponse
// @Router /plugins/tests/schedules [get]
@ -62,20 +60,6 @@ func (tc *TestScheduleController) ListTestSchedules(c *gin.Context) {
return
}
// Apply pagination
limit := 0
offset := 0
fmt.Sscanf(c.Query("limit"), "%d", &limit)
fmt.Sscanf(c.Query("offset"), "%d", &offset)
if offset > len(schedules) {
offset = len(schedules)
}
schedules = schedules[offset:]
if limit > 0 && len(schedules) > limit {
schedules = schedules[:limit]
}
c.JSON(http.StatusOK, schedules)
}

View file

@ -28,13 +28,18 @@ import (
"git.happydns.org/happyDomain/model"
)
// testSchedulerProvider is an interface for getting the test scheduler from dependencies
type testSchedulerProvider interface {
TestScheduler() controller.TestSchedulerInterface
}
// DeclareScopedTestResultRoutes declares test result routes for a specific scope (domain, zone, or service)
func DeclareScopedTestResultRoutes(
scopedRouter *gin.RouterGroup,
dependancies happydns.UsecaseDependancies,
scope happydns.TestScopeType,
) {
testScheduler := dependancies.TestScheduler()
testScheduler := dependancies.(testSchedulerProvider).TestScheduler()
tc := controller.NewTestResultController(
scope,

View file

@ -531,21 +531,13 @@ func (s *testScheduler) RescheduleUpcomingTests() (int, error) {
return s.scheduleUsecase.RescheduleUpcomingTests()
}
// cleanup removes old execution records and expired test results
// cleanup removes old execution records
func (s *testScheduler) cleanup() {
// This is a placeholder for cleanup logic
// In a full implementation, you'd clean up:
// - Old completed executions
// - Expired test results beyond retention
log.Println("Running scheduler cleanup...")
// Delete completed/failed execution records older than 7 days
if err := s.resultUsecase.DeleteCompletedExecutions(7 * 24 * time.Hour); err != nil {
log.Printf("Error cleaning up old executions: %v\n", err)
}
// Delete test results older than the configured retention period
if err := s.resultUsecase.CleanupOldResults(); err != nil {
log.Printf("Error cleaning up old test results: %v\n", err)
}
log.Println("Scheduler cleanup complete")
}
// worker.run processes tests from the queue
@ -624,25 +616,6 @@ func (w *worker) executeTest(item *queueItem) {
return
}
// Merge options: global defaults < user opts < domain/service opts < schedule opts
var domainId, serviceId *happydns.Identifier
switch schedule.TargetType {
case happydns.TestScopeDomain:
domainId = &schedule.TargetId
case happydns.TestScopeService:
serviceId = &schedule.TargetId
}
baseOptions, err := w.scheduler.pluginUsecase.GetTestPluginOptions(schedule.PluginName, &schedule.OwnerId, domainId, serviceId)
if err != nil {
log.Printf("Worker %d: warning, could not fetch plugin options for %s: %v\n", w.id, schedule.PluginName, err)
}
var mergedOptions happydns.PluginOptions
if baseOptions != nil {
mergedOptions = w.scheduler.scheduleUsecase.MergePluginOptions(nil, nil, *baseOptions, schedule.Options)
} else {
mergedOptions = schedule.Options
}
// Prepare metadata
meta := make(map[string]string)
meta["target_type"] = schedule.TargetType.String()
@ -654,12 +627,7 @@ func (w *worker) executeTest(item *queueItem) {
errorChan := make(chan error, 1)
go func() {
defer func() {
if r := recover(); r != nil {
errorChan <- fmt.Errorf("plugin panicked: %v", r)
}
}()
result, err := plugin.RunTest(mergedOptions, meta)
result, err := plugin.RunTest(schedule.Options, meta)
if err != nil {
errorChan <- err
} else {

View file

@ -179,31 +179,6 @@ func (s *KVStorage) DeleteOldTestResults(pluginName string, targetType happydns.
return nil
}
// DeleteTestResultsBefore removes all test results with ExecutedAt older than cutoff
func (s *KVStorage) DeleteTestResultsBefore(cutoff time.Time) error {
iter := s.db.Search("testresult|")
defer iter.Release()
var toDelete []string
for iter.Next() {
var r happydns.TestResult
if err := s.db.DecodeData(iter.Value(), &r); err != nil {
continue
}
if r.ExecutedAt.Before(cutoff) {
toDelete = append(toDelete, string(iter.Key()))
}
}
for _, key := range toDelete {
if err := s.db.Delete(key); err != nil {
return err
}
}
return nil
}
// Test Schedule storage keys:
// testschedule|{schedule-id}
// testschedule.byuser|{user-id}|{schedule-id}
@ -435,34 +410,6 @@ func (s *KVStorage) DeleteTestExecution(executionId happydns.Identifier) error {
return s.db.Delete(key)
}
// DeleteCompletedExecutionsBefore removes completed or failed execution records older than cutoff
func (s *KVStorage) DeleteCompletedExecutionsBefore(cutoff time.Time) error {
iter := s.db.Search("testexec|")
defer iter.Release()
var toDelete []string
for iter.Next() {
var exec happydns.TestExecution
if err := s.db.DecodeData(iter.Value(), &exec); err != nil {
continue
}
if exec.Status != happydns.TestExecutionCompleted && exec.Status != happydns.TestExecutionFailed {
continue
}
if exec.CompletedAt != nil && exec.CompletedAt.Before(cutoff) {
toDelete = append(toDelete, string(iter.Key()))
}
}
for _, key := range toDelete {
if err := s.db.Delete(key); err != nil {
return err
}
}
return nil
}
// Scheduler state storage key:
// testscheduler.lastrun

View file

@ -51,9 +51,6 @@ type TestResultStorage interface {
// DeleteOldTestResults removes old test results keeping only the most recent N results
DeleteOldTestResults(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier, keepCount int) error
// DeleteTestResultsBefore removes all test results older than the given time
DeleteTestResultsBefore(cutoff time.Time) error
// Test Schedules
// ListEnabledTestSchedules retrieves all enabled schedules (for scheduler)
ListEnabledTestSchedules() ([]*happydns.TestSchedule, error)
@ -92,9 +89,6 @@ type TestResultStorage interface {
// DeleteTestExecution removes an execution record
DeleteTestExecution(executionId happydns.Identifier) error
// DeleteCompletedExecutionsBefore removes completed or failed execution records older than the given time
DeleteCompletedExecutionsBefore(cutoff time.Time) error
// Scheduler State
// TestSchedulerRun marks that the scheduler has run at current time
TestSchedulerRun() error

View file

@ -120,7 +120,7 @@ func (u *TestResultUsecase) DeleteAllTestResults(pluginName string, targetType h
return nil
}
// CleanupOldResults removes test results older than the configured retention period
// CleanupOldResults removes test results older than retention period
func (u *TestResultUsecase) CleanupOldResults() error {
retentionDays := u.options.ResultRetentionDays
if retentionDays <= 0 {
@ -128,7 +128,16 @@ func (u *TestResultUsecase) CleanupOldResults() error {
}
cutoffTime := time.Now().AddDate(0, 0, -retentionDays)
return u.storage.DeleteTestResultsBefore(cutoffTime)
// Get all results for all users (inefficient but necessary without a time-based index)
// In a production system, you might want to add a time-based index for this
// For now, we'll iterate through results and delete old ones
// This is a placeholder - the actual implementation would need to be optimized
// based on specific storage patterns
_ = cutoffTime
return nil
}
// GetTestExecution retrieves the status of a test execution
@ -199,8 +208,15 @@ func (u *TestResultUsecase) FailTestExecution(executionId happydns.Identifier, e
return u.storage.UpdateTestExecution(execution)
}
// DeleteCompletedExecutions removes completed or failed execution records older than olderThan
// DeleteCompletedExecutions removes execution records that are completed
func (u *TestResultUsecase) DeleteCompletedExecutions(olderThan time.Duration) error {
cutoffTime := time.Now().Add(-olderThan)
return u.storage.DeleteCompletedExecutionsBefore(cutoffTime)
// Get active executions (this won't include completed ones)
// We need a different query to get completed executions older than cutoff
// For now, this is a placeholder
_ = cutoffTime
return nil
}

View file

@ -61,7 +61,7 @@ type PluginVersionInfo struct {
type PluginAvailability struct {
ApplyToDomain bool `json:"applyToDomain,omitempty"`
ApplyToService bool `json:"applyToService,omitempty"`
ApplyToService bool `json:"applyToDomain,omitempty"`
LimitToProviders []string `json:"limitToProviders,omitempty"`
LimitToServices []string `json:"limitToServices,omitempty"`
}

View file

@ -300,7 +300,6 @@ type TestScheduleUsecase interface {
// AdminSchedulerUsecase is satisfied by both testScheduler and disabledScheduler
type AdminSchedulerUsecase interface {
TriggerOnDemandTest(pluginName string, targetType TestScopeType, targetID Identifier, userID Identifier, options PluginOptions) (Identifier, error)
GetSchedulerStatus() SchedulerStatus
SetEnabled(enabled bool) error
RescheduleUpcomingTests() (int, error)

View file

@ -34,30 +34,29 @@
Form,
FormGroup,
Icon,
Label,
Row,
} from "@sveltestrap/sveltestrap";
import { page } from '$app/state';
import { page } from '$app/stores';
import { t } from '$lib/translations';
import { toasts } from '$lib/stores/toasts';
import {
getPluginsTestsByPname,
getPluginsTestsByPnameOptions,
putPluginsTestsByPnameOptions,
} from '$lib/api-admin';
import { getPluginStatus } from '$lib/api/plugins';
import Resource from '$lib/components/inputs/Resource.svelte';
import PluginOptionsGroups from '$lib/components/plugins/PluginOptionsGroups.svelte';
let pname = $derived(page.params.pname!);
let pname = $derived($page.params.pname!);
let pluginStatusQ = $derived(getPluginStatus(pname));
let pluginStatusQ = $derived(getPluginsTestsByPname({ path: { pname } }));
let pluginOptionsQ = $derived(getPluginsTestsByPnameOptions({ path: { pname } }));
let optionValues = $state<Record<string, any>>({});
let saving = $state(false);
$effect(() => {
pluginOptionsQ.then((optionsR) => {
optionValues = { ...(optionsR.data as Record<string, unknown> || {}) };
optionValues = { ...(optionsR.data || {}) };
});
});
@ -70,13 +69,13 @@
});
pluginOptionsQ = getPluginsTestsByPnameOptions({ path: { pname } });
toasts.addToast({
message: $t("plugins.tests.messages.options-updated"),
message: `Plugin options updated successfully`,
type: 'success',
timeout: 5000,
});
} catch (error) {
toasts.addErrorToast({
message: $t("plugins.tests.messages.update-failed", { error: String(error) }),
message: 'Failed to update options: ' + error,
timeout: 10000,
});
} finally {
@ -102,13 +101,13 @@
});
pluginOptionsQ = getPluginsTestsByPnameOptions({ path: { pname } });
toasts.addToast({
message: $t("plugins.tests.messages.options-cleaned"),
message: `Orphaned options removed successfully`,
type: 'success',
timeout: 5000,
});
} catch (error) {
toasts.addErrorToast({
message: $t("plugins.tests.messages.clean-failed", { error: String(error) }),
message: 'Failed to clean options: ' + error,
timeout: 10000,
});
} finally {
@ -127,7 +126,7 @@
<Col>
<Button color="link" href="/plugins" class="mb-2">
<Icon name="arrow-left"></Icon>
{$t("plugins.tests.back-button")}
Back to Plugins
</Button>
<h1 class="display-5">
<Icon name="puzzle-fill"></Icon>
@ -137,63 +136,49 @@
</Row>
{#await pluginStatusQ}
<Card body>
<p class="text-center mb-0">
<span class="spinner-border spinner-border-sm me-2"></span>
{$t("plugins.tests.loading-info")}
</p>
</Card>
{:then status}
<p>Loading plugin status...</p>
{:then statusR}
{@const status = statusR.data}
{#if status}
<Row class="mb-4">
<Col md={6}>
<Card>
<CardHeader>
<strong>{$t("plugins.tests.detail.test-information")}</strong>
<strong>Plugin Information</strong>
</CardHeader>
<CardBody>
<dl class="row mb-0">
<dt class="col-sm-4">{$t("plugins.tests.detail.name")}</dt>
<dt class="col-sm-4">Name:</dt>
<dd class="col-sm-8">{status.name}</dd>
<dt class="col-sm-4">{$t("plugins.tests.detail.version")}</dt>
<dt class="col-sm-4">Version:</dt>
<dd class="col-sm-8">{status.version}</dd>
<dt class="col-sm-4">{$t("plugins.tests.detail.availability")}</dt>
<dt class="col-sm-4">Availability:</dt>
<dd class="col-sm-8">
{#if status.availableOn}
<div class="d-flex flex-wrap gap-1">
{#if status.availableOn.applyToDomain}
<Badge color="success"
>{$t("plugins.tests.availability.domain-level")}</Badge
>
<Badge color="success">Domain-level</Badge>
{/if}
{#if status.availableOn.limitToProviders && status.availableOn.limitToProviders.length > 0}
<Badge color="primary">
{$t("plugins.tests.availability.providers", {
providers: status.availableOn.limitToProviders.join(', '),
})}
Providers: {status.availableOn.limitToProviders.join(', ')}
</Badge>
{/if}
{#if status.availableOn.limitToServices && status.availableOn.limitToServices.length > 0}
<Badge color="info">
{$t("plugins.tests.availability.services", {
services: status.availableOn.limitToServices.join(', '),
})}
Services: {status.availableOn.limitToServices.join(', ')}
</Badge>
{/if}
{#if !status.availableOn.applyToDomain &&
(!status.availableOn.limitToProviders || status.availableOn.limitToProviders.length === 0) &&
(!status.availableOn.limitToServices || status.availableOn.limitToServices.length === 0)}
<Badge color="secondary"
>{$t("plugins.tests.availability.general")}</Badge
>
<Badge color="secondary">General</Badge>
{/if}
</div>
{:else}
<Badge color="secondary"
>{$t("plugins.tests.availability.general")}</Badge
>
<Badge color="secondary">General</Badge>
{/if}
</dd>
</dl>
@ -205,20 +190,18 @@
{#await pluginOptionsQ}
<Card>
<CardBody>
<p class="text-center mb-0">
<span class="spinner-border spinner-border-sm me-2"></span>
{$t("plugins.tests.detail.loading-options")}
</p>
<p>Loading options...</p>
</CardBody>
</Card>
{:then _optionsR}
{:then optionsR}
{@const options = optionsR.data}
{@const adminOpts = status.options?.adminOpts || []}
{@const readOnlyOptGroups = [
{ label: $t("plugins.tests.option-groups.global-settings"), opts: status.options?.userOpts || [] },
{ label: $t("plugins.tests.option-groups.domain-settings"), opts: status.options?.domainOpts || [] },
{ label: $t("plugins.tests.option-groups.service-settings"), opts: status.options?.serviceOpts || [] },
{ label: $t("plugins.tests.option-groups.test-parameters"), opts: status.options?.runOpts || [] },
]}
{ key: 'userOpts', label: 'User Options', opts: status.options?.userOpts || [] },
{ key: 'domainOpts', label: 'Domain Options', opts: status.options?.domainOpts || [] },
{ key: 'serviceOpts', label: 'Service Options', opts: status.options?.serviceOpts || [] },
{ key: 'runOpts', label: 'Run Options', opts: status.options?.runOpts || [] }
]}
{@const hasAnyOpts = adminOpts.length > 0 || readOnlyOptGroups.some(g => g.opts.length > 0)}
{@const orphanedOpts = getOrphanedOptions(adminOpts)}
@ -227,18 +210,16 @@
<div class="d-flex justify-content-between align-items-center">
<div>
<Icon name="exclamation-triangle-fill"></Icon>
{$t("plugins.tests.detail.orphaned-options", {
options: orphanedOpts.join(', '),
})}
<strong>Orphaned options detected:</strong> {orphanedOpts.join(', ')}
</div>
<Button
color="danger"
size="sm"
onclick={() => cleanOrphanedOptions(adminOpts)}
disabled={saving}
disabled={saving}
>
<Icon name="trash"></Icon>
{$t("plugins.tests.detail.clean-up")}
Clean Up
</Button>
</div>
</Alert>
@ -247,7 +228,7 @@
{#if adminOpts.length > 0}
<Card class="mb-3">
<CardHeader>
<strong>{$t("plugins.tests.detail.configuration")}</strong>
<strong>Admin Options</strong>
</CardHeader>
<CardBody>
<Form on:submit={saveOptions}>
@ -271,7 +252,7 @@
<span class="spinner-border spinner-border-sm me-1"></span>
{/if}
<Icon name="check-circle"></Icon>
{$t("plugins.tests.detail.save-changes")}
Save Changes
</Button>
</div>
</Form>
@ -279,14 +260,41 @@
</Card>
{/if}
<PluginOptionsGroups groups={readOnlyOptGroups} t={$t} />
{#each readOnlyOptGroups as optGroup}
{#if optGroup.opts.length > 0}
<Card class="mb-3">
<CardHeader>
<strong>{optGroup.label}</strong>
</CardHeader>
<CardBody>
<dl class="row mb-0">
{#each optGroup.opts as optDoc}
<dt class="col-sm-4">{optDoc.label || optDoc.id}:</dt>
<dd class="col-sm-8">
{#if optDoc.default}
<span class="text-muted d-block">{optDoc.default}</span>
{:else if optDoc.placeholder}
<em class="text-muted d-block">{optDoc.placeholder}</em>
{/if}
{#if optDoc.description}
<small class="text-muted d-block">{optDoc.description}</small>
{/if}
<small class="text-muted">Type: {optDoc.type || 'string'}</small>
{#if optDoc.required}<small class="text-muted">Required</small>{/if}
</dd>
{/each}
</dl>
</CardBody>
</Card>
{/if}
{/each}
{#if !hasAnyOpts}
<Card>
<CardBody>
<Alert color="info" class="mb-0">
<Alert color="info">
<Icon name="info-circle"></Icon>
{$t("plugins.tests.detail.no-configurable-options")}
This plugin has no configurable options.
</Alert>
</CardBody>
</Card>
@ -294,11 +302,9 @@
{:catch error}
<Card>
<CardBody>
<Alert color="danger" class="mb-0">
<Alert color="danger">
<Icon name="exclamation-triangle-fill"></Icon>
{$t("plugins.tests.detail.error-loading-options", {
error: error.message,
})}
Error loading options: {error.message}
</Alert>
</CardBody>
</Card>
@ -308,13 +314,13 @@
{:else}
<Alert color="danger">
<Icon name="exclamation-triangle-fill"></Icon>
{$t("plugins.tests.test-info-not-found")}
Error: Plugin data not found
</Alert>
{/if}
{:catch error}
<Alert color="danger">
<Icon name="exclamation-triangle-fill"></Icon>
{$t("plugins.tests.error-loading-test", { error: error.message })}
Error loading plugin: {error.message}
</Alert>
{/await}
</Container>

View file

@ -1,89 +0,0 @@
<!--
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, CardBody, CardHeader } from "@sveltestrap/sveltestrap";
interface OptionDef {
id?: string;
label?: string;
type?: string;
default?: unknown;
placeholder?: string;
description?: string;
required?: boolean;
}
interface OptionGroup {
label: string;
opts: OptionDef[];
}
interface Props {
groups: OptionGroup[];
t: (key: string, params?: object) => string;
}
let { groups, t }: Props = $props();
</script>
{#each groups as optGroup}
{#if optGroup.opts.length > 0}
<Card class="mb-3">
<CardHeader>
<strong>{optGroup.label}</strong>
<small class="text-muted ms-2">{t("plugins.tests.detail.read-only")}</small>
</CardHeader>
<CardBody>
<dl class="row mb-0">
{#each optGroup.opts as optDoc}
{@const optName = optDoc.id!}
<dt class="col-sm-4">
{optDoc.label || optDoc.id}:
</dt>
<dd class="col-sm-8">
{#if optDoc.default}
<span class="text-muted d-block">{optDoc.default}</span>
{:else if optDoc.placeholder}
<em class="text-muted d-block">{optDoc.placeholder}</em>
{/if}
{#if optDoc.description}
<small class="text-muted d-block">{optDoc.description}</small>
{/if}
<small class="text-muted"
>{t("plugins.tests.option-groups.type", {
type: optDoc.type || "string",
})}</small
>
{#if optDoc.required}
<small class="text-danger ms-2"
>{t("plugins.tests.option-groups.required")}</small
>
{/if}
</dd>
{/each}
</dl>
</CardBody>
</Card>
{/if}
{/each}

View file

@ -550,12 +550,6 @@
},
"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",
@ -714,8 +708,7 @@
"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": {

View file

@ -481,14 +481,6 @@
"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": {
@ -549,8 +541,7 @@
"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

@ -31,59 +31,3 @@ 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,5 +2,4 @@
* Centralized utility exports
*/
export { toDatetimeLocal, fromDatetimeLocal, formatTestDate, formatRelative } from './datetime';
export { getStatusColor, getStatusKey, formatDuration } from './test';
export { toDatetimeLocal, fromDatetimeLocal } from './datetime';

View file

@ -1,39 +0,0 @@
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

@ -29,11 +29,9 @@
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 { PluginResultStatus, 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 };
@ -75,8 +73,8 @@
});
}
testsPromise = listAvailableTests(data.domain.id);
} catch (e: any) {
toasts.addErrorToast({ title: $t("tests.list.error-loading", { error: e.message }) });
} catch {
// toggle reverts visually on refresh; nothing extra needed
} finally {
const after = new Set(togglingTests);
after.delete(test.plugin_name);
@ -84,6 +82,43 @@
}
}
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";
}
}
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";
}
}
function formatDate(dateString?: string): string {
if (!dateString) return $t("tests.never");
return new Intl.DateTimeFormat(undefined, {
dateStyle: "short",
timeStyle: "short",
}).format(new Date(dateString));
}
</script>
<svelte:head>
@ -144,7 +179,7 @@
{/if}
</td>
<td class="align-middle">
{formatTestDate(test.last_result?.executed_at, "short", $t)}
{formatDate(test.last_result?.executed_at)}
</td>
<td class="align-middle">
<div class="form-check form-switch mb-0">

View file

@ -40,7 +40,6 @@
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 };
@ -84,6 +83,42 @@
loadTest();
function formatDate(dateString?: 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: "medium",
timeStyle: "short",
}).format(d);
}
function formatRelative(dateString?: 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 ? "in less than a minute" : "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 ? `in ${label}` : `${label} ago`;
}
async function handleSave() {
if (!test) return;
saving = true;
@ -234,9 +269,9 @@
{$t("tests.schedule.last-run")}:
</span>
<span>
{formatTestDate(test.schedule.last_run, "medium", $t)}
{formatDate(test.schedule.last_run)}
<small class="text-muted">
({formatRelative(test.schedule.last_run, $t)})
({formatRelative(test.schedule.last_run)})
</small>
</span>
</div>
@ -247,9 +282,9 @@
{$t("tests.schedule.next-run")}:
</span>
<span>
{formatTestDate(test.schedule.next_run, "medium", $t)}
{formatDate(test.schedule.next_run)}
<small class="text-muted">
({formatRelative(test.schedule.next_run, $t)})
({formatRelative(test.schedule.next_run)})
</small>
</span>
</div>

View file

@ -38,8 +38,8 @@
import { listTestResults, deleteTestResult, deleteAllTestResults } from "$lib/api/tests";
import { getPluginStatus } from "$lib/api/plugins";
import type { Domain } from "$lib/model/domain";
import { PluginResultStatus } from "$lib/model/test";
import RunTestModal from "$lib/components/modals/RunTestModal.svelte";
import { getStatusColor, getStatusKey, formatDuration, formatTestDate } from "$lib/utils";
interface Props {
data: { domain: Domain };
@ -54,6 +54,50 @@
let runTestModal: RunTestModal;
let errorMessage = $state<string | null>(null);
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";
}
}
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";
}
}
function formatDate(dateString: string): string {
return new Intl.DateTimeFormat(undefined, {
dateStyle: "short",
timeStyle: "medium",
}).format(new Date(dateString));
}
function formatDuration(duration?: number): string {
if (!duration) return $t("tests.na");
const seconds = duration / 1000000000;
if (seconds < 1) return `${(seconds * 1000).toFixed(0)}ms`;
return `${seconds.toFixed(2)}s`;
}
function handleTestTriggered() {
// Refresh results list after test is triggered
resultsPromise = listTestResults(data.domain.id, testName);
@ -167,7 +211,7 @@
{#each results as result}
<tr>
<td class="align-middle">
{formatTestDate(result.executed_at, "short", $t)}
{formatDate(result.executed_at)}
</td>
<td class="align-middle text-center">
<Badge color={getStatusColor(result.status)}>
@ -182,7 +226,7 @@
{/if}
</td>
<td class="align-middle">
{formatDuration(result.duration, $t)}
{formatDuration(result.duration)}
</td>
<td class="align-middle text-center">
{#if result.scheduled_test}

View file

@ -42,8 +42,7 @@
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";
import { PluginResultStatus } from "$lib/model/test";
interface Props {
data: { domain: Domain };
@ -57,7 +56,7 @@
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 resolvedResult = $state<import("$lib/model/test").TestResult | null>(null);
let isRelaunching = $state(false);
$effect(() => {
@ -66,6 +65,50 @@
});
});
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";
}
}
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";
}
}
function formatDate(dateString: string): string {
return new Intl.DateTimeFormat(undefined, {
dateStyle: "long",
timeStyle: "medium",
}).format(new Date(dateString));
}
function formatDuration(duration?: number): 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")}`;
}
async function handleRelaunch() {
if (!resolvedResult) return;
@ -191,11 +234,11 @@
</tr>
<tr>
<th>{$t("tests.result.field.executed-at")}</th>
<td>{formatTestDate(result.executed_at, "long", $t)}</td>
<td>{formatDate(result.executed_at)}</td>
</tr>
<tr>
<th>{$t("tests.result.field.duration")}</th>
<td>{formatDuration(result.duration, $t)}</td>
<td>{formatDuration(result.duration)}</td>
</tr>
<tr>
<th>{$t("tests.result.field.status")}</th>

View file

@ -36,15 +36,14 @@
Icon,
Row,
} from "@sveltestrap/sveltestrap";
import { page } from "$app/state";
import { page } from "$app/stores";
import { t } from "$lib/translations";
import { toasts } from "$lib/stores/toasts";
import { getPluginStatus, getPluginOptions, updatePluginOptions } from "$lib/api/plugins";
import Resource from "$lib/components/inputs/Resource.svelte";
import PluginOptionsGroups from "$lib/components/plugins/PluginOptionsGroups.svelte";
let pid = $derived(page.params.pid!);
let pid = $derived($page.params.pid!);
let pluginStatusPromise = $derived(getPluginStatus(pid));
let pluginOptionsPromise = $derived(getPluginOptions(pid));
@ -312,7 +311,59 @@
</Card>
{/if}
<PluginOptionsGroups groups={readOnlyOptGroups} t={$t} />
{#each readOnlyOptGroups as optGroup}
{#if optGroup.opts.length > 0}
<Card class="mb-3">
<CardHeader>
<strong>{optGroup.label}</strong>
<small class="text-muted ms-2"
>{$t("plugins.tests.detail.read-only")}</small
>
</CardHeader>
<CardBody>
<dl class="row mb-0">
{#each optGroup.opts as optDoc}
{@const optName = optDoc.id!}
<dt class="col-sm-4">
{optDoc.label || optDoc.id}:
</dt>
<dd class="col-sm-8">
{#if optionValues[optName]}
<span class="text-muted d-block"
>{optionValues[optName]}</span
>
{:else if optDoc.default}
<span class="text-muted d-block"
>{optDoc.default}</span
>
{:else if optDoc.placeholder}
<em class="text-muted d-block"
>{optDoc.placeholder}</em
>
{/if}
{#if optDoc.description}
<small class="text-muted d-block"
>{optDoc.description}</small
>
{/if}
<small class="text-muted"
>{$t("plugins.tests.option-groups.type", {
type: optDoc.type || "string",
})}</small
>
{#if optDoc.required}<small
class="text-danger ms-2"
>{$t(
"plugins.tests.option-groups.required",
)}</small
>{/if}
</dd>
{/each}
</dl>
</CardBody>
</Card>
{/if}
{/each}
{#if !hasAnyOpts}
<Card>