web: Add frontend for domain tests browsing and execution
Add test API client, data models, Svelte store, and pages to list available tests per domain, view results, and trigger test runs via a dedicated modal. Also refactor plugins page to use a shared store.
This commit is contained in:
parent
0d3f397307
commit
aab23b2847
21 changed files with 1923 additions and 313 deletions
|
|
@ -147,7 +147,7 @@ func (tc *TestResultController) ListAvailableTests(c *gin.Context) {
|
|||
|
||||
info := TestInfo{
|
||||
PluginName: pluginNames[0],
|
||||
Enabled: false,
|
||||
Enabled: true, // enabled by default unless explicitly disabled via a schedule
|
||||
}
|
||||
|
||||
// Check if there's a schedule
|
||||
|
|
|
|||
|
|
@ -1,285 +0,0 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package testresult
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
const (
|
||||
// Default test intervals
|
||||
DefaultDomainTestInterval = 24 * time.Hour // 24 hours for domain tests
|
||||
DefaultServiceTestInterval = 1 * time.Hour // 1 hour for service tests
|
||||
MinimumTestInterval = 5 * time.Minute // Minimum interval allowed
|
||||
)
|
||||
|
||||
// TestScheduleUsecase implements business logic for test schedules
|
||||
type TestScheduleUsecase struct {
|
||||
storage TestResultStorage
|
||||
options *happydns.Options
|
||||
}
|
||||
|
||||
// NewTestScheduleUsecase creates a new test schedule usecase
|
||||
func NewTestScheduleUsecase(storage TestResultStorage, options *happydns.Options) *TestScheduleUsecase {
|
||||
return &TestScheduleUsecase{
|
||||
storage: storage,
|
||||
options: options,
|
||||
}
|
||||
}
|
||||
|
||||
// ListUserSchedules retrieves all schedules for a specific user
|
||||
func (u *TestScheduleUsecase) ListUserSchedules(userId happydns.Identifier) ([]*happydns.TestSchedule, error) {
|
||||
return u.storage.ListTestSchedulesByUser(userId)
|
||||
}
|
||||
|
||||
// ListSchedulesByTarget retrieves all schedules for a specific target
|
||||
func (u *TestScheduleUsecase) ListSchedulesByTarget(targetType happydns.TestScopeType, targetId happydns.Identifier) ([]*happydns.TestSchedule, error) {
|
||||
return u.storage.ListTestSchedulesByTarget(targetType, targetId)
|
||||
}
|
||||
|
||||
// GetSchedule retrieves a specific schedule by ID
|
||||
func (u *TestScheduleUsecase) GetSchedule(scheduleId happydns.Identifier) (*happydns.TestSchedule, error) {
|
||||
return u.storage.GetTestSchedule(scheduleId)
|
||||
}
|
||||
|
||||
// CreateSchedule creates a new test schedule with validation
|
||||
func (u *TestScheduleUsecase) CreateSchedule(schedule *happydns.TestSchedule) error {
|
||||
// Validate interval
|
||||
if schedule.Interval < MinimumTestInterval {
|
||||
return fmt.Errorf("test interval must be at least %v", MinimumTestInterval)
|
||||
}
|
||||
|
||||
// Set default interval if not specified
|
||||
if schedule.Interval == 0 {
|
||||
schedule.Interval = u.getDefaultInterval(schedule.TargetType)
|
||||
}
|
||||
|
||||
// Calculate next run time
|
||||
if schedule.NextRun.IsZero() {
|
||||
schedule.NextRun = time.Now().Add(schedule.Interval)
|
||||
}
|
||||
|
||||
// Enable by default if not specified
|
||||
if !schedule.Enabled {
|
||||
schedule.Enabled = true
|
||||
}
|
||||
|
||||
return u.storage.CreateTestSchedule(schedule)
|
||||
}
|
||||
|
||||
// UpdateSchedule updates an existing schedule
|
||||
func (u *TestScheduleUsecase) UpdateSchedule(schedule *happydns.TestSchedule) error {
|
||||
// Validate interval
|
||||
if schedule.Interval < MinimumTestInterval {
|
||||
return fmt.Errorf("test interval must be at least %v", MinimumTestInterval)
|
||||
}
|
||||
|
||||
// Get existing schedule to preserve certain fields
|
||||
existing, err := u.storage.GetTestSchedule(schedule.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Preserve LastRun if not explicitly changed
|
||||
if schedule.LastRun == nil {
|
||||
schedule.LastRun = existing.LastRun
|
||||
}
|
||||
|
||||
// Recalculate next run time if interval changed
|
||||
if schedule.Interval != existing.Interval {
|
||||
if schedule.LastRun != nil {
|
||||
schedule.NextRun = schedule.LastRun.Add(schedule.Interval)
|
||||
} else {
|
||||
schedule.NextRun = time.Now().Add(schedule.Interval)
|
||||
}
|
||||
}
|
||||
|
||||
return u.storage.UpdateTestSchedule(schedule)
|
||||
}
|
||||
|
||||
// DeleteSchedule removes a schedule
|
||||
func (u *TestScheduleUsecase) DeleteSchedule(scheduleId happydns.Identifier) error {
|
||||
return u.storage.DeleteTestSchedule(scheduleId)
|
||||
}
|
||||
|
||||
// EnableSchedule enables a schedule
|
||||
func (u *TestScheduleUsecase) EnableSchedule(scheduleId happydns.Identifier) error {
|
||||
schedule, err := u.storage.GetTestSchedule(scheduleId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
schedule.Enabled = true
|
||||
|
||||
// Reset next run time if it's in the past
|
||||
if schedule.NextRun.Before(time.Now()) {
|
||||
schedule.NextRun = time.Now().Add(schedule.Interval)
|
||||
}
|
||||
|
||||
return u.storage.UpdateTestSchedule(schedule)
|
||||
}
|
||||
|
||||
// DisableSchedule disables a schedule
|
||||
func (u *TestScheduleUsecase) DisableSchedule(scheduleId happydns.Identifier) error {
|
||||
schedule, err := u.storage.GetTestSchedule(scheduleId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
schedule.Enabled = false
|
||||
return u.storage.UpdateTestSchedule(schedule)
|
||||
}
|
||||
|
||||
// UpdateScheduleAfterRun updates a schedule after it has been executed
|
||||
func (u *TestScheduleUsecase) UpdateScheduleAfterRun(scheduleId happydns.Identifier) error {
|
||||
schedule, err := u.storage.GetTestSchedule(scheduleId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
schedule.LastRun = &now
|
||||
schedule.NextRun = now.Add(schedule.Interval)
|
||||
|
||||
return u.storage.UpdateTestSchedule(schedule)
|
||||
}
|
||||
|
||||
// ListDueSchedules retrieves all enabled schedules that are due to run
|
||||
func (u *TestScheduleUsecase) ListDueSchedules() ([]*happydns.TestSchedule, error) {
|
||||
schedules, err := u.storage.ListEnabledTestSchedules()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
var dueSchedules []*happydns.TestSchedule
|
||||
|
||||
for _, schedule := range schedules {
|
||||
if schedule.Enabled && schedule.NextRun.Before(now) {
|
||||
dueSchedules = append(dueSchedules, schedule)
|
||||
}
|
||||
}
|
||||
|
||||
return dueSchedules, nil
|
||||
}
|
||||
|
||||
// getDefaultInterval returns the default test interval based on target type
|
||||
func (u *TestScheduleUsecase) getDefaultInterval(targetType happydns.TestScopeType) time.Duration {
|
||||
switch targetType {
|
||||
case happydns.TestScopeDomain:
|
||||
return DefaultDomainTestInterval
|
||||
case happydns.TestScopeService:
|
||||
return DefaultServiceTestInterval
|
||||
case happydns.TestScopeZone:
|
||||
return DefaultDomainTestInterval
|
||||
default:
|
||||
return DefaultDomainTestInterval
|
||||
}
|
||||
}
|
||||
|
||||
// MergePluginOptions merges plugin options from different scopes
|
||||
// Priority: schedule options > domain options > user options > global options
|
||||
func (u *TestScheduleUsecase) MergePluginOptions(
|
||||
globalOpts happydns.PluginOptions,
|
||||
userOpts happydns.PluginOptions,
|
||||
domainOpts happydns.PluginOptions,
|
||||
scheduleOpts happydns.PluginOptions,
|
||||
) happydns.PluginOptions {
|
||||
merged := make(happydns.PluginOptions)
|
||||
|
||||
// Start with global options
|
||||
for k, v := range globalOpts {
|
||||
merged[k] = v
|
||||
}
|
||||
|
||||
// Override with user options
|
||||
for k, v := range userOpts {
|
||||
merged[k] = v
|
||||
}
|
||||
|
||||
// Override with domain options
|
||||
for k, v := range domainOpts {
|
||||
merged[k] = v
|
||||
}
|
||||
|
||||
// Override with schedule options (highest priority)
|
||||
for k, v := range scheduleOpts {
|
||||
merged[k] = v
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
// ValidateScheduleOwnership checks if a user owns a schedule
|
||||
func (u *TestScheduleUsecase) ValidateScheduleOwnership(scheduleId happydns.Identifier, userId happydns.Identifier) error {
|
||||
schedule, err := u.storage.GetTestSchedule(scheduleId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !schedule.UserId.Equals(userId) {
|
||||
return fmt.Errorf("user does not own this schedule")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateDefaultSchedulesForTarget creates default schedules for a new target
|
||||
func (u *TestScheduleUsecase) CreateDefaultSchedulesForTarget(
|
||||
pluginName string,
|
||||
targetType happydns.TestScopeType,
|
||||
targetId happydns.Identifier,
|
||||
userId happydns.Identifier,
|
||||
enabled bool,
|
||||
) error {
|
||||
schedule := &happydns.TestSchedule{
|
||||
PluginName: pluginName,
|
||||
UserId: userId,
|
||||
TargetType: targetType,
|
||||
TargetId: targetId,
|
||||
Interval: u.getDefaultInterval(targetType),
|
||||
Enabled: enabled,
|
||||
NextRun: time.Now().Add(u.getDefaultInterval(targetType)),
|
||||
Options: make(happydns.PluginOptions),
|
||||
}
|
||||
|
||||
return u.CreateSchedule(schedule)
|
||||
}
|
||||
|
||||
// DeleteSchedulesForTarget removes all schedules for a target
|
||||
func (u *TestScheduleUsecase) DeleteSchedulesForTarget(targetType happydns.TestScopeType, targetId happydns.Identifier) error {
|
||||
schedules, err := u.storage.ListTestSchedulesByTarget(targetType, targetId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, schedule := range schedules {
|
||||
if err := u.storage.DeleteTestSchedule(schedule.Id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
190
web/src/lib/api/tests.ts
Normal file
190
web/src/lib/api/tests.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2022-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import type { PostDomainsByDomainTestsByTnameResponse } from "$lib/api-base/types.gen";
|
||||
import {
|
||||
getDomainsByDomainTests,
|
||||
getDomainsByDomainTestsByTname,
|
||||
postDomainsByDomainTestsByTname,
|
||||
getDomainsByDomainTestsByTnameExecutionsByExecutionId,
|
||||
getDomainsByDomainTestsByTnameOptions,
|
||||
putDomainsByDomainTestsByTnameOptions,
|
||||
getDomainsByDomainTestsByTnameResults,
|
||||
getDomainsByDomainTestsByTnameResultsByResultId,
|
||||
deleteDomainsByDomainTestsByTnameResultsByResultId,
|
||||
deleteDomainsByDomainTestsByTnameResults,
|
||||
getPluginsTestsSchedules,
|
||||
getPluginsTestsSchedulesByScheduleId,
|
||||
postPluginsTestsSchedules,
|
||||
putPluginsTestsSchedulesByScheduleId,
|
||||
deletePluginsTestsSchedulesByScheduleId,
|
||||
} from "$lib/api-base/sdk.gen";
|
||||
import type {
|
||||
TestResult,
|
||||
TestExecution,
|
||||
TestSchedule,
|
||||
AvailableTest,
|
||||
CreateScheduleRequest,
|
||||
} from "$lib/model/test";
|
||||
import type { PluginOptions } from "$lib/model/plugin";
|
||||
import { unwrapSdkResponse, unwrapEmptyResponse } from "./errors";
|
||||
|
||||
// Domain test operations
|
||||
export async function listAvailableTests(domainId: string): Promise<AvailableTest[]> {
|
||||
return unwrapSdkResponse(
|
||||
await getDomainsByDomainTests({ path: { domain: domainId } }),
|
||||
) as unknown as AvailableTest[];
|
||||
}
|
||||
|
||||
export async function listTestResults(
|
||||
domainId: string,
|
||||
testName: string,
|
||||
limit?: number,
|
||||
): Promise<TestResult[]> {
|
||||
return unwrapSdkResponse(
|
||||
await getDomainsByDomainTestsByTnameResults({
|
||||
path: { domain: domainId, tname: testName },
|
||||
query: limit !== undefined ? { limit } : undefined,
|
||||
}),
|
||||
) as TestResult[];
|
||||
}
|
||||
|
||||
export async function getLatestTestResults(
|
||||
domainId: string,
|
||||
testName: string,
|
||||
): Promise<TestResult[]> {
|
||||
return unwrapSdkResponse(
|
||||
await getDomainsByDomainTestsByTname({ path: { domain: domainId, tname: testName } }),
|
||||
) as TestResult[];
|
||||
}
|
||||
|
||||
export async function triggerTest(
|
||||
domainId: string,
|
||||
testName: string,
|
||||
options?: PluginOptions,
|
||||
): Promise<PostDomainsByDomainTestsByTnameResponse> {
|
||||
return unwrapSdkResponse(
|
||||
await postDomainsByDomainTestsByTname({
|
||||
path: { domain: domainId, tname: testName },
|
||||
body: { options } as any,
|
||||
}),
|
||||
) as PostDomainsByDomainTestsByTnameResponse;
|
||||
}
|
||||
|
||||
export async function getTestExecution(
|
||||
domainId: string,
|
||||
testName: string,
|
||||
executionId: string,
|
||||
): Promise<TestExecution> {
|
||||
return unwrapSdkResponse(
|
||||
await getDomainsByDomainTestsByTnameExecutionsByExecutionId({
|
||||
path: { domain: domainId, tname: testName, execution_id: executionId },
|
||||
}),
|
||||
) as TestExecution;
|
||||
}
|
||||
|
||||
export async function getTestResult(
|
||||
domainId: string,
|
||||
testName: string,
|
||||
resultId: string,
|
||||
): Promise<TestResult> {
|
||||
return unwrapSdkResponse(
|
||||
await getDomainsByDomainTestsByTnameResultsByResultId({
|
||||
path: { domain: domainId, tname: testName, result_id: resultId },
|
||||
}),
|
||||
) as TestResult;
|
||||
}
|
||||
|
||||
export async function deleteTestResult(
|
||||
domainId: string,
|
||||
testName: string,
|
||||
resultId: string,
|
||||
): Promise<void> {
|
||||
unwrapEmptyResponse(
|
||||
await deleteDomainsByDomainTestsByTnameResultsByResultId({
|
||||
path: { domain: domainId, tname: testName, result_id: resultId },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteAllTestResults(domainId: string, testName: string): Promise<void> {
|
||||
unwrapEmptyResponse(
|
||||
await deleteDomainsByDomainTestsByTnameResults({
|
||||
path: { domain: domainId, tname: testName },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getTestOptions(domainId: string, testName: string): Promise<PluginOptions> {
|
||||
return unwrapSdkResponse(
|
||||
await getDomainsByDomainTestsByTnameOptions({
|
||||
path: { domain: domainId, tname: testName },
|
||||
}),
|
||||
) as PluginOptions;
|
||||
}
|
||||
|
||||
export async function updateTestOptions(
|
||||
domainId: string,
|
||||
testName: string,
|
||||
options: PluginOptions,
|
||||
): Promise<void> {
|
||||
unwrapEmptyResponse(
|
||||
await putDomainsByDomainTestsByTnameOptions({
|
||||
path: { domain: domainId, tname: testName },
|
||||
body: { options } as any,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Schedule operations
|
||||
export async function listUserSchedules(): Promise<TestSchedule[]> {
|
||||
return unwrapSdkResponse(await getPluginsTestsSchedules()) as TestSchedule[];
|
||||
}
|
||||
|
||||
export async function getTestSchedule(scheduleId: string): Promise<TestSchedule> {
|
||||
return unwrapSdkResponse(
|
||||
await getPluginsTestsSchedulesByScheduleId({ path: { schedule_id: scheduleId } }),
|
||||
) as TestSchedule;
|
||||
}
|
||||
|
||||
export async function createTestSchedule(schedule: CreateScheduleRequest): Promise<TestSchedule> {
|
||||
return unwrapSdkResponse(
|
||||
await postPluginsTestsSchedules({ body: schedule as any }),
|
||||
) as TestSchedule;
|
||||
}
|
||||
|
||||
export async function updateTestSchedule(
|
||||
scheduleId: string,
|
||||
schedule: Partial<TestSchedule>,
|
||||
): Promise<void> {
|
||||
unwrapEmptyResponse(
|
||||
await putPluginsTestsSchedulesByScheduleId({
|
||||
path: { schedule_id: scheduleId },
|
||||
body: schedule as any,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteTestSchedule(scheduleId: string): Promise<void> {
|
||||
unwrapEmptyResponse(
|
||||
await deletePluginsTestsSchedulesByScheduleId({ path: { schedule_id: scheduleId } }),
|
||||
);
|
||||
}
|
||||
172
web/src/lib/components/modals/RunTestModal.svelte
Normal file
172
web/src/lib/components/modals/RunTestModal.svelte
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
<!--
|
||||
This file is part of the happyDomain (R) project.
|
||||
Copyright (c) 2022-2026 happyDomain
|
||||
Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
This program is offered under a commercial and under the AGPL license.
|
||||
For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
|
||||
For AGPL licensing:
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Form,
|
||||
FormGroup,
|
||||
Icon,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Spinner,
|
||||
} from "@sveltestrap/sveltestrap";
|
||||
|
||||
import { triggerTest, getTestOptions } from "$lib/api/tests";
|
||||
import { getPluginStatus } from "$lib/api/plugins";
|
||||
import type { PluginOptions } from "$lib/model/plugin";
|
||||
import Resource from "$lib/components/inputs/Resource.svelte";
|
||||
import { toasts } from "$lib/stores/toasts";
|
||||
import { t } from "$lib/translations";
|
||||
|
||||
interface Props {
|
||||
domainId: string;
|
||||
onTestTriggered?: (execution_id: string, plugin_name: string) => void;
|
||||
}
|
||||
|
||||
let { domainId, onTestTriggered }: Props = $props();
|
||||
|
||||
let isOpen = $state(false);
|
||||
let pluginName = $state<string>("");
|
||||
let pluginDisplayName = $state<string>("");
|
||||
let pluginStatusPromise = $state<Promise<any> | null>(null);
|
||||
let domainOptionsPromise = $state<Promise<PluginOptions> | null>(null);
|
||||
let runOptions = $state<Record<string, any>>({});
|
||||
let triggering = $state(false);
|
||||
|
||||
const toggle = () => (isOpen = !isOpen);
|
||||
|
||||
export function open(testPluginName: string, testDisplayName: string) {
|
||||
pluginName = testPluginName;
|
||||
pluginDisplayName = testDisplayName;
|
||||
runOptions = {};
|
||||
pluginStatusPromise = getPluginStatus(testPluginName);
|
||||
domainOptionsPromise = getTestOptions(domainId, testPluginName);
|
||||
isOpen = true;
|
||||
|
||||
// Pre-populate with domain options when they load
|
||||
domainOptionsPromise.then((options) => {
|
||||
runOptions = { ...(options || {}) };
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRunTest() {
|
||||
triggering = true;
|
||||
try {
|
||||
const result = await triggerTest(domainId, pluginName, runOptions);
|
||||
toasts.addToast({
|
||||
message: $t("tests.run-test.triggered-success", { id: result.execution_id }),
|
||||
type: "success",
|
||||
timeout: 5000,
|
||||
});
|
||||
isOpen = false;
|
||||
if (onTestTriggered && result.execution_id) {
|
||||
onTestTriggered(result.execution_id, pluginName);
|
||||
}
|
||||
} catch (error) {
|
||||
toasts.addErrorToast({
|
||||
message: $t("tests.run-test.trigger-failed", { error: String(error) }),
|
||||
timeout: 10000,
|
||||
});
|
||||
} finally {
|
||||
triggering = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal {isOpen} {toggle} size="lg">
|
||||
<ModalHeader {toggle}>
|
||||
{$t("tests.run-test.title")}: {pluginDisplayName}
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
{#if pluginStatusPromise && domainOptionsPromise}
|
||||
{#await Promise.all([pluginStatusPromise, domainOptionsPromise])}
|
||||
<div class="text-center py-3">
|
||||
<Spinner />
|
||||
<p class="mt-2">{$t("tests.run-test.loading-options")}</p>
|
||||
</div>
|
||||
{:then [status, _domainOpts]}
|
||||
{@const runOpts = status.options?.runOpts || []}
|
||||
{#if runOpts.length > 0}
|
||||
<p>
|
||||
{$t("tests.run-test.configure-info")}
|
||||
</p>
|
||||
<Form
|
||||
id="run-test-modal"
|
||||
on:submit={(e) => {
|
||||
e.preventDefault();
|
||||
handleRunTest();
|
||||
}}
|
||||
>
|
||||
{#each runOpts as optDoc}
|
||||
{#if optDoc.id}
|
||||
{@const optName = optDoc.id}
|
||||
<FormGroup>
|
||||
<Resource
|
||||
edit={true}
|
||||
index={optName}
|
||||
specs={optDoc}
|
||||
type={optDoc.type || "string"}
|
||||
bind:value={runOptions[optName]}
|
||||
/>
|
||||
</FormGroup>
|
||||
{/if}
|
||||
{/each}
|
||||
</Form>
|
||||
{:else}
|
||||
<Alert color="info" class="mb-0">
|
||||
<Icon name="info-circle"></Icon>
|
||||
{$t("tests.run-test.no-options")}
|
||||
</Alert>
|
||||
{/if}
|
||||
{:catch error}
|
||||
<Alert color="danger">
|
||||
<Icon name="exclamation-triangle-fill"></Icon>
|
||||
{$t("tests.run-test.error-loading-options", { error: error.message })}
|
||||
</Alert>
|
||||
{/await}
|
||||
{/if}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button type="button" color="secondary" onclick={toggle} disabled={triggering}>
|
||||
{$t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="run-test-modal"
|
||||
color="primary"
|
||||
onclick={handleRunTest}
|
||||
disabled={triggering}
|
||||
>
|
||||
{#if triggering}
|
||||
<Spinner size="sm" class="me-1" />
|
||||
{:else}
|
||||
<Icon name="play-fill"></Icon>
|
||||
{/if}
|
||||
{$t("tests.run-test.run-button")}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
|
|
@ -82,6 +82,7 @@
|
|||
"share": "Share the zone…",
|
||||
"upload": "Import a zone file",
|
||||
"view": "View my zone",
|
||||
"view-tests": "View tests",
|
||||
"others": "More actions on {{domain}}"
|
||||
},
|
||||
"alert": {
|
||||
|
|
@ -536,6 +537,126 @@
|
|||
"ttl": "Remaining time in cache",
|
||||
"showDNSSEC": "Show DNSSEC records in answer (if any)"
|
||||
},
|
||||
"tests": {
|
||||
"run-test": {
|
||||
"title": "Run Test",
|
||||
"loading-options": "Loading test options...",
|
||||
"configure-info": "Configure test options below. Pre-filled values are from domain-level settings.",
|
||||
"no-options": "This test has no configurable options. Click \"Run Test\" to execute with default settings.",
|
||||
"error-loading-options": "Error loading test options: {{error}}",
|
||||
"run-button": "Run Test",
|
||||
"triggered-success": "Test triggered successfully! Execution ID: {{id}}",
|
||||
"trigger-failed": "Failed to trigger test: {{error}}"
|
||||
},
|
||||
"never": "Never",
|
||||
"na": "N/A",
|
||||
"relative": {
|
||||
"in-less-than-a-minute": "in less than a minute",
|
||||
"just-now": "just now",
|
||||
"in": "in {{label}}",
|
||||
"ago": "{{label}} ago"
|
||||
},
|
||||
"status": {
|
||||
"ok": "OK",
|
||||
"info": "Info",
|
||||
"warning": "Warning",
|
||||
"error": "Error",
|
||||
"unknown": "Unknown",
|
||||
"not-run": "Not run"
|
||||
},
|
||||
"list": {
|
||||
"title": "Tests for ",
|
||||
"loading": "Loading tests...",
|
||||
"loading-plugins": "Loading plugin information...",
|
||||
"no-tests": "No tests available for this domain.",
|
||||
"run-test": "Run Test",
|
||||
"view-results": "View Results",
|
||||
"error-loading": "Error loading tests: {{error}}",
|
||||
"unknown-version": "Unknown",
|
||||
"table": {
|
||||
"plugin": "Test Plugin",
|
||||
"status": "Status",
|
||||
"last-run": "Last Run",
|
||||
"schedule": "Schedule",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"schedule": {
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled"
|
||||
}
|
||||
},
|
||||
"schedule": {
|
||||
"title": "Schedule",
|
||||
"card-title": "Automatic scheduling",
|
||||
"auto-enabled": "Run automatically",
|
||||
"auto-disabled": "Disabled (run manually only)",
|
||||
"interval-label": "Check interval",
|
||||
"hours": "hours",
|
||||
"interval-hint": "Minimum 1 hour. The test will run once per interval.",
|
||||
"next-run": "Next scheduled run",
|
||||
"last-run": "Last run",
|
||||
"no-schedule-yet": "No schedule created yet. Save to create one.",
|
||||
"save": "Save",
|
||||
"save-failed": "Failed to save schedule",
|
||||
"saved": "Schedule saved successfully."
|
||||
},
|
||||
"results": {
|
||||
"loading": "Loading test results...",
|
||||
"no-results": "No test results yet. Click \"Run Test Now\" to execute the test.",
|
||||
"title": "Test Results ({{count}})",
|
||||
"run-test-now": "Run Test Now",
|
||||
"back-to-tests": "Back to Tests",
|
||||
"delete-all": "Delete All",
|
||||
"delete-confirm": "Are you sure you want to delete this test result?",
|
||||
"delete-all-confirm": "Are you sure you want to delete ALL test results for this test? This cannot be undone.",
|
||||
"delete-failed": "Failed to delete result",
|
||||
"delete-all-failed": "Failed to delete results",
|
||||
"configure": "Configure",
|
||||
"domain-level": "Domain-level",
|
||||
"error-loading": "Error loading test results: {{error}}",
|
||||
"table": {
|
||||
"executed-at": "Executed At",
|
||||
"status": "Status",
|
||||
"message": "Message",
|
||||
"duration": "Duration",
|
||||
"type": "Type",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"type": {
|
||||
"scheduled": "Scheduled",
|
||||
"manual": "Manual"
|
||||
},
|
||||
"view": "View"
|
||||
},
|
||||
"result": {
|
||||
"title": "Test Result Details",
|
||||
"loading": "Loading test result...",
|
||||
"relaunch": "Relaunch Test",
|
||||
"delete": "Delete Result",
|
||||
"back-to-results": "Back to Results",
|
||||
"relaunch-failed": "Failed to relaunch test",
|
||||
"delete-confirm": "Are you sure you want to delete this test result?",
|
||||
"delete-failed": "Failed to delete result",
|
||||
"error-loading": "Error loading test result: {{error}}",
|
||||
"milliseconds": "milliseconds",
|
||||
"seconds": "seconds",
|
||||
"type": {
|
||||
"scheduled": "Scheduled Test",
|
||||
"manual": "Manual Test"
|
||||
},
|
||||
"test-options": "Test Options",
|
||||
"full-report": "Full Report",
|
||||
"field": {
|
||||
"domain": "Domain:",
|
||||
"executed-at": "Executed At:",
|
||||
"duration": "Duration:",
|
||||
"status": "Status:",
|
||||
"status-message": "Status Message:",
|
||||
"error": "Error:",
|
||||
"plugin-version": "Plugin Version:"
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"tests": {
|
||||
"title": "Domain Tests",
|
||||
|
|
@ -548,7 +669,6 @@
|
|||
"error-loading": "Error loading tests: {{error}}",
|
||||
"error-loading-test": "Error loading test: {{error}}",
|
||||
"test-info-not-found": "Error: Test information not found",
|
||||
"back-to-tests": "Back to Tests",
|
||||
"table": {
|
||||
"name": "Test Name",
|
||||
"version": "Version",
|
||||
|
|
@ -594,13 +714,16 @@
|
|||
"options-cleaned": "Orphaned options removed successfully",
|
||||
"update-failed": "Failed to update options: {{error}}",
|
||||
"clean-failed": "Failed to clean options: {{error}}"
|
||||
}
|
||||
},
|
||||
"back-button": "Back to Plugins"
|
||||
}
|
||||
},
|
||||
"zones": {
|
||||
"upload": "Import a zone",
|
||||
"import-text": "Import from text",
|
||||
"import-file": "Import from file",
|
||||
"return-to": "Go to the zone"
|
||||
"return-to": "Go to the zone",
|
||||
"return-to-results": "Back to Results",
|
||||
"return-to-tests": "Back to Tests"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -473,6 +473,24 @@
|
|||
"no-group": "Divers",
|
||||
"title": "Vos groupes"
|
||||
},
|
||||
"tests": {
|
||||
"run-test": {
|
||||
"title": "Lancer le test",
|
||||
"loading-options": "Chargement des options du test...",
|
||||
"configure-info": "Configurez les options du test ci-dessous. Les valeurs préremplies proviennent des paramètres au niveau du domaine.",
|
||||
"no-options": "Ce test n'a pas d'options configurables. Cliquez sur \"Lancer le test\" pour l'exécuter avec les paramètres par défaut.",
|
||||
"error-loading-options": "Erreur lors du chargement des options du test : {{error}}",
|
||||
"run-button": "Lancer le test"
|
||||
},
|
||||
"never": "Jamais",
|
||||
"na": "N/A",
|
||||
"relative": {
|
||||
"in-less-than-a-minute": "dans moins d'une minute",
|
||||
"just-now": "à l'instant",
|
||||
"in": "dans {{label}}",
|
||||
"ago": "il y a {{label}}"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"tests": {
|
||||
"title": "Tests de domaines",
|
||||
|
|
@ -531,7 +549,8 @@
|
|||
"options-cleaned": "Options orphelines supprimées avec succès",
|
||||
"update-failed": "Échec de la mise à jour des options : {{error}}",
|
||||
"clean-failed": "Échec du nettoyage des options : {{error}}"
|
||||
}
|
||||
},
|
||||
"back-button": "Retour aux plugins"
|
||||
}
|
||||
},
|
||||
"zones": {
|
||||
|
|
|
|||
54
web/src/lib/model/plugin.ts
Normal file
54
web/src/lib/model/plugin.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2022-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import type {
|
||||
HappydnsPluginAvailability,
|
||||
HappydnsPluginOptionDocumentation,
|
||||
HappydnsPluginOptionsDocumentation,
|
||||
HappydnsPluginOptions,
|
||||
} from "$lib/api-base/types.gen";
|
||||
|
||||
// Re-export auto-generated types with better names
|
||||
export type PluginAvailability = HappydnsPluginAvailability;
|
||||
export type PluginOptions = HappydnsPluginOptions;
|
||||
export type PluginOptionsDocumentation = HappydnsPluginOptionsDocumentation;
|
||||
|
||||
// Make 'id' required for PluginOptionDocumentation
|
||||
export interface PluginOptionDocumentation extends Omit<HappydnsPluginOptionDocumentation, "id"> {
|
||||
id: string;
|
||||
}
|
||||
|
||||
// Make 'name' and 'version' required for PluginVersionInfo
|
||||
export interface PluginVersionInfo {
|
||||
name: string;
|
||||
version: string;
|
||||
availableOn?: PluginAvailability;
|
||||
}
|
||||
|
||||
// Make 'name' and 'version' required for PluginStatus
|
||||
export interface PluginStatus {
|
||||
name: string;
|
||||
version: string;
|
||||
availableOn?: PluginAvailability;
|
||||
options?: PluginOptionsDocumentation;
|
||||
}
|
||||
|
||||
export type PluginList = Record<string, PluginVersionInfo>;
|
||||
106
web/src/lib/model/test.ts
Normal file
106
web/src/lib/model/test.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2022-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import type { PluginOptions } from './plugin';
|
||||
|
||||
export enum TestScopeType {
|
||||
TestScopeInstance = 0,
|
||||
TestScopeUser = 1,
|
||||
TestScopeDomain = 2,
|
||||
TestScopeZone = 3,
|
||||
TestScopeService = 4,
|
||||
TestScopeOnDemand = 5,
|
||||
}
|
||||
|
||||
export enum TestExecutionStatus {
|
||||
TestExecutionPending = 0,
|
||||
TestExecutionRunning = 1,
|
||||
TestExecutionCompleted = 2,
|
||||
TestExecutionFailed = 3,
|
||||
}
|
||||
|
||||
export enum PluginResultStatus {
|
||||
KO = 0,
|
||||
Warn = 1,
|
||||
Info = 2,
|
||||
OK = 3,
|
||||
}
|
||||
|
||||
export interface TestResult {
|
||||
id: string;
|
||||
plugin_name: string;
|
||||
test_type: TestScopeType;
|
||||
target_id: string;
|
||||
user_id: string;
|
||||
executed_at: string;
|
||||
scheduled_test: boolean;
|
||||
options?: PluginOptions;
|
||||
status: PluginResultStatus;
|
||||
status_line: string;
|
||||
report?: any;
|
||||
duration?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface TestSchedule {
|
||||
id: string;
|
||||
plugin_name: string;
|
||||
user_id: string;
|
||||
target_type: TestScopeType;
|
||||
target_id: string;
|
||||
interval: number;
|
||||
enabled: boolean;
|
||||
last_run?: string;
|
||||
next_run: string;
|
||||
options?: PluginOptions;
|
||||
}
|
||||
|
||||
export interface TestExecution {
|
||||
id: string;
|
||||
schedule_id?: string;
|
||||
plugin_name: string;
|
||||
user_id: string;
|
||||
target_id: string;
|
||||
status: TestExecutionStatus;
|
||||
started_at: string;
|
||||
completed_at?: string;
|
||||
result_id?: string;
|
||||
}
|
||||
|
||||
export interface AvailableTest {
|
||||
plugin_name: string;
|
||||
enabled: boolean;
|
||||
schedule?: TestSchedule;
|
||||
last_result?: TestResult;
|
||||
}
|
||||
|
||||
export interface TriggerTestRequest {
|
||||
options?: PluginOptions;
|
||||
}
|
||||
|
||||
export interface CreateScheduleRequest {
|
||||
plugin_name: string;
|
||||
target_type: TestScopeType;
|
||||
target_id: string;
|
||||
interval: number;
|
||||
enabled: boolean;
|
||||
options?: PluginOptions;
|
||||
}
|
||||
32
web/src/lib/stores/plugins.ts
Normal file
32
web/src/lib/stores/plugins.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2022-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import { listPlugins } from "$lib/api/plugins";
|
||||
import type { PluginList } from "$lib/model/plugin";
|
||||
import { writable, type Writable } from "svelte/store";
|
||||
|
||||
export const plugins: Writable<PluginList | undefined> = writable(undefined);
|
||||
|
||||
export async function refreshPlugins() {
|
||||
const data = await listPlugins();
|
||||
plugins.set(data);
|
||||
return data;
|
||||
}
|
||||
|
|
@ -40,6 +40,10 @@ interface Params {
|
|||
max?: number;
|
||||
suggestion?: string;
|
||||
key?: string;
|
||||
error?: string;
|
||||
providers?: string;
|
||||
services?: string;
|
||||
options?: string;
|
||||
// add more parameters that are used here
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")}`;
|
||||
}
|
||||
|
|
@ -81,7 +81,11 @@
|
|||
? "/logs"
|
||||
: page.route.id.startsWith("/domains/[dn]/history")
|
||||
? "/history"
|
||||
: ""
|
||||
: page.route.id.startsWith("/domains/[dn]/tests/[tname]")
|
||||
? `/tests/${page.params.tname!}`
|
||||
: page.route.id.startsWith("/domains/[dn]/tests")
|
||||
? "/tests"
|
||||
: ""
|
||||
: ""),
|
||||
);
|
||||
}
|
||||
|
|
@ -172,7 +176,34 @@
|
|||
<SelectDomain bind:selectedDomain />
|
||||
</div>
|
||||
|
||||
{#if page.route.id && (page.route.id.startsWith("/domains/[dn]/history") || page.route.id.startsWith("/domains/[dn]/logs"))}
|
||||
{#if page.route.id && page.route.id.startsWith("/domains/[dn]/tests/[tname]")}
|
||||
{#if page.route.id.startsWith("/domains/[dn]/tests/[tname]/results/")}
|
||||
<Button
|
||||
class="mt-2"
|
||||
outline
|
||||
color="primary"
|
||||
href={"/domains/" +
|
||||
encodeURIComponent(domainLink(selectedDomain)) +
|
||||
"/tests/" +
|
||||
encodeURIComponent(page.params.tname!)}
|
||||
>
|
||||
<Icon name="chevron-left" />
|
||||
{$t("zones.return-to-results")}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
class="mt-2"
|
||||
outline
|
||||
color="primary"
|
||||
href={"/domains/" +
|
||||
encodeURIComponent(domainLink(selectedDomain)) +
|
||||
"/tests"}
|
||||
>
|
||||
<Icon name="chevron-left" />
|
||||
{$t("zones.return-to-tests")}
|
||||
</Button>
|
||||
{/if}
|
||||
{:else if page.route.id && (page.route.id.startsWith("/domains/[dn]/history") || page.route.id.startsWith("/domains/[dn]/logs") || page.route.id.startsWith("/domains/[dn]/tests"))}
|
||||
<Button
|
||||
class="mt-2"
|
||||
outline
|
||||
|
|
@ -226,6 +257,9 @@
|
|||
<DropdownItem href={`/domains/${domainLink(selectedDomain)}/logs`}>
|
||||
{$t("domains.actions.audit")}
|
||||
</DropdownItem>
|
||||
<DropdownItem href={`/domains/${domainLink(selectedDomain)}/tests`}>
|
||||
{$t("domains.actions.view-tests")}
|
||||
</DropdownItem>
|
||||
<DropdownItem divider />
|
||||
<DropdownItem on:click={viewZone} disabled={!$sortedDomains}>
|
||||
{$t("domains.actions.view")}
|
||||
|
|
|
|||
17
web/src/routes/domains/[dn]/tests/+layout.ts
Normal file
17
web/src/routes/domains/[dn]/tests/+layout.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { type Load } from "@sveltejs/kit";
|
||||
|
||||
import { plugins, refreshPlugins } from "$lib/stores/plugins";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
export const load: Load = async ({ parent }) => {
|
||||
const data = await parent();
|
||||
|
||||
if (get(plugins) === undefined) {
|
||||
refreshPlugins();
|
||||
}
|
||||
|
||||
return {
|
||||
...data,
|
||||
isTestsPage: true,
|
||||
};
|
||||
};
|
||||
221
web/src/routes/domains/[dn]/tests/+page.svelte
Normal file
221
web/src/routes/domains/[dn]/tests/+page.svelte
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
<!--
|
||||
This file is part of the happyDomain (R) project.
|
||||
Copyright (c) 2022-2026 happyDomain
|
||||
Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
This program is offered under a commercial and under the AGPL license.
|
||||
For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
|
||||
For AGPL licensing:
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import { Card, Icon, Table, Badge, Button, Spinner } from "@sveltestrap/sveltestrap";
|
||||
|
||||
import { t } from "$lib/translations";
|
||||
import { listAvailableTests, updateTestSchedule, createTestSchedule } from "$lib/api/tests";
|
||||
import type { Domain } from "$lib/model/domain";
|
||||
import { TestScopeType, type AvailableTest } from "$lib/model/test";
|
||||
import { plugins } from "$lib/stores/plugins";
|
||||
import { toasts } from "$lib/stores/toasts";
|
||||
import RunTestModal from "$lib/components/modals/RunTestModal.svelte";
|
||||
import { getStatusColor, getStatusKey, formatTestDate } from "$lib/utils";
|
||||
|
||||
interface Props {
|
||||
data: { domain: Domain };
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
let testsPromise = $derived(listAvailableTests(data.domain.id));
|
||||
let runTestModal: RunTestModal;
|
||||
let togglingTests = $state(new Set<string>());
|
||||
|
||||
function handleTestTriggered(_: string, pluginName: string) {
|
||||
// Refresh the test list to show updated status
|
||||
testsPromise = listAvailableTests(data.domain.id);
|
||||
goto(`/domains/${page.params.dn!}/tests/${pluginName}/results`);
|
||||
}
|
||||
|
||||
async function handleToggleEnabled(test: AvailableTest) {
|
||||
const next = new Set(togglingTests);
|
||||
next.add(test.plugin_name);
|
||||
togglingTests = next;
|
||||
|
||||
try {
|
||||
const newEnabled = !test.enabled;
|
||||
if (test.schedule) {
|
||||
await updateTestSchedule(test.schedule.id, {
|
||||
...test.schedule,
|
||||
enabled: newEnabled,
|
||||
});
|
||||
} else {
|
||||
// No schedule record yet — create one to persist the disabled state.
|
||||
// (Enabled → Enabled needs no action since that's the implicit default.)
|
||||
await createTestSchedule({
|
||||
plugin_name: test.plugin_name,
|
||||
target_type: TestScopeType.TestScopeDomain,
|
||||
target_id: data.domain.id,
|
||||
interval: 0,
|
||||
enabled: newEnabled,
|
||||
});
|
||||
}
|
||||
testsPromise = listAvailableTests(data.domain.id);
|
||||
} catch (e: any) {
|
||||
toasts.addErrorToast({ title: $t("tests.list.error-loading", { error: e.message }) });
|
||||
} finally {
|
||||
const after = new Set(togglingTests);
|
||||
after.delete(test.plugin_name);
|
||||
togglingTests = after;
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Tests - {data.domain.domain} - happyDomain</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex-fill pb-4 pt-2">
|
||||
<h2>
|
||||
{$t("tests.list.title")}<span class="font-monospace">{data.domain.domain}</span>
|
||||
</h2>
|
||||
|
||||
{#await testsPromise}
|
||||
<div class="mt-5 text-center flex-fill">
|
||||
<Spinner />
|
||||
<p>{$t("tests.list.loading")}</p>
|
||||
</div>
|
||||
{:then tests}
|
||||
{#if !$plugins}
|
||||
<div class="mt-5 text-center flex-fill">
|
||||
<Spinner />
|
||||
<p>{$t("tests.list.loading-plugins")}</p>
|
||||
</div>
|
||||
{:else if !tests || tests.length === 0}
|
||||
<Card body class="mt-3">
|
||||
<p class="text-center text-muted mb-0">
|
||||
<Icon name="info-circle"></Icon>
|
||||
{$t("tests.list.no-tests")}
|
||||
</p>
|
||||
</Card>
|
||||
{:else}
|
||||
<Table hover striped class="mt-3">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{$t("tests.list.table.plugin")}</th>
|
||||
<th>{$t("tests.list.table.status")}</th>
|
||||
<th>{$t("tests.list.table.last-run")}</th>
|
||||
<th>{$t("tests.list.table.schedule")}</th>
|
||||
<th>{$t("tests.list.table.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each tests as test}
|
||||
{@const pluginInfo = $plugins[test.plugin_name]}
|
||||
<tr>
|
||||
<td class="align-middle">
|
||||
<strong>{pluginInfo?.name || test.plugin_name}</strong>
|
||||
<small class="ms-1 text-muted">
|
||||
{pluginInfo?.version || $t("tests.list.unknown-version")}
|
||||
</small>
|
||||
</td>
|
||||
<td class="align-middle text-center">
|
||||
{#if test.last_result !== undefined}
|
||||
<Badge color={getStatusColor(test.last_result.status)}>
|
||||
{$t(getStatusKey(test.last_result.status))}
|
||||
</Badge>
|
||||
{:else}
|
||||
<Badge color="secondary">{$t("tests.status.not-run")}</Badge>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
{formatTestDate(test.last_result?.executed_at, "short", $t)}
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
<div class="form-check form-switch mb-0">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
id="toggle-{test.plugin_name}"
|
||||
checked={test.enabled}
|
||||
disabled={togglingTests.has(test.plugin_name)}
|
||||
onchange={() => handleToggleEnabled(test)}
|
||||
/>
|
||||
<label
|
||||
class="form-check-label small"
|
||||
for="toggle-{test.plugin_name}"
|
||||
>
|
||||
{test.enabled
|
||||
? $t("tests.list.schedule.enabled")
|
||||
: $t("tests.list.schedule.disabled")}
|
||||
</label>
|
||||
</div>
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
<div class="d-flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
onclick={() =>
|
||||
runTestModal.open(
|
||||
test.plugin_name,
|
||||
pluginInfo?.name || test.plugin_name,
|
||||
)}
|
||||
>
|
||||
<Icon name="play-fill"></Icon>
|
||||
{$t("tests.list.run-test")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color="info"
|
||||
href={`/domains/${encodeURIComponent(data.domain.domain)}/tests/${encodeURIComponent(test.plugin_name)}/results`}
|
||||
>
|
||||
<Icon name="bar-chart-fill"></Icon>
|
||||
{$t("tests.list.view-results")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color="dark"
|
||||
href={`/domains/${encodeURIComponent(data.domain.domain)}/tests/${encodeURIComponent(test.plugin_name)}`}
|
||||
title={$t("tests.list.configure")}
|
||||
>
|
||||
<Icon name="gear"></Icon>
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</Table>
|
||||
{/if}
|
||||
{:catch error}
|
||||
<Card body color="danger" class="mt-3">
|
||||
<p class="mb-0">
|
||||
<Icon name="exclamation-triangle-fill"></Icon>
|
||||
{$t("tests.list.error-loading", { error: error.message })}
|
||||
</p>
|
||||
</Card>
|
||||
{/await}
|
||||
</div>
|
||||
|
||||
<RunTestModal
|
||||
domainId={data.domain.id}
|
||||
onTestTriggered={handleTestTriggered}
|
||||
bind:this={runTestModal}
|
||||
/>
|
||||
276
web/src/routes/domains/[dn]/tests/[tname]/+page.svelte
Normal file
276
web/src/routes/domains/[dn]/tests/[tname]/+page.svelte
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
<!--
|
||||
This file is part of the happyDomain (R) project.
|
||||
Copyright (c) 2022-2026 happyDomain
|
||||
Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
This program is offered under a commercial and under the AGPL license.
|
||||
For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
|
||||
For AGPL licensing:
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { page } from "$app/state";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Icon,
|
||||
Input,
|
||||
Spinner,
|
||||
} from "@sveltestrap/sveltestrap";
|
||||
|
||||
import { t } from "$lib/translations";
|
||||
import { listAvailableTests, updateTestSchedule, createTestSchedule } from "$lib/api/tests";
|
||||
import type { Domain } from "$lib/model/domain";
|
||||
import { TestScopeType, type AvailableTest } from "$lib/model/test";
|
||||
import { plugins } from "$lib/stores/plugins";
|
||||
import { toasts } from "$lib/stores/toasts";
|
||||
import { formatTestDate, formatRelative } from "$lib/utils";
|
||||
|
||||
interface Props {
|
||||
data: { domain: Domain };
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
const testName = $derived(page.params.tname || "");
|
||||
const pluginName = $derived($plugins?.[testName]?.name || testName);
|
||||
|
||||
// Resolved test data
|
||||
let test = $state<AvailableTest | null>(null);
|
||||
let loading = $state(true);
|
||||
let loadError = $state<string | null>(null);
|
||||
|
||||
// Form state
|
||||
let formEnabled = $state(true);
|
||||
let formIntervalHours = $state(24);
|
||||
let saving = $state(false);
|
||||
|
||||
async function loadTest() {
|
||||
loading = true;
|
||||
loadError = null;
|
||||
try {
|
||||
const tests = await listAvailableTests(data.domain.id);
|
||||
const found = tests?.find((t) => t.plugin_name === testName) ?? null;
|
||||
test = found;
|
||||
if (found) {
|
||||
formEnabled = found.enabled;
|
||||
formIntervalHours =
|
||||
found.schedule && found.schedule.interval > 0
|
||||
? found.schedule.interval / (3600 * 1e9)
|
||||
: 24;
|
||||
}
|
||||
} catch (e: any) {
|
||||
loadError = e.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
loadTest();
|
||||
|
||||
async function handleSave() {
|
||||
if (!test) return;
|
||||
saving = true;
|
||||
|
||||
try {
|
||||
const intervalNs = Math.max(formIntervalHours, 1) * 3600 * 1e9;
|
||||
|
||||
if (test.schedule) {
|
||||
await updateTestSchedule(test.schedule.id, {
|
||||
...test.schedule,
|
||||
enabled: formEnabled,
|
||||
interval: intervalNs,
|
||||
});
|
||||
} else {
|
||||
await createTestSchedule({
|
||||
plugin_name: test.plugin_name,
|
||||
target_type: TestScopeType.TestScopeDomain,
|
||||
target_id: data.domain.id,
|
||||
interval: intervalNs,
|
||||
enabled: formEnabled,
|
||||
});
|
||||
}
|
||||
|
||||
toasts.addToast({ title: $t("tests.schedule.saved"), type: "success", timeout: 3000 });
|
||||
await loadTest();
|
||||
} catch (e: any) {
|
||||
toasts.addErrorToast({ title: $t("tests.schedule.save-failed"), message: e.message });
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>
|
||||
{testName} - {$t("tests.schedule.title")} - {data.domain.domain} - happyDomain
|
||||
</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex-fill pb-4 pt-2">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2>
|
||||
<span class="font-monospace">{data.domain.domain}</span>
|
||||
–
|
||||
{pluginName}
|
||||
– {$t("tests.schedule.title")}
|
||||
</h2>
|
||||
<div class="d-flex gap-2">
|
||||
<Button
|
||||
color="secondary"
|
||||
href={`/domains/${encodeURIComponent(data.domain.domain)}/tests`}
|
||||
>
|
||||
<Icon name="arrow-left"></Icon>
|
||||
{$t("zones.return-to-tests")}
|
||||
</Button>
|
||||
<Button
|
||||
color="info"
|
||||
href={`/domains/${encodeURIComponent(data.domain.domain)}/tests/${encodeURIComponent(testName)}/results`}
|
||||
>
|
||||
<Icon name="bar-chart-fill"></Icon>
|
||||
{$t("tests.list.view-results")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="mt-5 text-center flex-fill">
|
||||
<Spinner />
|
||||
<p>{$t("tests.list.loading")}</p>
|
||||
</div>
|
||||
{:else if loadError}
|
||||
<Card body color="danger">
|
||||
<p class="mb-0">
|
||||
<Icon name="exclamation-triangle-fill"></Icon>
|
||||
{$t("tests.list.error-loading", { error: loadError })}
|
||||
</p>
|
||||
</Card>
|
||||
{:else if !test}
|
||||
<Card body>
|
||||
<p class="text-center text-muted mb-0">
|
||||
<Icon name="info-circle"></Icon>
|
||||
{$t("tests.list.no-tests")}
|
||||
</p>
|
||||
</Card>
|
||||
{:else}
|
||||
<Card class="mb-4">
|
||||
<CardHeader>
|
||||
<h4 class="mb-0">
|
||||
<Icon name="clock-history"></Icon>
|
||||
{$t("tests.schedule.card-title")}
|
||||
</h4>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div class="mb-4">
|
||||
<div class="form-check form-switch">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
id="schedule-enabled"
|
||||
bind:checked={formEnabled}
|
||||
disabled={saving}
|
||||
/>
|
||||
<label class="form-check-label" for="schedule-enabled">
|
||||
{#if formEnabled}
|
||||
<Badge color="success"
|
||||
>{$t("tests.schedule.auto-enabled")}</Badge
|
||||
>
|
||||
{:else}
|
||||
<Badge color="secondary"
|
||||
>{$t("tests.schedule.auto-disabled")}</Badge
|
||||
>
|
||||
{/if}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if formEnabled}
|
||||
<div class="mb-4">
|
||||
<label for="schedule-interval" class="form-label fw-semibold">
|
||||
{$t("tests.schedule.interval-label")}
|
||||
</label>
|
||||
<div class="input-group" style="max-width: 300px;">
|
||||
<Input
|
||||
type="number"
|
||||
id="schedule-interval"
|
||||
min={1}
|
||||
step={1}
|
||||
bind:value={formIntervalHours}
|
||||
disabled={saving}
|
||||
/>
|
||||
<span class="input-group-text">
|
||||
{$t("tests.schedule.hours")}
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
{$t("tests.schedule.interval-hint")}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if test.schedule}
|
||||
<div class="mb-4">
|
||||
<div class="row g-3">
|
||||
{#if test.schedule.last_run}
|
||||
<div class="col-auto">
|
||||
<span class="text-muted fw-semibold">
|
||||
{$t("tests.schedule.last-run")}:
|
||||
</span>
|
||||
<span>
|
||||
{formatTestDate(test.schedule.last_run, "medium", $t)}
|
||||
<small class="text-muted">
|
||||
({formatRelative(test.schedule.last_run, $t)})
|
||||
</small>
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if test.enabled && test.schedule.next_run}
|
||||
<div class="col-auto">
|
||||
<span class="text-muted fw-semibold">
|
||||
{$t("tests.schedule.next-run")}:
|
||||
</span>
|
||||
<span>
|
||||
{formatTestDate(test.schedule.next_run, "medium", $t)}
|
||||
<small class="text-muted">
|
||||
({formatRelative(test.schedule.next_run, $t)})
|
||||
</small>
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-muted">
|
||||
<Icon name="info-circle"></Icon>
|
||||
{$t("tests.schedule.no-schedule-yet")}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<Button color="primary" disabled={saving} onclick={handleSave}>
|
||||
{#if saving}
|
||||
<Spinner size="sm" class="me-1" />
|
||||
{/if}
|
||||
<Icon name="check-lg"></Icon>
|
||||
{$t("tests.schedule.save")}
|
||||
</Button>
|
||||
</CardBody>
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
237
web/src/routes/domains/[dn]/tests/[tname]/results/+page.svelte
Normal file
237
web/src/routes/domains/[dn]/tests/[tname]/results/+page.svelte
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
<!--
|
||||
This file is part of the happyDomain (R) project.
|
||||
Copyright (c) 2022-2026 happyDomain
|
||||
Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
This program is offered under a commercial and under the AGPL license.
|
||||
For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
|
||||
For AGPL licensing:
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
Card,
|
||||
Alert,
|
||||
Icon,
|
||||
Table,
|
||||
Badge,
|
||||
Button,
|
||||
Spinner,
|
||||
ButtonGroup,
|
||||
} from "@sveltestrap/sveltestrap";
|
||||
|
||||
import { t } from "$lib/translations";
|
||||
import { page } from "$app/state";
|
||||
import { listTestResults, deleteTestResult, deleteAllTestResults } from "$lib/api/tests";
|
||||
import { getPluginStatus } from "$lib/api/plugins";
|
||||
import type { Domain } from "$lib/model/domain";
|
||||
import RunTestModal from "$lib/components/modals/RunTestModal.svelte";
|
||||
import { getStatusColor, getStatusKey, formatDuration, formatTestDate } from "$lib/utils";
|
||||
|
||||
interface Props {
|
||||
data: { domain: Domain };
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
const testName = $derived(page.params.tname || "");
|
||||
|
||||
let resultsPromise = $derived(listTestResults(data.domain.id, testName));
|
||||
let pluginPromise = $derived(getPluginStatus(testName));
|
||||
let runTestModal: RunTestModal;
|
||||
let errorMessage = $state<string | null>(null);
|
||||
|
||||
function handleTestTriggered() {
|
||||
// Refresh results list after test is triggered
|
||||
resultsPromise = listTestResults(data.domain.id, testName);
|
||||
}
|
||||
|
||||
async function handleDeleteResult(resultId: string) {
|
||||
if (!confirm($t("tests.results.delete-confirm"))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteTestResult(data.domain.id, testName, resultId);
|
||||
resultsPromise = listTestResults(data.domain.id, testName);
|
||||
} catch (error: any) {
|
||||
errorMessage = error.message || $t("tests.results.delete-failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteAll() {
|
||||
if (!confirm($t("tests.results.delete-all-confirm"))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteAllTestResults(data.domain.id, testName);
|
||||
resultsPromise = listTestResults(data.domain.id, testName);
|
||||
} catch (error: any) {
|
||||
errorMessage = error.message || $t("tests.results.delete-all-failed");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{testName} Results - {data.domain.domain} - happyDomain</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex-fill pb-4 pt-2">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2>
|
||||
<span class="font-monospace">{data.domain.domain}</span>
|
||||
–
|
||||
{#await pluginPromise then plugin}
|
||||
{plugin.name || testName}
|
||||
{:catch}
|
||||
{testName}
|
||||
{/await}
|
||||
</h2>
|
||||
<div class="d-flex gap-2">
|
||||
<Button
|
||||
color="dark"
|
||||
href={`/domains/${encodeURIComponent(data.domain.domain)}/tests/${encodeURIComponent(testName)}`}
|
||||
>
|
||||
<Icon name="gear-fill"></Icon>
|
||||
{$t("tests.results.configure")}
|
||||
</Button>
|
||||
{#await pluginPromise then plugin}
|
||||
<Button
|
||||
color="primary"
|
||||
onclick={() => runTestModal.open(testName, plugin.name || testName)}
|
||||
>
|
||||
<Icon name="play-fill"></Icon>
|
||||
{$t("tests.results.run-test-now")}
|
||||
</Button>
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if errorMessage}
|
||||
{#key errorMessage}
|
||||
<Alert color="danger" dismissible>
|
||||
<Icon name="exclamation-triangle-fill"></Icon>
|
||||
{errorMessage}
|
||||
</Alert>
|
||||
{/key}
|
||||
{/if}
|
||||
|
||||
{#await resultsPromise}
|
||||
<div class="mt-5 text-center flex-fill">
|
||||
<Spinner />
|
||||
<p>{$t("tests.results.loading")}</p>
|
||||
</div>
|
||||
{:then results}
|
||||
{#if !results || results.length === 0}
|
||||
<Card body>
|
||||
<p class="text-center text-muted mb-0">
|
||||
<Icon name="info-circle"></Icon>
|
||||
{$t("tests.results.no-results")}
|
||||
</p>
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h4>{$t("tests.results.title", { count: results.length })}</h4>
|
||||
<Button size="sm" color="danger" outline onclick={handleDeleteAll}>
|
||||
<Icon name="trash"></Icon>
|
||||
{$t("tests.results.delete-all")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table hover striped>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{$t("tests.results.table.executed-at")}</th>
|
||||
<th class="text-center">{$t("tests.results.table.status")}</th>
|
||||
<th>{$t("tests.results.table.message")}</th>
|
||||
<th>{$t("tests.results.table.duration")}</th>
|
||||
<th class="text-center">{$t("tests.results.table.type")}</th>
|
||||
<th>{$t("tests.results.table.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each results as result}
|
||||
<tr>
|
||||
<td class="align-middle">
|
||||
{formatTestDate(result.executed_at, "short", $t)}
|
||||
</td>
|
||||
<td class="align-middle text-center">
|
||||
<Badge color={getStatusColor(result.status)}>
|
||||
{$t(getStatusKey(result.status))}
|
||||
</Badge>
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
{result.status_line}
|
||||
{#if result.error}
|
||||
<br />
|
||||
<small class="text-danger">{result.error}</small>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
{formatDuration(result.duration, $t)}
|
||||
</td>
|
||||
<td class="align-middle text-center">
|
||||
{#if result.scheduled_test}
|
||||
<Badge color="info">
|
||||
<Icon name="clock"></Icon>
|
||||
{$t("tests.results.type.scheduled")}
|
||||
</Badge>
|
||||
{:else}
|
||||
<Badge color="secondary">
|
||||
<Icon name="hand-index"></Icon>
|
||||
{$t("tests.results.type.manual")}
|
||||
</Badge>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
<ButtonGroup size="sm">
|
||||
<Button
|
||||
color="primary"
|
||||
href={`/domains/${encodeURIComponent(data.domain.domain)}/tests/${encodeURIComponent(testName)}/results/${encodeURIComponent(result.id)}`}
|
||||
>
|
||||
<Icon name="eye-fill"></Icon>
|
||||
{$t("tests.results.view")}
|
||||
</Button>
|
||||
<Button
|
||||
color="danger"
|
||||
outline
|
||||
onclick={() => handleDeleteResult(result.id)}
|
||||
>
|
||||
<Icon name="trash"></Icon>
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</Table>
|
||||
{/if}
|
||||
{:catch error}
|
||||
<Card body color="danger">
|
||||
<p class="mb-0">
|
||||
<Icon name="exclamation-triangle-fill"></Icon>
|
||||
{$t("tests.results.error-loading", { error: error.message })}
|
||||
</p>
|
||||
</Card>
|
||||
{/await}
|
||||
</div>
|
||||
|
||||
<RunTestModal
|
||||
domainId={data.domain.id}
|
||||
onTestTriggered={handleTestTriggered}
|
||||
bind:this={runTestModal}
|
||||
/>
|
||||
|
|
@ -0,0 +1,309 @@
|
|||
<!--
|
||||
This file is part of the happyDomain (R) project.
|
||||
Copyright (c) 2022-2026 happyDomain
|
||||
Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
This program is offered under a commercial and under the AGPL license.
|
||||
For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
|
||||
For AGPL licensing:
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Col,
|
||||
Icon,
|
||||
Row,
|
||||
Spinner,
|
||||
Table,
|
||||
} from "@sveltestrap/sveltestrap";
|
||||
|
||||
import { t } from "$lib/translations";
|
||||
import { page } from "$app/state";
|
||||
import { goto } from "$app/navigation";
|
||||
import { getTestResult, deleteTestResult, triggerTest } from "$lib/api/tests";
|
||||
import { getPluginStatus } from "$lib/api/plugins";
|
||||
import type { Domain } from "$lib/model/domain";
|
||||
import type { TestResult } from "$lib/model/test";
|
||||
import { getStatusColor, getStatusKey, formatDuration, formatTestDate } from "$lib/utils";
|
||||
|
||||
interface Props {
|
||||
data: { domain: Domain };
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
const testName = $derived(page.params.tname || "");
|
||||
const resultId = $derived(page.params.rid || "");
|
||||
|
||||
let resultPromise = $derived(getTestResult(data.domain.id, testName, resultId));
|
||||
let pluginPromise = $derived(getPluginStatus(testName));
|
||||
let errorMessage = $state<string | null>(null);
|
||||
let resolvedResult = $state<TestResult | null>(null);
|
||||
let isRelaunching = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
resultPromise.then((r) => {
|
||||
resolvedResult = r;
|
||||
});
|
||||
});
|
||||
|
||||
async function handleRelaunch() {
|
||||
if (!resolvedResult) return;
|
||||
|
||||
isRelaunching = true;
|
||||
try {
|
||||
await triggerTest(data.domain.id, testName, resolvedResult.options);
|
||||
goto(
|
||||
`/domains/${encodeURIComponent(data.domain.domain)}/tests/${encodeURIComponent(testName)}`,
|
||||
);
|
||||
} catch (error: any) {
|
||||
errorMessage = error.message || $t("tests.result.relaunch-failed");
|
||||
} finally {
|
||||
isRelaunching = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm($t("tests.result.delete-confirm"))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteTestResult(data.domain.id, testName, resultId);
|
||||
goto(
|
||||
`/domains/${encodeURIComponent(data.domain.domain)}/tests/${encodeURIComponent(testName)}`,
|
||||
);
|
||||
} catch (error: any) {
|
||||
errorMessage = error.message || $t("tests.result.delete-failed");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>
|
||||
Test Result - {testName} - {data.domain.domain} - happyDomain
|
||||
</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex-fill pb-4 pt-2 mw-100">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2 class="text-truncate">
|
||||
<span class="font-monospace">{data.domain.domain}</span>
|
||||
–
|
||||
{$t("tests.result.title")}
|
||||
</h2>
|
||||
<div class="d-flex gap-2">
|
||||
<Button
|
||||
color="primary"
|
||||
outline
|
||||
onclick={handleRelaunch}
|
||||
disabled={!resolvedResult || isRelaunching}
|
||||
>
|
||||
{#if isRelaunching}
|
||||
<Spinner size="sm" />
|
||||
{:else}
|
||||
<Icon name="arrow-repeat"></Icon>
|
||||
{/if}
|
||||
<span class="d-none d-lg-inline">
|
||||
{$t("tests.result.relaunch")}
|
||||
</span>
|
||||
</Button>
|
||||
<Button color="danger" outline onclick={handleDelete} disabled={!resolvedResult}>
|
||||
<Icon name="trash"></Icon>
|
||||
<span class="d-none d-lg-inline">
|
||||
{$t("tests.result.delete")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if errorMessage}
|
||||
{#key errorMessage}
|
||||
<Alert color="danger" dismissible>
|
||||
<Icon name="exclamation-triangle-fill"></Icon>
|
||||
{errorMessage}
|
||||
</Alert>
|
||||
{/key}
|
||||
{/if}
|
||||
|
||||
{#await Promise.all([resultPromise, pluginPromise])}
|
||||
<div class="mt-5 text-center flex-fill">
|
||||
<Spinner />
|
||||
<p>{$t("tests.result.loading")}</p>
|
||||
</div>
|
||||
{:then [result, plugin]}
|
||||
<Row>
|
||||
<Col lg>
|
||||
<Card class="mb-3">
|
||||
<CardHeader>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="d-flex align-items-end gap-2">
|
||||
<h4 class="mb-0">
|
||||
{plugin.name || testName}
|
||||
</h4>
|
||||
{#if plugin.version}
|
||||
<small
|
||||
class="text-muted"
|
||||
title={$t("tests.result.field.plugin-version")}
|
||||
>
|
||||
{plugin.version}
|
||||
</small>
|
||||
{/if}
|
||||
</div>
|
||||
{#if result.scheduled_test}
|
||||
<Badge color="info">
|
||||
<Icon name="clock"></Icon>
|
||||
{$t("tests.result.type.scheduled")}
|
||||
</Badge>
|
||||
{:else}
|
||||
<Badge color="secondary">
|
||||
<Icon name="hand-index"></Icon>
|
||||
{$t("tests.result.type.manual")}
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody class="p-2">
|
||||
<Table borderless size="sm" class="mb-0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th style="width: 200px">{$t("tests.result.field.domain")}</th>
|
||||
<td class="font-monospace">{data.domain.domain}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{$t("tests.result.field.executed-at")}</th>
|
||||
<td>{formatTestDate(result.executed_at, "long", $t)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{$t("tests.result.field.duration")}</th>
|
||||
<td>{formatDuration(result.duration, $t)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{$t("tests.result.field.status")}</th>
|
||||
<td>
|
||||
<Badge color={getStatusColor(result.status)}>
|
||||
{$t(getStatusKey(result.status))}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{$t("tests.result.field.status-message")}</th>
|
||||
<td>{result.status_line}</td>
|
||||
</tr>
|
||||
{#if result.error}
|
||||
<tr>
|
||||
<th>{$t("tests.result.field.error")}</th>
|
||||
<td class="text-danger">{result.error}</td>
|
||||
</tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
</Table>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Col>
|
||||
{#if result.options && Object.keys(result.options).length > 0}
|
||||
<Col lg>
|
||||
<Card class="mb-3">
|
||||
<CardHeader>
|
||||
<h5 class="mb-0">
|
||||
<Icon name="sliders"></Icon>
|
||||
{$t("tests.result.test-options")}
|
||||
</h5>
|
||||
</CardHeader>
|
||||
<CardBody class="p-2">
|
||||
<Table borderless size="sm" class="mb-0">
|
||||
<tbody>
|
||||
{#each Object.entries(plugin.options ?? {}) as [optKey, optVals]}
|
||||
{#each optVals as option}
|
||||
{@const value =
|
||||
(option.id
|
||||
? result.options[option.id]
|
||||
: undefined) ||
|
||||
option.default ||
|
||||
option.placeholder ||
|
||||
""}
|
||||
<tr>
|
||||
<th
|
||||
class="text-truncate"
|
||||
style="max-width: min(200px, 40vw)"
|
||||
title={option.label}
|
||||
>
|
||||
{option.label}:
|
||||
</th>
|
||||
<td class:text-truncate={typeof value !== "object"}>
|
||||
{#if typeof value === "object"}
|
||||
<pre class="mb-0"><code
|
||||
>{JSON.stringify(
|
||||
value,
|
||||
null,
|
||||
2,
|
||||
)}</code
|
||||
></pre>
|
||||
{:else}
|
||||
{value}
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/each}
|
||||
</tbody>
|
||||
</Table>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Col>
|
||||
{/if}
|
||||
</Row>
|
||||
|
||||
{#if result.report}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h5 class="mb-0">
|
||||
<Icon name="file-earmark-text"></Icon>
|
||||
{$t("tests.result.full-report")}
|
||||
</h5>
|
||||
</CardHeader>
|
||||
<CardBody class="text-truncate p-0">
|
||||
{#if typeof result.report === "string"}
|
||||
<pre class="bg-light p-3 rounded mb-0"><code>{result.report}</code></pre>
|
||||
{:else}
|
||||
<pre class="bg-light p-3 rounded mb-0"><code
|
||||
>{JSON.stringify(result.report, null, 2)}</code
|
||||
></pre>
|
||||
{/if}
|
||||
</CardBody>
|
||||
</Card>
|
||||
{/if}
|
||||
{:catch error}
|
||||
<Card body color="danger">
|
||||
<p class="mb-0">
|
||||
<Icon name="exclamation-triangle-fill"></Icon>
|
||||
{$t("tests.result.error-loading", { error: error.message })}
|
||||
</p>
|
||||
</Card>
|
||||
{/await}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
pre {
|
||||
overflow-x: scroll;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -36,11 +36,16 @@
|
|||
} from "@sveltestrap/sveltestrap";
|
||||
|
||||
import { t } from '$lib/translations';
|
||||
import { listPlugins } from '$lib/api/plugins';
|
||||
|
||||
let pluginsPromise = $state(listPlugins());
|
||||
import { plugins, refreshPlugins } from '$lib/stores/plugins';
|
||||
|
||||
let searchQuery = $state('');
|
||||
|
||||
// Load plugins if not already loaded
|
||||
$effect(() => {
|
||||
if ($plugins === undefined) {
|
||||
refreshPlugins();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
|
@ -58,9 +63,9 @@
|
|||
<span class="lead">
|
||||
{$t('plugins.tests.description')}
|
||||
</span>
|
||||
{#await pluginsPromise then plugins}
|
||||
<span>{$t('plugins.tests.available-count', { count: Object.keys(plugins ?? {}).length })}</span>
|
||||
{/await}
|
||||
{#if $plugins}
|
||||
<span>{$t('plugins.tests.available-count', { count: Object.keys($plugins).length })}</span>
|
||||
{/if}
|
||||
</p>
|
||||
</Col>
|
||||
</Row>
|
||||
|
|
@ -80,14 +85,14 @@
|
|||
</Col>
|
||||
</Row>
|
||||
|
||||
{#await pluginsPromise}
|
||||
{#if !$plugins}
|
||||
<Card body>
|
||||
<p class="text-center mb-0">
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>
|
||||
{$t('plugins.tests.loading')}
|
||||
</p>
|
||||
</Card>
|
||||
{:then plugins}
|
||||
{:else}
|
||||
<div class="table-responsive">
|
||||
<Table hover bordered>
|
||||
<thead>
|
||||
|
|
@ -99,14 +104,14 @@
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if !plugins || Object.keys(plugins).length == 0}
|
||||
{#if Object.keys($plugins).length == 0}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center text-muted py-4">
|
||||
{$t('plugins.tests.no-tests')}
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#each Object.entries(plugins ?? {}).filter(([name, _info]) => name.toLowerCase().indexOf(searchQuery.toLowerCase()) > -1) as [pluginName, pluginInfo]}
|
||||
{#each Object.entries($plugins).filter(([name, _info]) => name.toLowerCase().indexOf(searchQuery.toLowerCase()) > -1) as [pluginName, pluginInfo]}
|
||||
<tr>
|
||||
<td><strong>{pluginInfo.name || pluginName}</strong></td>
|
||||
<td>{pluginInfo.version}</td>
|
||||
|
|
@ -141,12 +146,5 @@
|
|||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
{:catch error}
|
||||
<Card body color="danger">
|
||||
<p class="mb-0">
|
||||
<Icon name="exclamation-triangle-fill"></Icon>
|
||||
{$t('plugins.tests.error-loading', { error: error.message })}
|
||||
</p>
|
||||
</Card>
|
||||
{/await}
|
||||
{/if}
|
||||
</Container>
|
||||
|
|
|
|||
|
|
@ -106,8 +106,15 @@
|
|||
}
|
||||
}
|
||||
|
||||
function getOrphanedOptions(userOpts: any[]): string[] {
|
||||
function getOrphanedOptions(userOpts: any[], readOnlyOptGroups: any[]): string[] {
|
||||
const validOptIds = new Set(userOpts.map((opt) => opt.id));
|
||||
|
||||
for (const group of readOnlyOptGroups) {
|
||||
for (const opt of group.opts) {
|
||||
validOptIds.add(opt.id);
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(optionValues).filter((key) => !validOptIds.has(key));
|
||||
}
|
||||
</script>
|
||||
|
|
@ -239,7 +246,7 @@
|
|||
]}
|
||||
{@const hasAnyOpts =
|
||||
userOpts.length > 0 || readOnlyOptGroups.some((g) => g.opts.length > 0)}
|
||||
{@const orphanedOpts = getOrphanedOptions(userOpts)}
|
||||
{@const orphanedOpts = getOrphanedOptions(userOpts, readOnlyOptGroups)}
|
||||
|
||||
{#if orphanedOpts.length > 0}
|
||||
<Alert color="warning" class="mb-3">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue