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
e5fa392c76
commit
771b8bf1a8
27 changed files with 2418 additions and 122 deletions
|
|
@ -131,6 +131,7 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, captchaVerifi
|
|||
router.GET("/service-worker.js", serveFile)
|
||||
|
||||
// Routes to virtual content
|
||||
router.GET("/checkers/*_", serveIndex)
|
||||
router.GET("/domains/*_", serveIndex)
|
||||
router.GET("/email-validation", serveIndex)
|
||||
router.GET("/forgotten-password", serveIndex)
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ import {
|
|||
putChecksByCnameOptionsByOptname,
|
||||
postDomainsByDomainChecksByCname,
|
||||
getDomainsByDomainChecksByCnameOptions,
|
||||
postDomainsByDomainChecksByCnameOptions,
|
||||
putDomainsByDomainChecksByCnameOptions,
|
||||
getDomainsByDomainChecks,
|
||||
getDomainsByDomainChecksByCnameResults,
|
||||
getDomainsByDomainChecksByCnameResultsByResultId,
|
||||
|
|
@ -43,14 +45,14 @@ import type {
|
|||
CheckerList,
|
||||
CheckerInfo,
|
||||
CheckerOptions,
|
||||
AvailableCheck,
|
||||
AvailableChecker,
|
||||
CheckerSchedule,
|
||||
CheckResult,
|
||||
CheckExecution,
|
||||
} from "$lib/model/check";
|
||||
import { CheckScopeType } from "$lib/model/check";
|
||||
CheckScopeType,
|
||||
} from "$lib/model/checker";
|
||||
|
||||
export async function listChecks(): Promise<CheckerList> {
|
||||
export async function listCheckers(): Promise<CheckerList> {
|
||||
return unwrapSdkResponse(await getChecks()) as CheckerList;
|
||||
}
|
||||
|
||||
|
|
@ -123,6 +125,32 @@ export async function getDomainCheckOptions(
|
|||
) as CheckerOptions;
|
||||
}
|
||||
|
||||
export async function addDomainCheckOptions(
|
||||
domainId: string,
|
||||
checkId: string,
|
||||
options: CheckerOptions,
|
||||
): Promise<boolean> {
|
||||
return unwrapSdkResponse(
|
||||
await postDomainsByDomainChecksByCnameOptions({
|
||||
path: { domain: domainId, cname: checkId },
|
||||
body: { options } as any,
|
||||
}),
|
||||
) as boolean;
|
||||
}
|
||||
|
||||
export async function updateDomainCheckOptions(
|
||||
domainId: string,
|
||||
checkId: string,
|
||||
options: CheckerOptions,
|
||||
): Promise<boolean> {
|
||||
return unwrapSdkResponse(
|
||||
await putDomainsByDomainChecksByCnameOptions({
|
||||
path: { domain: domainId, cname: checkId },
|
||||
body: { options } as any,
|
||||
}),
|
||||
) as boolean;
|
||||
}
|
||||
|
||||
export async function triggerCheck(
|
||||
domainId: string,
|
||||
checkId: string,
|
||||
|
|
@ -141,12 +169,12 @@ export async function triggerCheck(
|
|||
) as { execution_id?: string };
|
||||
}
|
||||
|
||||
export async function listAvailableChecks(domainId: string): Promise<AvailableCheck[]> {
|
||||
export async function listAvailableCheckers(domainId: string): Promise<AvailableChecker[]> {
|
||||
return unwrapSdkResponse(
|
||||
await getDomainsByDomainChecks({
|
||||
path: { domain: domainId },
|
||||
}),
|
||||
) as unknown as AvailableCheck[];
|
||||
) as unknown as AvailableChecker[];
|
||||
}
|
||||
|
||||
export async function createCheckSchedule(data: {
|
||||
|
|
@ -192,7 +220,6 @@ export async function updateCheckSchedule(
|
|||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getCheckResult(
|
||||
domainId: string,
|
||||
checkName: string,
|
||||
107
web/src/lib/components/checkers/CheckerOptionsCard.svelte
Normal file
107
web/src/lib/components/checkers/CheckerOptionsCard.svelte
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<!--
|
||||
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 {
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Form,
|
||||
FormGroup,
|
||||
Icon,
|
||||
} from "@sveltestrap/sveltestrap";
|
||||
|
||||
import Resource from "$lib/components/inputs/Resource.svelte";
|
||||
import { t } from "$lib/translations";
|
||||
import { toasts } from "$lib/stores/toasts";
|
||||
|
||||
interface Props {
|
||||
options: Array<any>;
|
||||
optionValues: Record<string, any>;
|
||||
title: string;
|
||||
saveOptionsFn: (values: Record<string, any>) => Promise<void>;
|
||||
}
|
||||
|
||||
let { options, optionValues = $bindable(), title, saveOptionsFn }: Props = $props();
|
||||
|
||||
let saving = $state(false);
|
||||
|
||||
async function handleSave() {
|
||||
saving = true;
|
||||
try {
|
||||
await saveOptionsFn(optionValues);
|
||||
toasts.addToast({
|
||||
message: $t("checkers.messages.options-updated"),
|
||||
type: "success",
|
||||
timeout: 5000,
|
||||
});
|
||||
} catch (e: any) {
|
||||
toasts.addErrorToast({
|
||||
message: $t("checkers.messages.update-failed", { error: e.message }),
|
||||
});
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if options && options.length > 0}
|
||||
<Card class="mt-3">
|
||||
<CardHeader>
|
||||
<strong>{title}</strong>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Form
|
||||
on:submit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}}
|
||||
>
|
||||
{#each options as optDoc}
|
||||
{#if optDoc.id}
|
||||
{@const optName = optDoc.id}
|
||||
<FormGroup>
|
||||
<Resource
|
||||
edit={true}
|
||||
index={optName}
|
||||
specs={optDoc}
|
||||
type={optDoc.type || "string"}
|
||||
bind:value={optionValues[optName]}
|
||||
/>
|
||||
</FormGroup>
|
||||
{/if}
|
||||
{/each}
|
||||
<div class="d-flex gap-2">
|
||||
<Button type="submit" color="success" disabled={saving}>
|
||||
{#if saving}
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>
|
||||
{/if}
|
||||
<Icon name="check-circle"></Icon>
|
||||
{$t("checkers.detail.save-changes")}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
{/if}
|
||||
|
|
@ -53,7 +53,7 @@
|
|||
<Card class="mb-3">
|
||||
<CardHeader>
|
||||
<strong>{optGroup.label}</strong>
|
||||
<small class="text-muted ms-2">{$t("checks.detail.read-only")}</small>
|
||||
<small class="text-muted ms-2">{$t("checkers.detail.read-only")}</small>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<dl class="row mb-0">
|
||||
|
|
@ -72,13 +72,13 @@
|
|||
<small class="text-muted d-block">{optDoc.description}</small>
|
||||
{/if}
|
||||
<small class="text-muted">
|
||||
{$t("checks.option-groups.type", {
|
||||
{$t("checkers.option-groups.type", {
|
||||
type: optDoc.type || "string",
|
||||
})}
|
||||
</small>
|
||||
{#if optDoc.required}
|
||||
<small class="text-danger ms-2">
|
||||
{$t("checks.option-groups.required")}
|
||||
{$t("checkers.option-groups.required")}
|
||||
</small>
|
||||
{/if}
|
||||
</dd>
|
||||
|
|
|
|||
170
web/src/lib/components/checkers/CheckerScheduleCard.svelte
Normal file
170
web/src/lib/components/checkers/CheckerScheduleCard.svelte
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
<!--
|
||||
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 {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Icon,
|
||||
Input,
|
||||
Spinner,
|
||||
} from "@sveltestrap/sveltestrap";
|
||||
|
||||
import { t } from "$lib/translations";
|
||||
import type { AvailableChecker } from "$lib/model/checker";
|
||||
import type { HappydnsCheckIntervalSpec } from "$lib/api-base/types.gen";
|
||||
import { formatCheckDate, formatRelative } from "$lib/utils";
|
||||
|
||||
const NS_PER_HOUR = 3600 * 1e9;
|
||||
|
||||
interface Props {
|
||||
checker: AvailableChecker;
|
||||
intervalSpec?: HappydnsCheckIntervalSpec;
|
||||
formEnabled: boolean;
|
||||
formIntervalHours: number;
|
||||
saving: boolean;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
let { checker, intervalSpec, formEnabled = $bindable(), formIntervalHours = $bindable(), saving, onSave }: Props = $props();
|
||||
|
||||
const minHours = $derived(intervalSpec?.min ? intervalSpec.min / NS_PER_HOUR : 1);
|
||||
const maxHours = $derived(intervalSpec?.max ? intervalSpec.max / NS_PER_HOUR : undefined);
|
||||
|
||||
$effect(() => {
|
||||
if (formIntervalHours < minHours) {
|
||||
formIntervalHours = minHours;
|
||||
}
|
||||
if (maxHours !== undefined && formIntervalHours > maxHours) {
|
||||
formIntervalHours = maxHours;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Card class="mb-4">
|
||||
<CardHeader>
|
||||
<h4 class="mb-0">
|
||||
<Icon name="clock-history"></Icon>
|
||||
{$t("checkers.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("checkers.schedule.auto-enabled")}</Badge>
|
||||
{:else}
|
||||
<Badge color="secondary">{$t("checkers.schedule.auto-disabled")}</Badge>
|
||||
{/if}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if formEnabled}
|
||||
<div class="mb-4">
|
||||
<label for="schedule-interval" class="form-label fw-semibold">
|
||||
{$t("checkers.schedule.interval-label")}
|
||||
</label>
|
||||
<div class="input-group" style="max-width: 300px;">
|
||||
<Input
|
||||
type="number"
|
||||
id="schedule-interval"
|
||||
min={minHours}
|
||||
max={maxHours}
|
||||
step={minHours < 1 ? 0.1 : 1}
|
||||
bind:value={formIntervalHours}
|
||||
disabled={saving}
|
||||
/>
|
||||
<span class="input-group-text">
|
||||
{$t("checkers.schedule.hours")}
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
{#if intervalSpec}
|
||||
{$t("checkers.schedule.interval-hint-bounded", { min: minHours, max: maxHours })}
|
||||
{:else}
|
||||
{$t("checkers.schedule.interval-hint")}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if checker.schedule}
|
||||
<div class="mb-4">
|
||||
<div class="row g-3">
|
||||
{#if checker.schedule.last_run}
|
||||
<div class="col-auto">
|
||||
<span class="text-muted fw-semibold">
|
||||
{$t("checkers.schedule.last-run")}:
|
||||
</span>
|
||||
<span>
|
||||
{formatCheckDate(checker.schedule.last_run, "medium", $t)}
|
||||
<small class="text-muted">
|
||||
({formatRelative(checker.schedule.last_run, $t)})
|
||||
</small>
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if checker.enabled && checker.schedule.next_run}
|
||||
<div class="col-auto">
|
||||
<span class="text-muted fw-semibold">
|
||||
{$t("checkers.schedule.next-run")}:
|
||||
</span>
|
||||
<span>
|
||||
{formatCheckDate(checker.schedule.next_run, "medium", $t)}
|
||||
<small class="text-muted">
|
||||
({formatRelative(checker.schedule.next_run, $t)})
|
||||
</small>
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-muted">
|
||||
<Icon name="info-circle"></Icon>
|
||||
{$t("checkers.schedule.no-schedule-yet")}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<Button color="primary" disabled={saving} onclick={onSave}>
|
||||
{#if saving}
|
||||
<Spinner size="sm" class="me-1" />
|
||||
{/if}
|
||||
<Icon name="check-lg"></Icon>
|
||||
{$t("checkers.schedule.save")}
|
||||
</Button>
|
||||
</CardBody>
|
||||
</Card>
|
||||
83
web/src/lib/components/checkers/CheckerSidebar.svelte
Normal file
83
web/src/lib/components/checkers/CheckerSidebar.svelte
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<!--
|
||||
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 { Icon, Spinner } from "@sveltestrap/sveltestrap";
|
||||
|
||||
import { checkers } from "$lib/stores/checkers";
|
||||
import { t } from "$lib/translations";
|
||||
|
||||
interface Props {
|
||||
currentCheckId: string;
|
||||
}
|
||||
|
||||
let { currentCheckId }: Props = $props();
|
||||
</script>
|
||||
|
||||
<nav class="checker-sidebar d-flex flex-column h-100">
|
||||
<a
|
||||
href="/checkers"
|
||||
class="sidebar-back d-flex align-items-center gap-1 mb-3 text-muted text-decoration-none fw-semibold"
|
||||
>
|
||||
<Icon name="chevron-left" />
|
||||
{$t("checkers.title")}
|
||||
</a>
|
||||
|
||||
{#if !$checkers}
|
||||
<div class="d-flex gap-2 align-items-center justify-content-center my-3 text-muted">
|
||||
<Spinner size="sm" color="primary" />
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="list-unstyled mb-0 flex-fill overflow-auto">
|
||||
{#each Object.entries($checkers) as [checkerName, checkerInfo]}
|
||||
{@const isActive = checkerName === currentCheckId}
|
||||
<li>
|
||||
<a
|
||||
href="/checkers/{encodeURIComponent(checkerName)}"
|
||||
class="checker-item d-flex align-items-center gap-2 py-2 px-2 rounded text-decoration-none {isActive
|
||||
? 'fw-bold text-primary active'
|
||||
: 'text-muted'}"
|
||||
>
|
||||
<span class="text-truncate">
|
||||
{checkerInfo.name || checkerName}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.checker-item {
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.checker-item:hover {
|
||||
background-color: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.checker-item.active {
|
||||
background-color: rgba(var(--bs-primary-rgb), 0.1);
|
||||
}
|
||||
</style>
|
||||
105
web/src/lib/components/checkers/CheckersAvailabilityTable.svelte
Normal file
105
web/src/lib/components/checkers/CheckersAvailabilityTable.svelte
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<!--
|
||||
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 { Badge, Icon, Table } from "@sveltestrap/sveltestrap";
|
||||
|
||||
import { navigate } from "$lib/stores/config";
|
||||
import { t } from "$lib/translations";
|
||||
import type { CheckerInfo } from "$lib/model/checker";
|
||||
|
||||
interface Props {
|
||||
checkers: [string, CheckerInfo][];
|
||||
basePath: string;
|
||||
configureKey?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
checkers,
|
||||
basePath,
|
||||
configureKey = "checkers.actions.configure",
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<Table striped hover responsive>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{$t("checkers.table.name")}</th>
|
||||
<th>{$t("checkers.table.availability")}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each checkers as [checkerName, checkerInfo]}
|
||||
<tr
|
||||
style="cursor: pointer"
|
||||
onclick={() => navigate(`${basePath}/${encodeURIComponent(checkerName)}`)}
|
||||
>
|
||||
<td><strong>{checkerInfo.name || checkerName}</strong></td>
|
||||
<td>
|
||||
{#if checkerInfo.availability}
|
||||
{#if checkerInfo.availability.applyToDomain}
|
||||
<Badge color="success">
|
||||
{$t("checkers.availability.domain")}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if checkerInfo.availability.applyToZone}
|
||||
<Badge color="success">
|
||||
{$t("checkers.availability.zone")}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if checkerInfo.availability.limitToProviders && checkerInfo.availability.limitToProviders.length > 0}
|
||||
<Badge
|
||||
color="primary"
|
||||
title={checkerInfo.availability.limitToProviders.join(", ")}
|
||||
>
|
||||
{$t("checkers.availability.provider-specific")}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if checkerInfo.availability.limitToServices && checkerInfo.availability.limitToServices.length > 0}
|
||||
<Badge
|
||||
color="info"
|
||||
title={checkerInfo.availability.limitToServices.join(", ")}
|
||||
>
|
||||
{$t("checkers.availability.service-specific")}
|
||||
</Badge>
|
||||
{/if}
|
||||
{:else}
|
||||
<Badge color="secondary">
|
||||
{$t("checkers.availability.general")}
|
||||
</Badge>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<a
|
||||
href="{basePath}/{encodeURIComponent(checkerName)}"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
>
|
||||
<Icon name="gear-fill"></Icon>
|
||||
{$t(configureKey)}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</Table>
|
||||
131
web/src/lib/components/checkers/DomainCheckerSidebar.svelte
Normal file
131
web/src/lib/components/checkers/DomainCheckerSidebar.svelte
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
<!--
|
||||
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 type { ClassValue } from "svelte/elements";
|
||||
import { Icon, Spinner } from "@sveltestrap/sveltestrap";
|
||||
|
||||
import { checkers } from "$lib/stores/checkers";
|
||||
|
||||
interface Props {
|
||||
class?: ClassValue;
|
||||
domainName: string;
|
||||
currentCheckerName: string;
|
||||
}
|
||||
|
||||
let { class: className = "", domainName, currentCheckerName }: Props = $props();
|
||||
</script>
|
||||
|
||||
<nav class="checker-sidebar d-flex flex-column h-100 {className}">
|
||||
{#if !$checkers}
|
||||
<div class="d-flex gap-2 align-items-center justify-content-center my-3 text-muted">
|
||||
<Spinner size="sm" color="primary" />
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="list-unstyled mb-0 flex-fill overflow-auto">
|
||||
{#each Object.entries($checkers) as [checkerName, checkerInfo]}
|
||||
{@const isActive = checkerName === currentCheckerName}
|
||||
{@const onResults = page.route.id?.startsWith(
|
||||
"/domains/[dn]/checks/[cname]/results",
|
||||
)}
|
||||
<li>
|
||||
<div
|
||||
class="checker-item d-flex align-items-center gap-1 py-2 px-2 rounded {isActive
|
||||
? 'fw-bold text-primary active'
|
||||
: 'text-muted'}"
|
||||
>
|
||||
<a
|
||||
href="/domains/{encodeURIComponent(
|
||||
domainName,
|
||||
)}/checks/{encodeURIComponent(checkerName)}{onResults
|
||||
? '/results'
|
||||
: ''}"
|
||||
class="text-truncate flex-fill text-decoration-none {isActive
|
||||
? 'text-primary'
|
||||
: 'text-muted'}"
|
||||
>
|
||||
{checkerInfo.name || checkerName}
|
||||
</a>
|
||||
{#if onResults}
|
||||
<a
|
||||
href="/domains/{encodeURIComponent(
|
||||
domainName,
|
||||
)}/checks/{encodeURIComponent(checkerName)}"
|
||||
class="checker-action text-muted"
|
||||
title="Configure"
|
||||
>
|
||||
<Icon name="gear" />
|
||||
</a>
|
||||
{:else}
|
||||
<a
|
||||
href="/domains/{encodeURIComponent(
|
||||
domainName,
|
||||
)}/checks/{encodeURIComponent(checkerName)}/results"
|
||||
class="checker-action text-muted"
|
||||
title="Results"
|
||||
>
|
||||
<Icon name="bar-chart-fill" />
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.checker-item {
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.checker-item:hover {
|
||||
background-color: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.checker-action {
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
text-decoration: none;
|
||||
line-height: 1;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.checker-action.always-visible {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.checker-item:hover .checker-action,
|
||||
.checker-item.active .checker-action {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.checker-action:hover {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.checker-item.active {
|
||||
background-color: rgba(var(--bs-primary-rgb), 0.1);
|
||||
}
|
||||
</style>
|
||||
204
web/src/lib/components/modals/RunCheckModal.svelte
Normal file
204
web/src/lib/components/modals/RunCheckModal.svelte
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
<!--
|
||||
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 { triggerCheck, getDomainCheckOptions, getCheckStatus } from "$lib/api/checkers";
|
||||
import type { CheckerOptions } from "$lib/model/checker";
|
||||
import Resource from "$lib/components/inputs/Resource.svelte";
|
||||
import { toasts } from "$lib/stores/toasts";
|
||||
import { t } from "$lib/translations";
|
||||
|
||||
interface Props {
|
||||
domainId: string;
|
||||
onCheckTriggered?: (execution_id: string, checker_name: string) => void;
|
||||
}
|
||||
|
||||
let { domainId, onCheckTriggered }: Props = $props();
|
||||
|
||||
let isOpen = $state(false);
|
||||
let checkName = $state<string>("");
|
||||
let checkDisplayName = $state<string>("");
|
||||
let checkStatusPromise = $state<Promise<any> | null>(null);
|
||||
let domainOptionsPromise = $state<Promise<CheckerOptions> | null>(null);
|
||||
let runOptions = $state<Record<string, any>>({});
|
||||
let triggering = $state(false);
|
||||
let showAdvanced = $state(false);
|
||||
|
||||
const toggle = () => (isOpen = !isOpen);
|
||||
|
||||
export function open(name: string, displayName: string) {
|
||||
checkName = name;
|
||||
checkDisplayName = displayName;
|
||||
runOptions = {};
|
||||
checkStatusPromise = getCheckStatus(name);
|
||||
domainOptionsPromise = getDomainCheckOptions(domainId, name);
|
||||
isOpen = true;
|
||||
|
||||
// Pre-populate with domain options when they load
|
||||
domainOptionsPromise.then((options) => {
|
||||
runOptions = { ...(options || {}) };
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRunCheck() {
|
||||
triggering = true;
|
||||
try {
|
||||
const result = await triggerCheck(domainId, checkName, runOptions);
|
||||
toasts.addToast({
|
||||
message: $t("checkers.run-check.triggered-success", { id: result.execution_id }),
|
||||
type: "success",
|
||||
timeout: 5000,
|
||||
});
|
||||
isOpen = false;
|
||||
if (onCheckTriggered && result.execution_id) {
|
||||
onCheckTriggered(result.execution_id, checkName);
|
||||
}
|
||||
} catch (error) {
|
||||
toasts.addErrorToast({
|
||||
message: $t("checkers.run-check.trigger-failed", { error: String(error) }),
|
||||
timeout: 10000,
|
||||
});
|
||||
} finally {
|
||||
triggering = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal {isOpen} {toggle} size="lg">
|
||||
<ModalHeader {toggle}>
|
||||
{$t("checkers.run-check.title")}: {checkDisplayName}
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
{#if checkStatusPromise && domainOptionsPromise}
|
||||
{#await Promise.all([checkStatusPromise, domainOptionsPromise])}
|
||||
<div class="text-center py-3">
|
||||
<Spinner />
|
||||
<p class="mt-2">{$t("checkers.run-check.loading-options")}</p>
|
||||
</div>
|
||||
{:then [status, _domainOpts]}
|
||||
{@const runOpts = status.options?.runOpts || []}
|
||||
{#if runOpts.length > 0}
|
||||
<p>
|
||||
{$t("checkers.run-check.configure-info")}
|
||||
</p>
|
||||
<Form
|
||||
id="run-test-modal"
|
||||
on:submit={(e) => {
|
||||
e.preventDefault();
|
||||
handleRunCheck();
|
||||
}}
|
||||
>
|
||||
{#each runOpts as optDoc}
|
||||
{#if optDoc.id}
|
||||
{@const optName = optDoc.id}
|
||||
<FormGroup>
|
||||
<Resource
|
||||
edit={true}
|
||||
index={optName}
|
||||
specs={optDoc}
|
||||
type={optDoc.type || "string"}
|
||||
readonly={!!optDoc.autoFill}
|
||||
bind:value={runOptions[optName]}
|
||||
/>
|
||||
</FormGroup>
|
||||
{/if}
|
||||
{/each}
|
||||
{@const otherOpts = [
|
||||
...(status.options?.adminOpts || []),
|
||||
...(status.options?.userOpts || []),
|
||||
...(status.options?.domainOpts || []),
|
||||
...(status.options?.serviceOpts || []),
|
||||
].filter((o) => o.id)}
|
||||
{#if otherOpts.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-link btn-sm px-0 mb-2 text-muted d-flex align-items-center gap-1 text-decoration-none"
|
||||
onclick={() => (showAdvanced = !showAdvanced)}
|
||||
>
|
||||
<Icon name={showAdvanced ? "chevron-down" : "chevron-right"} />
|
||||
{$t("checkers.run-check.advanced-options")}
|
||||
</button>
|
||||
{#if showAdvanced}
|
||||
{#each otherOpts as optDoc}
|
||||
{@const optName = optDoc.id}
|
||||
<FormGroup>
|
||||
<Resource
|
||||
edit={true}
|
||||
index={optName}
|
||||
specs={optDoc}
|
||||
type={optDoc.type || "string"}
|
||||
readonly={true}
|
||||
bind:value={runOptions[optName]}
|
||||
/>
|
||||
</FormGroup>
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
</Form>
|
||||
{:else}
|
||||
<Alert color="info" class="mb-0">
|
||||
<Icon name="info-circle"></Icon>
|
||||
{$t("checkers.run-check.no-options")}
|
||||
</Alert>
|
||||
{/if}
|
||||
{:catch error}
|
||||
<Alert color="danger">
|
||||
<Icon name="exclamation-triangle-fill"></Icon>
|
||||
{$t("checkers.run-check.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={handleRunCheck}
|
||||
disabled={triggering}
|
||||
>
|
||||
{#if triggering}
|
||||
<Spinner size="sm" class="me-1" />
|
||||
{:else}
|
||||
<Icon name="play-fill"></Icon>
|
||||
{/if}
|
||||
{$t("checkers.run-check.run-button")}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
|
|
@ -85,6 +85,7 @@
|
|||
"share": "Share the zone…",
|
||||
"upload": "Import a zone file",
|
||||
"view": "View my zone",
|
||||
"view-checks": "View checks",
|
||||
"others": "More actions on {{domain}}"
|
||||
},
|
||||
"alert": {
|
||||
|
|
@ -255,7 +256,7 @@
|
|||
"my-domains": "My domains",
|
||||
"my-providers": "My domain providers",
|
||||
"dns-resolver": "DNS resolver",
|
||||
"checkers": "Domain Checkers",
|
||||
"checkers": "Configure Checkers",
|
||||
"my-account": "My account",
|
||||
"logout": "Sign out",
|
||||
"provider-features": "Supported providers",
|
||||
|
|
@ -590,6 +591,132 @@
|
|||
}
|
||||
},
|
||||
"checkers": {
|
||||
"run-check": {
|
||||
"title": "Run Check",
|
||||
"loading-options": "Loading checker options...",
|
||||
"configure-info": "Configure checker options below. Pre-filled values are from domain-level settings.",
|
||||
"no-options": "This checker has no configurable options. Click \"Run Check\" to execute with default settings.",
|
||||
"error-loading-options": "Error loading checker options: {{error}}",
|
||||
"run-button": "Run Check",
|
||||
"triggered-success": "Check triggered successfully! Execution ID: {{id}}",
|
||||
"trigger-failed": "Failed to trigger check: {{error}}",
|
||||
"advanced-options": "Advanced options"
|
||||
},
|
||||
"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",
|
||||
"pending": "Pending",
|
||||
"not-run": "Not run"
|
||||
},
|
||||
"list": {
|
||||
"title": "Checks for ",
|
||||
"loading": "Loading checkers...",
|
||||
"loading-checkers": "Loading checker information...",
|
||||
"no-checks": "No checks available for this domain.",
|
||||
"run-check": "Run Check",
|
||||
"view-results": "View Results",
|
||||
"configure": "Configure",
|
||||
"error-loading": "Error loading checkers: {{error}}",
|
||||
"unknown-version": "Unknown",
|
||||
"table": {
|
||||
"checker": "Checker",
|
||||
"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 check will run once per interval.",
|
||||
"interval-hint-bounded": "Between {{min}} and {{max}} hours.",
|
||||
"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 check results...",
|
||||
"no-results": "No check results yet. Click \"Run Check Now\" to execute the check.",
|
||||
"title": "Check Results ({{count}})",
|
||||
"run-check-now": "Run Check Now",
|
||||
"back-to-checks": "Back to checks",
|
||||
"delete-all": "Delete All",
|
||||
"delete-confirm": "Are you sure you want to delete this check result?",
|
||||
"delete-all-confirm": "Are you sure you want to delete ALL check results for this checker? 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 checker results: {{error}}",
|
||||
"table": {
|
||||
"executed-at": "Executed At",
|
||||
"status": "Status",
|
||||
"message": "Message",
|
||||
"duration": "Duration",
|
||||
"type": "Type",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"type": {
|
||||
"scheduled": "Scheduled",
|
||||
"manual": "Manual"
|
||||
},
|
||||
"pending": {
|
||||
"queued": "Queued",
|
||||
"queued-description": "Queued, waiting to run…",
|
||||
"running": "Running",
|
||||
"running-description": "Check is currently running…"
|
||||
},
|
||||
"view": "View"
|
||||
},
|
||||
"result": {
|
||||
"title": "Check Result Details",
|
||||
"loading": "Loading check result...",
|
||||
"relaunch": "Relaunch Check",
|
||||
"delete": "Delete Result",
|
||||
"relaunch-failed": "Failed to relaunch check",
|
||||
"delete-confirm": "Are you sure you want to delete this check?",
|
||||
"delete-failed": "Failed to delete result",
|
||||
"error-loading": "Error loading check: {{error}}",
|
||||
"milliseconds": "milliseconds",
|
||||
"seconds": "seconds",
|
||||
"type": {
|
||||
"scheduled": "Scheduled Check",
|
||||
"manual": "Manual Check"
|
||||
},
|
||||
"check-options": "Check Options",
|
||||
"full-report": "Full Report",
|
||||
"field": {
|
||||
"domain": "Domain:",
|
||||
"executed-at": "Executed At:",
|
||||
"duration": "Duration:",
|
||||
"status": "Status:",
|
||||
"status-message": "Message:",
|
||||
"error": "Error:"
|
||||
}
|
||||
},
|
||||
"title": "Checkers",
|
||||
"description": "Configure automated checks for your domains",
|
||||
"available-count": "Available: {{count}} checkers",
|
||||
|
|
@ -608,10 +735,12 @@
|
|||
},
|
||||
"availability": {
|
||||
"domain": "Domain",
|
||||
"zone": "Zone",
|
||||
"provider-specific": "Provider-specific",
|
||||
"service-specific": "Service-specific",
|
||||
"general": "General",
|
||||
"domain-level": "Domain-level",
|
||||
"zone-level": "Zone-level",
|
||||
"providers": "Providers: {{providers}}",
|
||||
"services": "Services: {{services}}"
|
||||
},
|
||||
|
|
@ -654,6 +783,8 @@
|
|||
"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-checks": "Back to checks"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
78
web/src/lib/model/checker.ts
Normal file
78
web/src/lib/model/checker.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
// 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 {
|
||||
HappydnsCheckerAvailability,
|
||||
HappydnsCheckerOptionDocumentation,
|
||||
HappydnsCheckerOptionsDocumentation,
|
||||
HappydnsCheckerOptions,
|
||||
HappydnsCheckerResponse,
|
||||
HappydnsCheckResult,
|
||||
HappydnsCheckExecution,
|
||||
HappydnsCheckerSchedule,
|
||||
} from "$lib/api-base/types.gen";
|
||||
|
||||
// Re-export auto-generated types with better names
|
||||
export type CheckerAvailability = HappydnsCheckerAvailability;
|
||||
export type CheckerInfo = HappydnsCheckerResponse;
|
||||
export type CheckerList = { [key: string]: HappydnsCheckerResponse };
|
||||
export type CheckerOptions = HappydnsCheckerOptions;
|
||||
export type CheckerOptionsDocumentation = HappydnsCheckerOptionsDocumentation;
|
||||
export type CheckerSchedule = HappydnsCheckerSchedule;
|
||||
export type CheckResult = HappydnsCheckResult;
|
||||
export type CheckExecution = HappydnsCheckExecution;
|
||||
|
||||
// Make 'id' required for CheckerOptionDocumentation
|
||||
export interface CheckerOptionDocumentation extends Omit<HappydnsCheckerOptionDocumentation, "id"> {
|
||||
id: string;
|
||||
}
|
||||
|
||||
// Enums for named access to numeric status/scope values
|
||||
export enum CheckResultStatus {
|
||||
Unknown = 0,
|
||||
Crit = 1,
|
||||
Warn = 2,
|
||||
Info = 3,
|
||||
OK = 4,
|
||||
}
|
||||
|
||||
export enum CheckScopeType {
|
||||
CheckScopeInstance = 0,
|
||||
CheckScopeUser = 1,
|
||||
CheckScopeDomain = 2,
|
||||
CheckScopeService = 3,
|
||||
CheckScopeOnDemand = 4,
|
||||
}
|
||||
|
||||
export enum CheckExecutionStatus {
|
||||
CheckExecutionPending = 0,
|
||||
CheckExecutionRunning = 1,
|
||||
CheckExecutionCompleted = 2,
|
||||
CheckExecutionFailed = 3,
|
||||
}
|
||||
|
||||
export interface AvailableChecker {
|
||||
checker_name: string;
|
||||
enabled: boolean;
|
||||
not_discovered: boolean;
|
||||
schedule?: CheckerSchedule;
|
||||
last_result?: CheckResult;
|
||||
}
|
||||
32
web/src/lib/stores/checkers.ts
Normal file
32
web/src/lib/stores/checkers.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 { listCheckers } from "$lib/api/checkers";
|
||||
import type { CheckerList } from "$lib/model/checker";
|
||||
import { writable, type Writable } from "svelte/store";
|
||||
|
||||
export const checkers: Writable<CheckerList | undefined> = writable(undefined);
|
||||
|
||||
export async function refreshCheckers() {
|
||||
const data = await listCheckers();
|
||||
checkers.set(data);
|
||||
return data;
|
||||
}
|
||||
|
|
@ -46,6 +46,9 @@ interface Params {
|
|||
key?: string;
|
||||
error?: string;
|
||||
options?: string;
|
||||
providers?: string;
|
||||
services?: string;
|
||||
label?: string;
|
||||
// add more parameters that are used here
|
||||
}
|
||||
|
||||
|
|
|
|||
43
web/src/lib/utils/check.ts
Normal file
43
web/src/lib/utils/check.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import type { HappydnsCheckResultStatus } from "$lib/api-base/types.gen";
|
||||
import { CheckResultStatus } from "$lib/model/checker";
|
||||
|
||||
export function getStatusColor(
|
||||
status: CheckResultStatus | HappydnsCheckResultStatus | undefined,
|
||||
): string {
|
||||
switch (status) {
|
||||
case CheckResultStatus.OK:
|
||||
return "success";
|
||||
case CheckResultStatus.Info:
|
||||
return "info";
|
||||
case CheckResultStatus.Warn:
|
||||
return "warning";
|
||||
case CheckResultStatus.Crit:
|
||||
return "danger";
|
||||
default:
|
||||
return "secondary";
|
||||
}
|
||||
}
|
||||
|
||||
export function getStatusKey(
|
||||
status: CheckResultStatus | HappydnsCheckResultStatus | undefined,
|
||||
): string {
|
||||
switch (status) {
|
||||
case CheckResultStatus.OK:
|
||||
return "checkers.status.ok";
|
||||
case CheckResultStatus.Info:
|
||||
return "checkers.status.info";
|
||||
case CheckResultStatus.Warn:
|
||||
return "checkers.status.warning";
|
||||
case CheckResultStatus.Crit:
|
||||
return "checkers.status.error";
|
||||
default:
|
||||
return "checkers.status.unknown";
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDuration(duration: number | undefined, t: (k: string) => string): string {
|
||||
if (!duration) return t("checkers.na");
|
||||
const seconds = duration / 1000000000;
|
||||
if (seconds < 1) return `${(seconds * 1000).toFixed(0)} ${t("checkers.result.milliseconds")}`;
|
||||
return `${seconds.toFixed(2)} ${t("checkers.result.seconds")}`;
|
||||
}
|
||||
|
|
@ -31,3 +31,64 @@ export function fromDatetimeLocal(datetimeLocal: string): string | null {
|
|||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date string for display in check 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("checkers.never") if undefined/invalid
|
||||
*/
|
||||
export function formatCheckDate(
|
||||
dateString: string | undefined,
|
||||
style: "short" | "medium" | "long",
|
||||
t: (k: string) => string,
|
||||
): string {
|
||||
if (!dateString) return t("checkers.never");
|
||||
const d = new Date(dateString);
|
||||
if (isNaN(d.getTime())) return t("checkers.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, params?: Record<string, 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("checkers.relative.in-less-than-a-minute")
|
||||
: t("checkers.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("checkers.relative.in", { label })
|
||||
: t("checkers.relative.ago", { label });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,4 +2,5 @@
|
|||
* Centralized utility exports
|
||||
*/
|
||||
|
||||
export { toDatetimeLocal, fromDatetimeLocal } from './datetime';
|
||||
export { toDatetimeLocal, fromDatetimeLocal, formatCheckDate, formatRelative } from "./datetime";
|
||||
export { getStatusColor, getStatusKey, formatDuration } from "./check";
|
||||
|
|
|
|||
31
web/src/routes/checkers/+layout.ts
Normal file
31
web/src/routes/checkers/+layout.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// 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 Load } from "@sveltejs/kit";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
import { checkers, refreshCheckers } from "$lib/stores/checkers";
|
||||
|
||||
export const load: Load = async ({ parent }) => {
|
||||
if (get(checkers) === undefined) refreshCheckers();
|
||||
|
||||
return await parent();
|
||||
};
|
||||
|
|
@ -30,17 +30,23 @@
|
|||
Input,
|
||||
InputGroup,
|
||||
InputGroupText,
|
||||
Table,
|
||||
Row,
|
||||
Badge,
|
||||
} from "@sveltestrap/sveltestrap";
|
||||
|
||||
import PageTitle from "$lib/components/PageTitle.svelte";
|
||||
import CheckersAvailabilityTable from "$lib/components/checkers/CheckersAvailabilityTable.svelte";
|
||||
import { t } from "$lib/translations";
|
||||
import { listChecks } from "$lib/api/checks";
|
||||
|
||||
let checksPromise = $state(listChecks());
|
||||
import { checkers } from "$lib/stores/checkers";
|
||||
|
||||
let searchQuery = $state("");
|
||||
|
||||
let filteredCheckers = $derived(
|
||||
$checkers
|
||||
? Object.entries($checkers).filter(([name]) =>
|
||||
name.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
: [],
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
|
@ -48,28 +54,15 @@
|
|||
</svelte:head>
|
||||
|
||||
<Container class="flex-fill my-5">
|
||||
<Row class="mb-4">
|
||||
<Col md={8}>
|
||||
<h1 class="display-5">
|
||||
<Icon name="check-circle-fill"></Icon>
|
||||
{$t("checkers.title")}
|
||||
</h1>
|
||||
<p class="d-flex gap-3 align-items-center text-muted">
|
||||
<span class="lead">
|
||||
{$t("checkers.description")}
|
||||
</span>
|
||||
{#await checksPromise then checkers}
|
||||
<span
|
||||
>{$t("checkers.available-count", {
|
||||
count: Object.keys(checkers ?? {}).length,
|
||||
})}</span
|
||||
>
|
||||
{/await}
|
||||
</p>
|
||||
</Col>
|
||||
</Row>
|
||||
<PageTitle title={$t("checkers.title")} subtitle={$t("checkers.description")}>
|
||||
{#if $checkers}
|
||||
{$t("checkers.available-count", {
|
||||
count: Object.keys($checkers).length,
|
||||
})}
|
||||
{/if}
|
||||
</PageTitle>
|
||||
|
||||
<Row class="mb-4">
|
||||
<Row class="mb-4 mt-3">
|
||||
<Col md={8} lg={6}>
|
||||
<InputGroup>
|
||||
<InputGroupText>
|
||||
|
|
@ -84,90 +77,23 @@
|
|||
</Col>
|
||||
</Row>
|
||||
|
||||
{#await checksPromise}
|
||||
{#if !$checkers}
|
||||
<Card body>
|
||||
<p class="text-center mb-0">
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>
|
||||
{$t("checkers.loading")}
|
||||
</p>
|
||||
</Card>
|
||||
{:then checks}
|
||||
<div class="table-responsive">
|
||||
<Table hover bordered>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{$t("checkers.table.name")}</th>
|
||||
<th>{$t("checkers.table.availability")}</th>
|
||||
<th>{$t("checkers.table.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if !checks || Object.keys(checks).length == 0}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center text-muted py-4">
|
||||
{$t("checkers.no-checkers")}
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#each Object.entries(checks ?? {}).filter(([name, _info]) => name
|
||||
.toLowerCase()
|
||||
.indexOf(searchQuery.toLowerCase()) > -1) as [checkerName, checkerInfo]}
|
||||
<tr>
|
||||
<td><strong>{checkerInfo.name || checkerName}</strong></td>
|
||||
<td>
|
||||
{#if checkerInfo.availableOn}
|
||||
{#if checkerInfo.availableOn.applyToDomain}
|
||||
<Badge color="success">
|
||||
{$t("checkers.availability.domain")}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if checkerInfo.availableOn.limitToProviders && checkerInfo.availableOn.limitToProviders.length > 0}
|
||||
<Badge
|
||||
color="primary"
|
||||
title={checkerInfo.availableOn.limitToProviders.join(
|
||||
", ",
|
||||
)}
|
||||
>
|
||||
{$t("checkers.availability.provider-specific")}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if checkerInfo.availableOn.limitToServices && checkerInfo.availableOn.limitToServices.length > 0}
|
||||
<Badge
|
||||
color="info"
|
||||
title={checkerInfo.availableOn.limitToServices.join(
|
||||
", ",
|
||||
)}
|
||||
>
|
||||
{$t("checkers.availability.service-specific")}
|
||||
</Badge>
|
||||
{/if}
|
||||
{:else}
|
||||
<Badge color="secondary">
|
||||
{$t("checkers.availability.general")}
|
||||
</Badge>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href="/checkers/{checkerName}"
|
||||
class="btn btn-sm btn-primary"
|
||||
>
|
||||
<Icon name="gear-fill"></Icon>
|
||||
{$t("checkers.actions.configure")}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
{:catch error}
|
||||
<Card body color="danger">
|
||||
<p class="mb-0">
|
||||
<Icon name="exclamation-triangle-fill"></Icon>
|
||||
{$t("checkers.error-loading", { error: error.message })}
|
||||
{:else}
|
||||
{#if Object.keys($checkers).length == 0}
|
||||
<p class="text-center text-muted py-4">
|
||||
{$t("checkers.no-checkers")}
|
||||
</p>
|
||||
</Card>
|
||||
{/await}
|
||||
{:else}
|
||||
<CheckersAvailabilityTable
|
||||
checkers={filteredCheckers}
|
||||
basePath="/checkers"
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</Container>
|
||||
|
|
|
|||
53
web/src/routes/checkers/[cname]/+layout.svelte
Normal file
53
web/src/routes/checkers/[cname]/+layout.svelte
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<!--
|
||||
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 { Col, Container, Row } from "@sveltestrap/sveltestrap";
|
||||
import { page } from "$app/state";
|
||||
|
||||
import CheckerSidebar from "$lib/components/checkers/CheckerSidebar.svelte";
|
||||
|
||||
let {
|
||||
children,
|
||||
}: {
|
||||
children?: import("svelte").Snippet;
|
||||
} = $props();
|
||||
|
||||
let cname = $derived(page.params.cname!);
|
||||
</script>
|
||||
|
||||
<Container fluid class="d-flex flex-column flex-fill">
|
||||
<Row class="flex-fill">
|
||||
<Col
|
||||
sm={4}
|
||||
md={3}
|
||||
class="py-3 sticky-top d-flex flex-column"
|
||||
style="background-color: #edf5f2; overflow-y: auto; max-height: 100vh; z-index: 0"
|
||||
>
|
||||
<CheckerSidebar currentCheckId={cname} />
|
||||
</Col>
|
||||
<Col sm={8} md={9} class="d-flex flex-column">
|
||||
{@render children?.()}
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
|
|
@ -40,7 +40,7 @@
|
|||
import { t } from "$lib/translations";
|
||||
import { toasts } from "$lib/stores/toasts";
|
||||
import PageTitle from "$lib/components/PageTitle.svelte";
|
||||
import { getCheckStatus, getCheckOptions, updateCheckOptions } from "$lib/api/checks";
|
||||
import { getCheckStatus, getCheckOptions, updateCheckOptions } from "$lib/api/checkers";
|
||||
import Resource from "$lib/components/inputs/Resource.svelte";
|
||||
import CheckerOptionsGroups from "$lib/components/checkers/CheckerOptionsGroups.svelte";
|
||||
|
||||
|
|
@ -162,6 +162,11 @@
|
|||
{$t("checkers.availability.domain-level")}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if status.availability.applyToZone}
|
||||
<Badge color="success">
|
||||
{$t("checkers.availability.zone-level")}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if status.availability.limitToProviders && status.availability.limitToProviders.length > 0}
|
||||
<Badge color="primary">
|
||||
{$t("checkers.availability.providers", {
|
||||
|
|
@ -182,7 +187,7 @@
|
|||
})}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if !status.availability.applyToDomain && (!status.availability.limitToProviders || status.availability.limitToProviders.length === 0) && (!status.availability.limitToServices || status.availability.limitToServices.length === 0)}
|
||||
{#if !status.availability.applyToDomain && !status.availability.applyToZone && (!status.availability.limitToProviders || status.availability.limitToProviders.length === 0) && (!status.availability.limitToServices || status.availability.limitToServices.length === 0)}
|
||||
<Badge color="secondary">
|
||||
{$t("checkers.availability.general")}
|
||||
</Badge>
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@
|
|||
import type { ZoneMeta } from "$lib/model/zone";
|
||||
import { domains_idx, refreshDomains } from "$lib/stores/domains";
|
||||
import { t } from "$lib/translations";
|
||||
import DomainCheckerSidebar from "$lib/components/checkers/DomainCheckerSidebar.svelte";
|
||||
import ButtonZonePublish from "./ButtonZonePublish.svelte";
|
||||
import ModalDiffZone from "./ModalDiffZone.svelte";
|
||||
import ModalDomainDelete, { controls as ctrlDomainDelete } from "./ModalDomainDelete.svelte";
|
||||
|
|
@ -67,7 +68,11 @@
|
|||
? "/history"
|
||||
: page.route.id.startsWith("/domains/[dn]/[[historyid]]/export")
|
||||
? "/export"
|
||||
: ""
|
||||
: page.route.id.startsWith("/domains/[dn]/checks/[cname]")
|
||||
? `/checks/${page.params.cname!}`
|
||||
: page.route.id.startsWith("/domains/[dn]/checks")
|
||||
? "/checks"
|
||||
: ""
|
||||
: ""),
|
||||
);
|
||||
}
|
||||
|
|
@ -149,7 +154,37 @@
|
|||
<SelectDomain bind:selectedDomain />
|
||||
</div>
|
||||
|
||||
{#if page.route.id && (page.route.id.startsWith("/domains/[dn]/history") || page.route.id.startsWith("/domains/[dn]/logs") || page.route.id.startsWith("/domains/[dn]/[[historyid]]/export"))}
|
||||
{#if page.route.id && page.route.id.startsWith("/domains/[dn]/checks/[cname]")}
|
||||
{#if page.route.id.startsWith("/domains/[dn]/checks/[cname]/results/")}
|
||||
<a
|
||||
href={"/domains/" +
|
||||
encodeURIComponent(domainLink(selectedDomain)) +
|
||||
"/checks/" +
|
||||
encodeURIComponent(page.params.cname!) +
|
||||
"/results"}
|
||||
class="sidebar-back d-flex align-items-center gap-1 mt-3 text-muted text-decoration-none fw-semibold"
|
||||
>
|
||||
<Icon name="chevron-left" />
|
||||
{$t("zones.return-to-results")}
|
||||
</a>
|
||||
{:else}
|
||||
<a
|
||||
href="/domains/{encodeURIComponent(domainLink(selectedDomain))}/checks"
|
||||
class="sidebar-back d-flex align-items-center gap-1 mt-3 text-muted text-decoration-none fw-semibold"
|
||||
>
|
||||
<Icon name="chevron-left" />
|
||||
{$t("checkers.title")}
|
||||
</a>
|
||||
{#if page.params.cname}
|
||||
<DomainCheckerSidebar
|
||||
class="mt-3"
|
||||
domainName={data.domain.domain}
|
||||
currentCheckerName={page.params.cname}
|
||||
/>
|
||||
{/if}
|
||||
<div class="flex-fill"></div>
|
||||
{/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]/[[historyid]]/export") || page.route.id == "/domains/[dn]/checks")}
|
||||
<a
|
||||
href="/domains/{encodeURIComponent(domainLink(selectedDomain))}"
|
||||
class="sidebar-back d-flex align-items-center gap-1 mt-3 text-muted text-decoration-none fw-semibold"
|
||||
|
|
|
|||
|
|
@ -117,6 +117,9 @@
|
|||
<DropdownItem href={`/domains/${domainLink(selectedDomain)}/logs`}>
|
||||
{$t("domains.actions.audit")}
|
||||
</DropdownItem>
|
||||
<DropdownItem href={`/domains/${domainLink(selectedDomain)}/checks`}>
|
||||
{$t("domains.actions.view-checks")}
|
||||
</DropdownItem>
|
||||
<DropdownItem divider />
|
||||
<DropdownItem on:click={viewZone} disabled={!$sortedDomains}>
|
||||
{$t("domains.actions.view")}
|
||||
|
|
|
|||
17
web/src/routes/domains/[dn]/checks/+layout.ts
Normal file
17
web/src/routes/domains/[dn]/checks/+layout.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { type Load } from "@sveltejs/kit";
|
||||
|
||||
import { checkers, refreshCheckers } from "$lib/stores/checkers";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
export const load: Load = async ({ parent }) => {
|
||||
const data = await parent();
|
||||
|
||||
if (get(checkers) === undefined) {
|
||||
refreshCheckers();
|
||||
}
|
||||
|
||||
return {
|
||||
...data,
|
||||
isTestsPage: true,
|
||||
};
|
||||
};
|
||||
222
web/src/routes/domains/[dn]/checks/+page.svelte
Normal file
222
web/src/routes/domains/[dn]/checks/+page.svelte
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
<!--
|
||||
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 { navigate } from "$lib/stores/config";
|
||||
import { page } from "$app/state";
|
||||
import { Card, Icon, Table, Badge, Button, Spinner } from "@sveltestrap/sveltestrap";
|
||||
|
||||
import { t } from "$lib/translations";
|
||||
import PageTitle from "$lib/components/PageTitle.svelte";
|
||||
import {
|
||||
listAvailableCheckers,
|
||||
updateCheckSchedule,
|
||||
createCheckSchedule,
|
||||
} from "$lib/api/checkers";
|
||||
import type { Domain } from "$lib/model/domain";
|
||||
import { CheckScopeType, type AvailableChecker } from "$lib/model/checker";
|
||||
import { checkers } from "$lib/stores/checkers";
|
||||
import { toasts } from "$lib/stores/toasts";
|
||||
import RunCheckModal from "$lib/components/modals/RunCheckModal.svelte";
|
||||
import { getStatusColor, getStatusKey, formatCheckDate } from "$lib/utils";
|
||||
|
||||
interface Props {
|
||||
data: { domain: Domain };
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
let checksPromise = $derived(listAvailableCheckers(data.domain.id));
|
||||
let runCheckModal: RunCheckModal;
|
||||
let togglingChecks = $state(new Set<string>());
|
||||
|
||||
function handleCheckTriggered(_: string, checkName: string) {
|
||||
// Refresh the check list to show updated status
|
||||
checksPromise = listAvailableCheckers(data.domain.id);
|
||||
navigate(`/domains/${page.params.dn!}/checks/${checkName}/results`);
|
||||
}
|
||||
|
||||
async function handleToggleEnabled(checker: AvailableChecker) {
|
||||
const next = new Set(togglingChecks);
|
||||
next.add(checker.checker_name);
|
||||
togglingChecks = next;
|
||||
|
||||
try {
|
||||
const newEnabled = !checker.enabled;
|
||||
if (checker.schedule) {
|
||||
await updateCheckSchedule(checker.schedule.id!, {
|
||||
...checker.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 createCheckSchedule({
|
||||
checker_name: checker.checker_name,
|
||||
target_type: CheckScopeType.CheckScopeDomain,
|
||||
target_id: data.domain.id,
|
||||
interval: 0,
|
||||
enabled: newEnabled,
|
||||
});
|
||||
}
|
||||
checksPromise = listAvailableCheckers(data.domain.id);
|
||||
} catch (e: any) {
|
||||
toasts.addErrorToast({
|
||||
title: $t("checkers.list.error-loading", { error: e.message }),
|
||||
});
|
||||
} finally {
|
||||
const after = new Set(togglingChecks);
|
||||
after.delete(checker.checker_name);
|
||||
togglingChecks = after;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Checks - {data.domain.domain} - happyDomain</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex-fill pb-4 pt-2">
|
||||
<PageTitle title={$t("checkers.list.title")} domain={data.domain.domain} />
|
||||
|
||||
{#await checksPromise}
|
||||
<div class="mt-5 text-center flex-fill">
|
||||
<Spinner />
|
||||
<p>{$t("checkers.list.loading")}</p>
|
||||
</div>
|
||||
{:then availableCheckers}
|
||||
{#if !$checkers}
|
||||
<div class="mt-5 text-center flex-fill">
|
||||
<Spinner />
|
||||
<p>{$t("checkers.list.loading-checks")}</p>
|
||||
</div>
|
||||
{:else if !availableCheckers || availableCheckers.length === 0}
|
||||
<Card body class="mt-3">
|
||||
<p class="text-center text-muted mb-0">
|
||||
<Icon name="info-circle"></Icon>
|
||||
{$t("checkers.list.no-checks")}
|
||||
</p>
|
||||
</Card>
|
||||
{:else}
|
||||
<Table hover striped class="mt-3">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{$t("checkers.list.table.checker")}</th>
|
||||
<th>{$t("checkers.list.table.status")}</th>
|
||||
<th>{$t("checkers.list.table.last-run")}</th>
|
||||
<th>{$t("checkers.list.table.schedule")}</th>
|
||||
<th>{$t("checkers.list.table.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each availableCheckers as checker}
|
||||
{@const checkInfo = $checkers[checker.checker_name]}
|
||||
<tr>
|
||||
<td class="align-middle">
|
||||
<strong>{checkInfo?.name || checker.checker_name}</strong>
|
||||
</td>
|
||||
<td class="align-middle text-center">
|
||||
{#if checker.last_result !== undefined}
|
||||
<Badge color={getStatusColor(checker.last_result.status)}>
|
||||
{$t(getStatusKey(checker.last_result.status))}
|
||||
</Badge>
|
||||
{:else}
|
||||
<Badge color="secondary">{$t("checkers.status.not-run")}</Badge>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
{formatCheckDate(checker.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-{checker.checker_name}"
|
||||
checked={checker.enabled}
|
||||
disabled={togglingChecks.has(checker.checker_name)}
|
||||
onchange={() => handleToggleEnabled(checker)}
|
||||
/>
|
||||
<label
|
||||
class="form-check-label small"
|
||||
for="toggle-{checker.checker_name}"
|
||||
>
|
||||
{checker.enabled
|
||||
? $t("checkers.list.schedule.enabled")
|
||||
: $t("checkers.list.schedule.disabled")}
|
||||
</label>
|
||||
</div>
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
<div class="d-flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
onclick={() =>
|
||||
runCheckModal.open(
|
||||
checker.checker_name,
|
||||
checkInfo?.name || checker.checker_name,
|
||||
)}
|
||||
>
|
||||
<Icon name="play-fill"></Icon>
|
||||
{$t("checkers.list.run-check")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color="info"
|
||||
href={`/domains/${encodeURIComponent(data.domain.domain)}/checks/${encodeURIComponent(checker.checker_name)}/results`}
|
||||
>
|
||||
<Icon name="bar-chart-fill"></Icon>
|
||||
{$t("checkers.list.view-results")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color="dark"
|
||||
href={`/domains/${encodeURIComponent(data.domain.domain)}/checks/${encodeURIComponent(checker.checker_name)}`}
|
||||
title={$t("checkers.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("checkers.list.error-loading", { error: error.message })}
|
||||
</p>
|
||||
</Card>
|
||||
{/await}
|
||||
</div>
|
||||
|
||||
<RunCheckModal
|
||||
domainId={data.domain.id}
|
||||
onCheckTriggered={handleCheckTriggered}
|
||||
bind:this={runCheckModal}
|
||||
/>
|
||||
203
web/src/routes/domains/[dn]/checks/[cname]/+page.svelte
Normal file
203
web/src/routes/domains/[dn]/checks/[cname]/+page.svelte
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
<!--
|
||||
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 { Button, Card, Icon, Spinner } from "@sveltestrap/sveltestrap";
|
||||
|
||||
import CheckerOptionsCard from "$lib/components/checkers/CheckerOptionsCard.svelte";
|
||||
import CheckerScheduleCard from "$lib/components/checkers/CheckerScheduleCard.svelte";
|
||||
import PageTitle from "$lib/components/PageTitle.svelte";
|
||||
import { t } from "$lib/translations";
|
||||
import {
|
||||
listAvailableCheckers,
|
||||
updateCheckSchedule,
|
||||
createCheckSchedule,
|
||||
getCheckStatus,
|
||||
getDomainCheckOptions,
|
||||
updateDomainCheckOptions,
|
||||
} from "$lib/api/checkers";
|
||||
import type { Domain } from "$lib/model/domain";
|
||||
import { CheckScopeType, type AvailableChecker, type CheckerInfo } from "$lib/model/checker";
|
||||
import { checkers } from "$lib/stores/checkers";
|
||||
import { toasts } from "$lib/stores/toasts";
|
||||
|
||||
interface Props {
|
||||
data: { domain: Domain };
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
const NS_PER_HOUR = 3600 * 1e9;
|
||||
|
||||
const checkName = $derived(page.params.cname || "");
|
||||
const checkDisplayName = $derived($checkers?.[checkName]?.name || checkName);
|
||||
const intervalSpec = $derived($checkers?.[checkName]?.interval);
|
||||
|
||||
// Resolved check data
|
||||
let check = $state<AvailableChecker | null>(null);
|
||||
let checkStatus = $state<CheckerInfo | 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);
|
||||
|
||||
// Options state
|
||||
let domainOptionValues = $state<Record<string, any>>({});
|
||||
|
||||
async function loadCheck() {
|
||||
loading = true;
|
||||
loadError = null;
|
||||
try {
|
||||
const [checks, status, options] = await Promise.all([
|
||||
listAvailableCheckers(data.domain.id),
|
||||
getCheckStatus(checkName),
|
||||
getDomainCheckOptions(data.domain.id, checkName),
|
||||
]);
|
||||
const found = checks?.find((c) => c.checker_name === checkName) ?? null;
|
||||
check = found;
|
||||
checkStatus = status;
|
||||
domainOptionValues = { ...(options || {}) };
|
||||
if (found) {
|
||||
formEnabled = found.enabled;
|
||||
const defaultHours = intervalSpec?.default
|
||||
? intervalSpec.default / NS_PER_HOUR
|
||||
: 24;
|
||||
formIntervalHours =
|
||||
found.schedule &&
|
||||
found.schedule.interval !== undefined &&
|
||||
found.schedule.interval > 0
|
||||
? found.schedule.interval / NS_PER_HOUR
|
||||
: defaultHours;
|
||||
}
|
||||
} catch (e: any) {
|
||||
loadError = e.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
loadCheck();
|
||||
|
||||
async function handleSave() {
|
||||
if (!check) return;
|
||||
saving = true;
|
||||
|
||||
try {
|
||||
const intervalNs = Math.max(formIntervalHours, 1) * 3600 * 1e9;
|
||||
|
||||
if (check.schedule) {
|
||||
await updateCheckSchedule(check.schedule.id!, {
|
||||
...check.schedule,
|
||||
enabled: formEnabled,
|
||||
interval: intervalNs,
|
||||
});
|
||||
} else {
|
||||
await createCheckSchedule({
|
||||
checker_name: check.checker_name,
|
||||
target_type: CheckScopeType.CheckScopeDomain,
|
||||
target_id: data.domain.id,
|
||||
interval: intervalNs,
|
||||
enabled: formEnabled,
|
||||
});
|
||||
}
|
||||
|
||||
toasts.addToast({
|
||||
title: $t("checkers.schedule.saved"),
|
||||
type: "success",
|
||||
timeout: 3000,
|
||||
});
|
||||
await loadCheck();
|
||||
} catch (e: any) {
|
||||
toasts.addErrorToast({
|
||||
title: $t("checkers.schedule.save-failed"),
|
||||
message: e.message,
|
||||
});
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>
|
||||
{checkDisplayName} - {data.domain.domain} - happyDomain
|
||||
</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex-fill pb-4 pt-2">
|
||||
<PageTitle
|
||||
title={checkDisplayName}
|
||||
domain={data.domain.domain}
|
||||
>
|
||||
{#if $checkers && (!$checkers[checkName].availability || $checkers[checkName].availability.applyToDomain)}
|
||||
<Button
|
||||
color="info"
|
||||
href={`/domains/${encodeURIComponent(data.domain.domain)}/checks/${encodeURIComponent(checkName)}/results`}
|
||||
>
|
||||
<Icon name="bar-chart-fill"></Icon>
|
||||
{$t("checkers.list.view-results")}
|
||||
</Button>
|
||||
{/if}
|
||||
</PageTitle>
|
||||
|
||||
{#if loading}
|
||||
<div class="mt-5 text-center flex-fill">
|
||||
<Spinner />
|
||||
<p>{$t("checkers.list.loading")}</p>
|
||||
</div>
|
||||
{:else if loadError}
|
||||
<Card body color="danger">
|
||||
<p class="mb-0">
|
||||
<Icon name="exclamation-triangle-fill"></Icon>
|
||||
{$t("checkers.list.error-loading", { error: loadError })}
|
||||
</p>
|
||||
</Card>
|
||||
{:else if !check}
|
||||
<Card body>
|
||||
<p class="text-center text-muted mb-0">
|
||||
<Icon name="info-circle"></Icon>
|
||||
{$t("checkers.list.no-checks")}
|
||||
</p>
|
||||
</Card>
|
||||
{:else}
|
||||
<CheckerScheduleCard
|
||||
checker={check}
|
||||
{intervalSpec}
|
||||
bind:formEnabled
|
||||
bind:formIntervalHours
|
||||
{saving}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
|
||||
<CheckerOptionsCard
|
||||
options={checkStatus?.options?.domainOpts ?? []}
|
||||
bind:optionValues={domainOptionValues}
|
||||
title={$t("checkers.option-groups.domain-settings")}
|
||||
saveOptionsFn={(values) => updateDomainCheckOptions(data.domain.id, checkName, values)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
317
web/src/routes/domains/[dn]/checks/[cname]/results/+page.svelte
Normal file
317
web/src/routes/domains/[dn]/checks/[cname]/results/+page.svelte
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
<!--
|
||||
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 { onDestroy } from "svelte";
|
||||
|
||||
import { t } from "$lib/translations";
|
||||
import { page } from "$app/state";
|
||||
import PageTitle from "$lib/components/PageTitle.svelte";
|
||||
import {
|
||||
listCheckResults,
|
||||
deleteCheckResult,
|
||||
deleteAllCheckResults,
|
||||
getCheckExecution,
|
||||
} from "$lib/api/checkers";
|
||||
import { getCheckStatus } from "$lib/api/checkers";
|
||||
import type { Domain } from "$lib/model/domain";
|
||||
import type { CheckExecution } from "$lib/model/checker";
|
||||
import { CheckExecutionStatus, CheckScopeType } from "$lib/model/checker";
|
||||
import RunCheckModal from "$lib/components/modals/RunCheckModal.svelte";
|
||||
import { getStatusColor, getStatusKey, formatDuration, formatCheckDate } from "$lib/utils";
|
||||
|
||||
interface Props {
|
||||
data: { domain: Domain };
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
const checkName = $derived(page.params.cname || "");
|
||||
|
||||
let resultsPromise = $derived(listCheckResults(data.domain.id, checkName));
|
||||
let checkPromise = $derived(getCheckStatus(checkName));
|
||||
let checkerDisplayName = $state(checkName);
|
||||
$effect(() => {
|
||||
checkPromise.then((c) => (checkerDisplayName = c.name || checkName)).catch(() => {});
|
||||
});
|
||||
let runCheckModal: RunCheckModal;
|
||||
let errorMessage = $state<string | null>(null);
|
||||
let pendingExecutions = $state<CheckExecution[]>([]);
|
||||
const pollingIntervals = new Map<string, ReturnType<typeof setInterval>>();
|
||||
|
||||
onDestroy(() => {
|
||||
for (const id of pollingIntervals.values()) clearInterval(id);
|
||||
});
|
||||
|
||||
function handleCheckTriggered(execution_id: string) {
|
||||
const placeholder: CheckExecution = {
|
||||
id: execution_id,
|
||||
checker_name: checkName,
|
||||
owner_id: "",
|
||||
target_type: CheckScopeType.CheckScopeDomain,
|
||||
target_id: data.domain.id,
|
||||
status: CheckExecutionStatus.CheckExecutionPending,
|
||||
started_at: new Date().toISOString(),
|
||||
};
|
||||
pendingExecutions = [...pendingExecutions, placeholder];
|
||||
|
||||
const intervalId = setInterval(async () => {
|
||||
try {
|
||||
const exec = await getCheckExecution(data.domain.id, checkName, execution_id);
|
||||
pendingExecutions = pendingExecutions.map((e) =>
|
||||
e.id === execution_id ? exec : e,
|
||||
);
|
||||
|
||||
if (
|
||||
exec.status === CheckExecutionStatus.CheckExecutionCompleted ||
|
||||
exec.status === CheckExecutionStatus.CheckExecutionFailed
|
||||
) {
|
||||
clearInterval(intervalId);
|
||||
pollingIntervals.delete(execution_id);
|
||||
pendingExecutions = pendingExecutions.filter((e) => e.id !== execution_id);
|
||||
resultsPromise = listCheckResults(data.domain.id, checkName);
|
||||
}
|
||||
} catch {
|
||||
clearInterval(intervalId);
|
||||
pollingIntervals.delete(execution_id);
|
||||
pendingExecutions = pendingExecutions.filter((e) => e.id !== execution_id);
|
||||
}
|
||||
}, 2000);
|
||||
pollingIntervals.set(execution_id, intervalId);
|
||||
}
|
||||
|
||||
async function handleDeleteResult(resultId: string) {
|
||||
if (!confirm($t("checkers.results.delete-confirm"))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteCheckResult(data.domain.id, checkName, resultId);
|
||||
resultsPromise = listCheckResults(data.domain.id, checkName);
|
||||
} catch (error: any) {
|
||||
errorMessage = error.message || $t("checkers.results.delete-failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteAll() {
|
||||
if (!confirm($t("checkers.results.delete-all-confirm"))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteAllCheckResults(data.domain.id, checkName);
|
||||
resultsPromise = listCheckResults(data.domain.id, checkName);
|
||||
} catch (error: any) {
|
||||
errorMessage = error.message || $t("checkers.results.delete-all-failed");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{checkName} Results - {data.domain.domain} - happyDomain</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex-fill pb-4 pt-2">
|
||||
<PageTitle title={checkerDisplayName} domain={data.domain.domain}>
|
||||
<div class="d-flex gap-2">
|
||||
<Button
|
||||
color="dark"
|
||||
href={`/domains/${encodeURIComponent(data.domain.domain)}/checks/${encodeURIComponent(checkName)}`}
|
||||
>
|
||||
<Icon name="gear-fill"></Icon>
|
||||
{$t("checkers.results.configure")}
|
||||
</Button>
|
||||
{#await checkPromise then check}
|
||||
<Button
|
||||
color="primary"
|
||||
onclick={() => runCheckModal.open(checkName, check.name || checkName)}
|
||||
>
|
||||
<Icon name="play-fill"></Icon>
|
||||
{$t("checkers.results.run-check-now")}
|
||||
</Button>
|
||||
{/await}
|
||||
</div>
|
||||
</PageTitle>
|
||||
|
||||
{#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("checkers.results.loading")}</p>
|
||||
</div>
|
||||
{:then results}
|
||||
{#if (!results || results.length === 0) && pendingExecutions.length === 0}
|
||||
<Card body>
|
||||
<p class="text-center text-muted mb-0">
|
||||
<Icon name="info-circle"></Icon>
|
||||
{$t("checkers.results.no-results")}
|
||||
</p>
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h4>{$t("checkers.results.title", { count: results?.length ?? 0 })}</h4>
|
||||
{#if results?.length}
|
||||
<Button size="sm" color="danger" outline onclick={handleDeleteAll}>
|
||||
<Icon name="trash"></Icon>
|
||||
{$t("checkers.results.delete-all")}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Table hover striped>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{$t("checkers.results.table.executed-at")}</th>
|
||||
<th class="text-center">{$t("checkers.results.table.status")}</th>
|
||||
<th>{$t("checkers.results.table.message")}</th>
|
||||
<th>{$t("checkers.results.table.duration")}</th>
|
||||
<th class="text-center">{$t("checkers.results.table.type")}</th>
|
||||
<th>{$t("checkers.results.table.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each pendingExecutions as exec (exec.id)}
|
||||
<tr class="table-warning">
|
||||
<td class="align-middle">
|
||||
{formatCheckDate(exec.started_at, "short", $t)}
|
||||
</td>
|
||||
<td class="align-middle text-center">
|
||||
<Badge
|
||||
color={exec.status ===
|
||||
CheckExecutionStatus.CheckExecutionRunning
|
||||
? "info"
|
||||
: "secondary"}
|
||||
>
|
||||
{exec.status === CheckExecutionStatus.CheckExecutionRunning
|
||||
? $t("checkers.results.pending.running")
|
||||
: $t("checkers.results.pending.queued")}
|
||||
</Badge>
|
||||
</td>
|
||||
<td class="align-middle text-muted">
|
||||
{exec.status === CheckExecutionStatus.CheckExecutionRunning
|
||||
? $t("checkers.results.pending.running-description")
|
||||
: $t("checkers.results.pending.queued-description")}
|
||||
</td>
|
||||
<td class="align-middle">—</td>
|
||||
<td class="align-middle text-center">
|
||||
<Badge color="secondary">
|
||||
{#if exec.schedule_id}
|
||||
<Icon name="clock"></Icon>
|
||||
{$t("checkers.results.type.scheduled")}
|
||||
{:else}
|
||||
<Icon name="hand-index"></Icon>
|
||||
{$t("checkers.results.type.manual")}
|
||||
{/if}
|
||||
</Badge>
|
||||
</td>
|
||||
<td class="align-middle"></td>
|
||||
</tr>
|
||||
{/each}
|
||||
{#each results ?? [] as result}
|
||||
<tr>
|
||||
<td class="align-middle">
|
||||
{formatCheckDate(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">
|
||||
<Badge color="secondary">
|
||||
{#if result.scheduled_check}
|
||||
<Icon name="clock"></Icon>
|
||||
{$t("checkers.results.type.scheduled")}
|
||||
{:else}
|
||||
<Icon name="hand-index"></Icon>
|
||||
{$t("checkers.results.type.manual")}
|
||||
{/if}
|
||||
</Badge>
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
<ButtonGroup size="sm">
|
||||
<Button
|
||||
color="primary"
|
||||
href={`/domains/${encodeURIComponent(data.domain.domain)}/checks/${encodeURIComponent(checkName)}/results/${encodeURIComponent(result.id!)}`}
|
||||
>
|
||||
<Icon name="eye-fill"></Icon>
|
||||
{$t("checkers.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("checkers.results.error-loading", { error: error.message })}
|
||||
</p>
|
||||
</Card>
|
||||
{/await}
|
||||
</div>
|
||||
|
||||
<RunCheckModal
|
||||
domainId={data.domain.id}
|
||||
onCheckTriggered={handleCheckTriggered}
|
||||
bind:this={runCheckModal}
|
||||
/>
|
||||
|
|
@ -0,0 +1,307 @@
|
|||
<!--
|
||||
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 { navigate } from "$lib/stores/config";
|
||||
import {
|
||||
getCheckStatus,
|
||||
getCheckResult,
|
||||
deleteCheckResult,
|
||||
triggerCheck,
|
||||
} from "$lib/api/checkers";
|
||||
import type { Domain } from "$lib/model/domain";
|
||||
import type { CheckResult } from "$lib/model/checker";
|
||||
import { getStatusColor, getStatusKey, formatDuration, formatCheckDate } from "$lib/utils";
|
||||
|
||||
interface Props {
|
||||
data: { domain: Domain };
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
const checkName = $derived(page.params.cname || "");
|
||||
const resultId = $derived(page.params.rid || "");
|
||||
|
||||
let resultPromise = $derived(getCheckResult(data.domain.id, checkName, resultId));
|
||||
let checkPromise = $derived(getCheckStatus(checkName));
|
||||
let errorMessage = $state<string | null>(null);
|
||||
let resolvedResult = $state<CheckResult | null>(null);
|
||||
let isRelaunching = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
resultPromise.then((r) => {
|
||||
resolvedResult = r;
|
||||
});
|
||||
});
|
||||
|
||||
async function handleRelaunch() {
|
||||
if (!resolvedResult) return;
|
||||
|
||||
isRelaunching = true;
|
||||
try {
|
||||
await triggerCheck(data.domain.id, checkName, resolvedResult.options);
|
||||
navigate(
|
||||
`/domains/${encodeURIComponent(data.domain.domain)}/checks/${encodeURIComponent(checkName)}`,
|
||||
);
|
||||
} catch (error: any) {
|
||||
errorMessage = error.message || $t("checkers.result.relaunch-failed");
|
||||
} finally {
|
||||
isRelaunching = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm($t("checkers.result.delete-confirm"))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteCheckResult(data.domain.id, checkName, resultId);
|
||||
navigate(
|
||||
`/domains/${encodeURIComponent(data.domain.domain)}/checks/${encodeURIComponent(checkName)}`,
|
||||
);
|
||||
} catch (error: any) {
|
||||
errorMessage = error.message || $t("checkers.result.delete-failed");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>
|
||||
Check Result - {checkName} - {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("checkers.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("checkers.result.relaunch")}
|
||||
</span>
|
||||
</Button>
|
||||
<Button color="danger" outline onclick={handleDelete} disabled={!resolvedResult}>
|
||||
<Icon name="trash"></Icon>
|
||||
<span class="d-none d-lg-inline">
|
||||
{$t("checkers.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, checkPromise])}
|
||||
<div class="mt-5 text-center flex-fill">
|
||||
<Spinner />
|
||||
<p>{$t("checkers.result.loading")}</p>
|
||||
</div>
|
||||
{:then [result, check]}
|
||||
<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">
|
||||
{check.name || checkName}
|
||||
</h4>
|
||||
</div>
|
||||
{#if result.scheduled_check}
|
||||
<Badge color="info">
|
||||
<Icon name="clock"></Icon>
|
||||
{$t("checkers.result.type.scheduled")}
|
||||
</Badge>
|
||||
{:else}
|
||||
<Badge color="secondary">
|
||||
<Icon name="hand-index"></Icon>
|
||||
{$t("checkers.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("checkers.result.field.domain")}</th
|
||||
>
|
||||
<td class="font-monospace">{data.domain.domain}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{$t("checkers.result.field.executed-at")}</th>
|
||||
<td>{formatCheckDate(result.executed_at, "long", $t)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{$t("checkers.result.field.duration")}</th>
|
||||
<td>{formatDuration(result.duration, $t)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{$t("checkers.result.field.status")}</th>
|
||||
<td>
|
||||
<Badge color={getStatusColor(result.status)}>
|
||||
{$t(getStatusKey(result.status))}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{$t("checkers.result.field.status-message")}</th>
|
||||
<td>{result.status_line}</td>
|
||||
</tr>
|
||||
{#if result.error}
|
||||
<tr>
|
||||
<th>{$t("checkers.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("checkers.result.check-options")}
|
||||
</h5>
|
||||
</CardHeader>
|
||||
<CardBody class="p-2">
|
||||
<Table borderless size="sm" class="mb-0">
|
||||
<tbody>
|
||||
{#each Object.entries(check.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("checkers.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("checkers.result.error-loading", { error: error.message })}
|
||||
</p>
|
||||
</Card>
|
||||
{/await}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
pre {
|
||||
overflow-x: scroll;
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue