web: Refactor frontend utilities and eliminate duplicate code
Some checks failed
continuous-integration/drone/push Build is failing
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:
parent
6a2ebe83b6
commit
b541809470
12 changed files with 291 additions and 304 deletions
|
|
@ -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>
|
||||
|
|
|
|||
89
web/src/lib/components/plugins/PluginOptionsGroups.svelte
Normal file
89
web/src/lib/components/plugins/PluginOptionsGroups.svelte
Normal 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}
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
39
web/src/lib/utils/test.ts
Normal 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")}`;
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue