Compare commits
13 commits
b541809470
...
4c7c3b4568
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c7c3b4568 | |||
| 30fdbff8a3 | |||
| cde9d405f4 | |||
| 262da7bb0e | |||
| 5b4dd01a13 | |||
| cac9419947 | |||
| 7c2ee9ad8f | |||
| 34d72cc178 | |||
| cb8bb2f9a1 | |||
| a256d2efe2 | |||
| 8f8cf8db7c | |||
| 82e30391ee | |||
| 6a68bafdbb |
20 changed files with 339 additions and 413 deletions
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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")}`;
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue