web: Refactor frontend utilities and eliminate duplicate code
Some checks failed
continuous-integration/drone/push Build is failing

Extract shared test utilities (getStatusColor, getStatusKey, formatDuration)
into web/src/lib/utils/test.ts and extend datetime.ts with formatTestDate and
formatRelative (with full i18n support). Remove 4 sets of duplicate functions
across the tests/* pages, fix $app/stores → $app/state in plugins/[pid], and
fix inline type import in results/[rid].

Create PluginOptionsGroups.svelte shared component for read-only option group
rendering, used by both web and web-admin plugin detail pages. Update
web-admin/src/routes/plugins/[pname]/+page.svelte to use i18n translations
throughout, replacing all hardcoded English strings.

Add i18n keys: tests.relative.{in,ago,just-now,in-less-than-a-minute} and
plugins.tests.back-button to en.json and fr.json.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
nemunaire 2026-02-11 18:58:51 +07:00
commit b541809470
12 changed files with 291 additions and 304 deletions

View file

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

View file

@ -0,0 +1,89 @@
<!--
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,6 +550,12 @@
},
"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",
@ -708,7 +714,8 @@
"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,6 +481,14 @@
"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": {
@ -541,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

@ -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

@ -29,9 +29,11 @@
import { t } from "$lib/translations";
import { listAvailableTests, updateTestSchedule, createTestSchedule } from "$lib/api/tests";
import type { Domain } from "$lib/model/domain";
import { PluginResultStatus, TestScopeType, type AvailableTest } from "$lib/model/test";
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 };
@ -73,8 +75,8 @@
});
}
testsPromise = listAvailableTests(data.domain.id);
} catch {
// toggle reverts visually on refresh; nothing extra needed
} 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);
@ -82,43 +84,6 @@
}
}
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>
@ -179,7 +144,7 @@
{/if}
</td>
<td class="align-middle">
{formatDate(test.last_result?.executed_at)}
{formatTestDate(test.last_result?.executed_at, "short", $t)}
</td>
<td class="align-middle">
<div class="form-check form-switch mb-0">

View file

@ -40,6 +40,7 @@
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 };
@ -83,42 +84,6 @@
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;
@ -269,9 +234,9 @@
{$t("tests.schedule.last-run")}:
</span>
<span>
{formatDate(test.schedule.last_run)}
{formatTestDate(test.schedule.last_run, "medium", $t)}
<small class="text-muted">
({formatRelative(test.schedule.last_run)})
({formatRelative(test.schedule.last_run, $t)})
</small>
</span>
</div>
@ -282,9 +247,9 @@
{$t("tests.schedule.next-run")}:
</span>
<span>
{formatDate(test.schedule.next_run)}
{formatTestDate(test.schedule.next_run, "medium", $t)}
<small class="text-muted">
({formatRelative(test.schedule.next_run)})
({formatRelative(test.schedule.next_run, $t)})
</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,50 +54,6 @@
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);
@ -211,7 +167,7 @@
{#each results as result}
<tr>
<td class="align-middle">
{formatDate(result.executed_at)}
{formatTestDate(result.executed_at, "short", $t)}
</td>
<td class="align-middle text-center">
<Badge color={getStatusColor(result.status)}>
@ -226,7 +182,7 @@
{/if}
</td>
<td class="align-middle">
{formatDuration(result.duration)}
{formatDuration(result.duration, $t)}
</td>
<td class="align-middle text-center">
{#if result.scheduled_test}

View file

@ -42,7 +42,8 @@
import { getTestResult, deleteTestResult, triggerTest } from "$lib/api/tests";
import { getPluginStatus } from "$lib/api/plugins";
import type { Domain } from "$lib/model/domain";
import { PluginResultStatus } from "$lib/model/test";
import type { TestResult } from "$lib/model/test";
import { getStatusColor, getStatusKey, formatDuration, formatTestDate } from "$lib/utils";
interface Props {
data: { domain: Domain };
@ -56,7 +57,7 @@
let resultPromise = $derived(getTestResult(data.domain.id, testName, resultId));
let pluginPromise = $derived(getPluginStatus(testName));
let errorMessage = $state<string | null>(null);
let resolvedResult = $state<import("$lib/model/test").TestResult | null>(null);
let resolvedResult = $state<TestResult | null>(null);
let isRelaunching = $state(false);
$effect(() => {
@ -65,50 +66,6 @@
});
});
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;
@ -234,11 +191,11 @@
</tr>
<tr>
<th>{$t("tests.result.field.executed-at")}</th>
<td>{formatDate(result.executed_at)}</td>
<td>{formatTestDate(result.executed_at, "long", $t)}</td>
</tr>
<tr>
<th>{$t("tests.result.field.duration")}</th>
<td>{formatDuration(result.duration)}</td>
<td>{formatDuration(result.duration, $t)}</td>
</tr>
<tr>
<th>{$t("tests.result.field.status")}</th>

View file

@ -36,14 +36,15 @@
Icon,
Row,
} from "@sveltestrap/sveltestrap";
import { page } from "$app/stores";
import { page } from "$app/state";
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));
@ -311,59 +312,7 @@
</Card>
{/if}
{#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}
<PluginOptionsGroups groups={readOnlyOptGroups} t={$t} />
{#if !hasAnyOpts}
<Card>