checkers: add frontend UI components and routes

Add all checker UI pages and components:
- Checker list, config, schedule, and rules pages
- Execution list, detail, results, and rules pages
- Sidebar components for domain/service checker status
- Run check modal with option overrides and rule selection
- Domain-scoped and service-scoped check routes
- Admin pages for checker configuration and scheduler management
- Header navigation link for checkers section
This commit is contained in:
nemunaire 2026-04-04 11:55:59 +07:00
commit 28bed1cb46
49 changed files with 4821 additions and 60 deletions

View file

@ -101,6 +101,12 @@
<NavItem>
<NavLink href="/sessions" active={page && page.url.pathname.startsWith('/sessions')}>Sessions</NavLink>
</NavItem>
<NavItem>
<NavLink href="/checkers" active={page && page.url.pathname.startsWith('/checkers')}>Checkers</NavLink>
</NavItem>
<NavItem>
<NavLink href="/scheduler" active={page && page.url.pathname.startsWith('/scheduler')}>Scheduler</NavLink>
</NavItem>
</Nav>
</Collapse>
</Navbar>

View file

@ -0,0 +1,141 @@
<!--
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,
Col,
Container,
Icon,
Input,
InputGroup,
InputGroupText,
Table,
Row,
Badge,
} from "@sveltestrap/sveltestrap";
import { getCheckers } from "$lib/api-base";
let checkersQ = $state(getCheckers());
let searchQuery = $state("");
function availabilityBadges(
availability: { applyToDomain?: boolean; applyToZone?: boolean; applyToService?: boolean } | undefined,
): { label: string; color: string }[] {
if (!availability) return [];
const badges = [];
if (availability.applyToDomain) badges.push({ label: "Domain", color: "success" });
if (availability.applyToZone) badges.push({ label: "Zone", color: "info" });
if (availability.applyToService) badges.push({ label: "Service", color: "warning" });
return badges;
}
</script>
<Container class="flex-fill my-5">
<Row class="mb-4">
<Col md={8}>
<h1 class="display-5">
<Icon name="puzzle-fill"></Icon>
Checkers
</h1>
<p class="d-flex gap-3 align-items-center text-muted">
<span class="lead"> Manage all checkers </span>
{#await checkersQ then checkersR}
<span>Total: {Object.keys(checkersR.data ?? {}).length} checkers</span>
{/await}
</p>
</Col>
</Row>
<Row class="mb-4">
<Col md={8} lg={6}>
<InputGroup>
<InputGroupText>
<Icon name="search"></Icon>
</InputGroupText>
<Input type="text" placeholder="Search checker..." bind:value={searchQuery} />
</InputGroup>
</Col>
</Row>
{#await checkersQ}
Please wait...
{:then checkersR}
{@const checkers = checkersR.data}
<div class="table-responsive">
<Table hover bordered>
<thead>
<tr>
<th>Plugin Name</th>
<th>Availability</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{#if !checkers || Object.keys(checkers).length == 0}
<tr>
<td colspan="3" class="text-center text-muted py-2">
No checkers available
</td>
</tr>
{:else}
{#each Object.entries(checkers ?? {}).filter(([name, _info]) => name
.toLowerCase()
.indexOf(searchQuery.toLowerCase()) > -1) as [checkerId, checkerInfo]}
<tr>
<td><strong>{checkerInfo.name || checkerId}</strong></td>
<td>
{#if availabilityBadges(checkerInfo.availability).length > 0}
{#each availabilityBadges(checkerInfo.availability) as badge}
<Badge color={badge.color} class="me-1">{badge.label}</Badge>
{/each}
{:else}
<Badge color="secondary">General</Badge>
{/if}
</td>
<td>
<a
href="/checkers/{checkerId}"
class="btn btn-sm btn-primary"
>
<Icon name="gear-fill"></Icon>
Manage
</a>
</td>
</tr>
{/each}
{/if}
</tbody>
</Table>
</div>
{:catch error}
<Card body color="danger">
<p class="mb-0">
<Icon name="exclamation-triangle-fill"></Icon>
Error loading checkers: {error.message}
</p>
</Card>
{/await}
</Container>

View file

@ -0,0 +1,452 @@
<!--
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,
Container,
Form,
Icon,
ListGroup,
ListGroupItem,
Row,
} from "@sveltestrap/sveltestrap";
import { page } from "$app/state";
import { toasts } from "$lib/stores/toasts";
import {
getCheckersByCheckerId,
getCheckersByCheckerIdOptions,
putCheckersByCheckerIdOptions,
} from "$lib/api-base";
import type { HappydnsCheckerOptionDocumentation } from "$lib/api-base";
import ResourceInput from "$lib/components/inputs/Resource.svelte";
import { formatDuration } from "$lib/utils";
let checkerId = $derived(page.params.checkerId!);
let checkerQ = $derived(getCheckersByCheckerId({ path: { checkerId } }));
let checkerOptionsQ = $derived(getCheckersByCheckerIdOptions({ path: { checkerId } }));
let optionValues = $state<Record<string, unknown>>({});
let saving = $state(false);
$effect(() => {
checkerOptionsQ.then((optionsR) => {
optionValues = { ...((optionsR.data as Record<string, unknown>) || {}) };
});
});
async function saveOptions() {
saving = true;
try {
await putCheckersByCheckerIdOptions({
path: { checkerId },
body: optionValues as any,
});
checkerOptionsQ = getCheckersByCheckerIdOptions({ path: { checkerId } });
toasts.addToast({
message: `Checker options updated successfully`,
type: "success",
timeout: 5000,
});
} catch (error) {
toasts.addErrorToast({
message: "Failed to update options: " + error,
timeout: 10000,
});
} finally {
saving = false;
}
}
async function cleanOrphanedOptions(adminOpts: HappydnsCheckerOptionDocumentation[]) {
const validOptIds = new Set(adminOpts.map((opt) => opt.id));
const cleanedOptions: Record<string, unknown> = {};
for (const [key, value] of Object.entries(optionValues)) {
if (validOptIds.has(key)) {
cleanedOptions[key] = value;
}
}
saving = true;
try {
await putCheckersByCheckerIdOptions({
path: { checkerId },
body: cleanedOptions as any,
});
checkerOptionsQ = getCheckersByCheckerIdOptions({ path: { checkerId } });
toasts.addToast({
message: `Orphaned options removed successfully`,
type: "success",
timeout: 5000,
});
} catch (error) {
toasts.addErrorToast({
message: "Failed to clean options: " + error,
timeout: 10000,
});
} finally {
saving = false;
}
}
function getOrphanedOptions(adminOpts: HappydnsCheckerOptionDocumentation[]): string[] {
const validOptIds = new Set(adminOpts.map((opt) => opt.id));
return Object.keys(optionValues).filter((key) => !validOptIds.has(key));
}
function availabilityBadges(
availability:
| {
applyToDomain?: boolean;
applyToZone?: boolean;
applyToService?: boolean;
limitToProviders?: string[];
limitToServices?: string[];
}
| undefined,
): { label: string; color: string }[] {
if (!availability) return [];
const badges = [];
if (availability.applyToDomain) badges.push({ label: "Domain", color: "success" });
if (availability.applyToZone) badges.push({ label: "Zone", color: "info" });
if (availability.applyToService) badges.push({ label: "Service", color: "warning" });
return badges;
}
</script>
<Container class="flex-fill my-5">
<Row class="mb-4">
<Col>
<Button color="link" href="/checkers" class="mb-2">
<Icon name="arrow-left"></Icon>
Back to checkers
</Button>
<h1 class="display-5">
<Icon name="puzzle-fill"></Icon>
{checkerId}
</h1>
</Col>
</Row>
{#await checkerQ}
<Card body>
<p class="text-center mb-0">
<span class="spinner-border spinner-border-sm me-2"></span>
Loading checker status...
</p>
</Card>
{:then checkerR}
{@const checker = checkerR.data}
{#if checker}
<Row class="mb-4">
<Col md={6}>
<Card class="mb-3">
<CardHeader>
<strong>Checker Information</strong>
</CardHeader>
<CardBody>
<dl class="row mb-0">
<dt class="col-sm-4">Name:</dt>
<dd class="col-sm-8">{checker.name}</dd>
<dt class="col-sm-4">Availability:</dt>
<dd class="col-sm-8">
{#if availabilityBadges(checker.availability).length > 0}
<div class="d-flex flex-wrap gap-1">
{#each availabilityBadges(checker.availability) as badge}
<Badge color={badge.color}
>{badge.label}-level</Badge
>
{/each}
</div>
{:else}
<Badge color="secondary">General</Badge>
{/if}
{#if checker.availability?.limitToProviders?.length}
<div class="mt-1 small text-muted">
Providers: {checker.availability.limitToProviders.join(
", ",
)}
</div>
{/if}
{#if checker.availability?.limitToServices?.length}
<div class="mt-1 small text-muted">
Services: {checker.availability.limitToServices.join(
", ",
)}
</div>
{/if}
</dd>
{#if checker.interval}
<dt class="col-sm-4">Interval:</dt>
<dd class="col-sm-8">
<span>default {formatDuration(checker.interval.default)}</span>
<span class="text-muted small ms-2">
(min {formatDuration(checker.interval.min)} / max {formatDuration(checker.interval.max)})
</span>
</dd>
{/if}
</dl>
</CardBody>
</Card>
{#if checker.rules && checker.rules.length > 0}
<Card>
<CardHeader class="d-flex align-items-center justify-content-between">
<div>
<strong>Check Rules</strong>
<Badge color="secondary" class="ms-2">
{checker.rules.length}
</Badge>
</div>
{#if checker.rules.reduce((acc, rule) => acc + rule.options?.adminOpts?.length, 0) > 0}
<Button
color="success"
size="sm"
onclick={saveOptions}
disabled={saving}
>
{#if saving}
<span class="spinner-border spinner-border-sm me-1"
></span>
{:else}
<Icon name="check-circle"></Icon>
{/if}
Save
</Button>
{/if}
</CardHeader>
<ListGroup flush>
{#each checker.rules as rule, i}
{@const ruleOpts = rule.options?.adminOpts || []}
<ListGroupItem>
<div class="d-flex align-items-start gap-2 mb-1">
<Icon
name="check2-circle"
class="text-success mt-1 flex-shrink-0"
></Icon>
<div class="flex-grow-1">
<strong>{rule.name}</strong>
{#if rule.description}
<p class="text-muted small mb-0">
{rule.description}
</p>
{/if}
</div>
</div>
{#if ruleOpts.length > 0}
<div class="ms-4 mt-2">
<Form onsubmit={saveOptions}>
{#each ruleOpts as optDoc, index}
{#if optDoc.id}
<ResourceInput
edit
index={"" + index}
specs={optDoc}
type={optDoc.type || "string"}
bind:value={optionValues[optDoc.id]}
/>
{/if}
{/each}
</Form>
</div>
{/if}
</ListGroupItem>
{/each}
</ListGroup>
</Card>
{/if}
</Col>
<Col md={6}>
{#await checkerOptionsQ}
<Card>
<CardBody>
<p class="text-center mb-0">
<span class="spinner-border spinner-border-sm me-2"></span>
Loading options...
</p>
</CardBody>
</Card>
{:then _optionsR}
{@const adminOpts = checker.options?.adminOpts || []}
{@const readOnlyOptGroups = [
{
key: "userOpts",
label: "User Options",
opts: checker.options?.userOpts || [],
},
{
key: "domainOpts",
label: "Domain Options",
opts: checker.options?.domainOpts || [],
},
{
key: "serviceOpts",
label: "Service Options",
opts: checker.options?.serviceOpts || [],
},
{
key: "runOpts",
label: "Run Options",
opts: checker.options?.runOpts || [],
},
]}
{@const rulesAdminOpts = (checker.rules || []).flatMap(
(r) => r.options?.adminOpts || [],
)}
{@const allAdminOpts = [...adminOpts, ...rulesAdminOpts]}
{@const hasAnyOpts =
allAdminOpts.length > 0 ||
readOnlyOptGroups.some((g) => g.opts.length > 0)}
{@const orphanedOpts = getOrphanedOptions(allAdminOpts)}
{#if orphanedOpts.length > 0}
<Alert color="warning" class="mb-3">
<div class="d-flex justify-content-between align-items-center">
<div>
<Icon name="exclamation-triangle-fill"></Icon>
<strong>Orphaned options detected:</strong>
{orphanedOpts.join(", ")}
</div>
<Button
color="danger"
size="sm"
onclick={() => cleanOrphanedOptions(allAdminOpts)}
disabled={saving}
>
<Icon name="trash"></Icon>
Clean Up
</Button>
</div>
</Alert>
{/if}
{#if adminOpts.length > 0}
<Card class="mb-3">
<CardHeader
class="d-flex align-items-center justify-content-between"
>
<strong>Admin Options</strong>
<Button
form="adminoptsform"
color="success"
size="sm"
onclick={saveOptions}
disabled={saving}
>
{#if saving}
<span class="spinner-border spinner-border-sm me-1"
></span>
{:else}
<Icon name="check-circle"></Icon>
{/if}
Save
</Button>
</CardHeader>
<CardBody>
<Form id="adminoptsform" onsubmit={saveOptions}>
{#each adminOpts as optDoc, index}
{#if optDoc.id}
<ResourceInput
edit
index={"" + index}
specs={optDoc}
type={optDoc.type || "string"}
bind:value={optionValues[optDoc.id]}
/>
{/if}
{/each}
</Form>
</CardBody>
</Card>
{/if}
{#each readOnlyOptGroups.filter((g) => g.opts.length > 0) as group}
<Card class="mb-3">
<CardHeader>
<strong>{group.label}</strong>
<Badge color="secondary" class="ms-2">read-only</Badge>
</CardHeader>
<CardBody>
<dl class="row mb-0">
{#each group.opts as opt}
<dt class="col-sm-4">{opt.label || opt.id}</dt>
<dd class="col-sm-8">
<span class="text-muted small"
>{opt.type || "string"}</span
>
{#if opt.description}
<div class="form-text">{opt.description}</div>
{/if}
</dd>
{/each}
</dl>
</CardBody>
</Card>
{/each}
{#if !hasAnyOpts}
<Card>
<CardBody>
<Alert color="info" class="mb-0">
<Icon name="info-circle"></Icon>
This checker has no configurable options.
</Alert>
</CardBody>
</Card>
{/if}
{:catch error}
<Card>
<CardBody>
<Alert color="danger" class="mb-0">
<Icon name="exclamation-triangle-fill"></Icon>
Error loading options: {error.message}
</Alert>
</CardBody>
</Card>
{/await}
</Col>
</Row>
{:else}
<Alert color="danger">
<Icon name="exclamation-triangle-fill"></Icon>
Error: checker data not found
</Alert>
{/if}
{:catch error}
<Alert color="danger">
<Icon name="exclamation-triangle-fill"></Icon>
Error loading checker: {error.message}
</Alert>
{/await}
</Container>

View file

@ -0,0 +1,262 @@
<!--
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 { onMount } from "svelte";
import {
Badge,
Button,
Card,
CardBody,
CardHeader,
Col,
Container,
Icon,
Row,
Spinner,
Table,
} from "@sveltestrap/sveltestrap";
import {
getScheduler,
postSchedulerEnable,
postSchedulerDisable,
postSchedulerRescheduleUpcoming,
} from "$lib/api-admin";
import type { CheckerSchedulerStatus } from "$lib/api-admin";
import { formatDuration, formatRelative } from "$lib/utils/datetime";
let status = $state<CheckerSchedulerStatus | null>(null);
let loading = $state(true);
let toggling = $state(false);
let rescheduling = $state(false);
let error = $state<string | null>(null);
async function fetchStatus() {
loading = true;
error = null;
try {
const { data, error: err } = await getScheduler();
if (err) throw new Error(String(err));
status = data ?? null;
} catch (e: any) {
error = e.message ?? "Unknown error";
} finally {
loading = false;
}
}
async function toggleScheduler() {
if (!status) return;
toggling = true;
error = null;
try {
const fn = status.running ? postSchedulerDisable : postSchedulerEnable;
const { data, error: err } = await fn();
if (err) throw new Error(String(err));
status = data ?? null;
} catch (e: any) {
error = e.message ?? "Unknown error";
} finally {
toggling = false;
}
}
async function rebuildQueue() {
rescheduling = true;
error = null;
try {
const { error: err } = await postSchedulerRescheduleUpcoming();
if (err) throw new Error(String(err));
await fetchStatus();
} catch (e: any) {
error = e.message ?? "Unknown error";
} finally {
rescheduling = false;
}
}
onMount(fetchStatus);
</script>
<Container class="flex-fill my-5">
<Row class="mb-4">
<Col>
<h1 class="display-5">
<Icon name="clock-history"></Icon>
Scheduler
</h1>
<p class="text-muted lead">Monitor and control the checker scheduler</p>
</Col>
</Row>
{#if error}
<Card color="danger" body class="mb-4">
<Icon name="exclamation-triangle-fill"></Icon>
{error}
</Card>
{/if}
{#if loading}
<div class="d-flex align-items-center gap-2">
<Spinner size="sm" />
<span>Loading scheduler status...</span>
</div>
{:else if status}
<Card class="mb-4">
<CardHeader>
<div class="d-flex justify-content-between align-items-center">
<span>
<Icon name="info-circle-fill"></Icon>
Scheduler Status
</span>
<div class="d-flex gap-2">
<Button size="sm" color="secondary" outline onclick={fetchStatus}>
<Icon name="arrow-clockwise"></Icon> Refresh
</Button>
<Button
size="sm"
color={status.running ? "warning" : "success"}
disabled={toggling}
onclick={toggleScheduler}
>
{#if toggling}
<Spinner size="sm" />
{:else if status.running}
<Icon name="stop-fill"></Icon> Stop
{:else}
<Icon name="play-fill"></Icon> Start
{/if}
</Button>
<Button
size="sm"
color="primary"
outline
disabled={rescheduling}
onclick={rebuildQueue}
>
{#if rescheduling}
<Spinner size="sm" />
{:else}
<Icon name="calendar2-check"></Icon> Rebuild queue
{/if}
</Button>
</div>
</div>
</CardHeader>
<CardBody>
<div class="d-flex gap-4 align-items-center">
<div>
<small class="text-muted d-block">Status</small>
{#if status.running}
<Badge color="success"><Icon name="play-fill"></Icon> Running</Badge>
{:else}
<Badge color="secondary"><Icon name="stop-fill"></Icon> Stopped</Badge>
{/if}
</div>
<div>
<small class="text-muted d-block">Jobs in queue</small>
<strong>{status.job_count ?? 0}</strong>
</div>
</div>
</CardBody>
</Card>
<Card>
<CardHeader>
<Icon name="list-ol"></Icon>
Next scheduled jobs
<Badge color="secondary" class="ms-2">{status.next_jobs?.length ?? 0}</Badge>
</CardHeader>
<CardBody class="p-0">
<div class="table-responsive">
<Table hover class="mb-0">
<thead>
<tr>
<th>Checker</th>
<th>Target</th>
<th>Interval</th>
<th>Next run</th>
</tr>
</thead>
<tbody>
{#if !status.next_jobs || status.next_jobs.length === 0}
<tr>
<td colspan="4" class="text-center text-muted py-3">
No jobs scheduled
</td>
</tr>
{:else}
{#each status.next_jobs as job}
<tr>
<td>
<code>{job.checkerID ?? "—"}</code>
</td>
<td>
{#if job.target?.domainId}
<Badge
href={"/domains/" + job.target?.domainId}
color="info"
class="me-1"
>
domain
</Badge>
{/if}
{#if job.target?.serviceId}
<Badge
href={"/service/" + job.target?.serviceId}
color="warning"
class="me-1"
>
service
</Badge>
{/if}
{#if job.target?.userId}
<Badge
href={"/users/" + job.target?.userId}
color="secondary"
class="me-1"
>
user
</Badge>
{/if}
{#if !job.target?.domainId && !job.target?.serviceId && !job.target?.userId}
<span class="text-muted"></span>
{/if}
</td>
<td>{formatDuration(job.interval)}</td>
<td>
<span title={job.nextRun}
>{formatRelative(job.nextRun)}</span
>
</td>
</tr>
{/each}
{/if}
</tbody>
</Table>
</div>
</CardBody>
</Card>
{/if}
</Container>

View file

@ -54,6 +54,7 @@ import {
import type {
HappydnsCheckEvaluation,
HappydnsCheckPlan,
HappydnsCheckPlanWritable,
HappydnsCheckerDefinition,
HappydnsCheckerOptions,
HappydnsCheckerOptionsPositional,
@ -85,7 +86,7 @@ export async function updateCheckOptions(
options: HappydnsCheckerOptions,
): Promise<HappydnsCheckerOptions> {
return unwrapSdkResponse(
await putCheckersByCheckerIdOptions({ path: { checkerId }, body: options as any }),
await putCheckersByCheckerIdOptions({ path: { checkerId }, body: options }),
) as HappydnsCheckerOptions;
}
@ -93,7 +94,7 @@ export async function updateCheckOptions(
export async function listDomainCheckers(domain: string): Promise<HappydnsCheckerStatus[]> {
return (unwrapSdkResponse(
await getDomainsByDomainCheckers({ path: { domain } } as any),
await getDomainsByDomainCheckers({ path: { domain } }),
) as HappydnsCheckerStatus[]) ?? [];
}
@ -106,10 +107,10 @@ export async function listDomainExecutions(
await getDomainsByDomainCheckersByCheckerIdExecutions({
path: { domain, checkerId },
query: {
...(options?.includePlanned ? { include_planned: "true" } : {}),
...(options?.limit ? { limit: String(options.limit) } : {}),
...(options?.includePlanned ? { include_planned: true } : {}),
...(options?.limit ? { limit: options.limit } : {}),
},
} as any),
}),
) as HappydnsExecution[]) ?? [];
}
@ -122,7 +123,7 @@ export async function triggerDomainCheck(
await postDomainsByDomainCheckersByCheckerIdExecutions({
path: { domain, checkerId },
body: request,
} as any),
}),
) as HappydnsExecution;
}
@ -134,7 +135,7 @@ export async function getDomainExecution(
return unwrapSdkResponse(
await getDomainsByDomainCheckersByCheckerIdExecutionsByExecutionId({
path: { domain, checkerId, executionId },
} as any),
}),
) as HappydnsExecution;
}
@ -146,7 +147,7 @@ export async function deleteDomainExecution(
return unwrapEmptyResponse(
await deleteDomainsByDomainCheckersByCheckerIdExecutionsByExecutionId({
path: { domain, checkerId, executionId },
} as any),
}),
);
}
@ -157,7 +158,7 @@ export async function deleteAllDomainExecutions(
return unwrapEmptyResponse(
await deleteDomainsByDomainCheckersByCheckerIdExecutions({
path: { domain, checkerId },
} as any),
}),
);
}
@ -169,7 +170,7 @@ export async function getDomainExecutionResults(
return unwrapSdkResponse(
await getDomainsByDomainCheckersByCheckerIdExecutionsByExecutionIdResults({
path: { domain, checkerId, executionId },
} as any),
}),
) as HappydnsCheckEvaluation;
}
@ -181,7 +182,7 @@ export async function getDomainExecutionObservations(
return unwrapSdkResponse(
await getDomainsByDomainCheckersByCheckerIdExecutionsByExecutionIdObservations({
path: { domain, checkerId, executionId },
} as any),
}),
) as HappydnsObservationSnapshot;
}
@ -192,7 +193,7 @@ export async function getDomainCheckOptions(
return (unwrapSdkResponse(
await getDomainsByDomainCheckersByCheckerIdOptions({
path: { domain, checkerId },
} as any),
}),
) as HappydnsCheckerOptionsPositional[]) ?? [];
}
@ -204,8 +205,8 @@ export async function updateDomainCheckOptions(
return unwrapSdkResponse(
await putDomainsByDomainCheckersByCheckerIdOptions({
path: { domain, checkerId },
body: options as any,
} as any),
body: options,
}),
) as HappydnsCheckerOptions;
}
@ -216,20 +217,20 @@ export async function getDomainCheckPlans(
return (unwrapSdkResponse(
await getDomainsByDomainCheckersByCheckerIdPlans({
path: { domain, checkerId },
} as any),
}),
) as HappydnsCheckPlan[]) ?? [];
}
export async function createDomainCheckPlan(
domain: string,
checkerId: string,
plan: HappydnsCheckPlan,
plan: HappydnsCheckPlan | HappydnsCheckPlanWritable,
): Promise<HappydnsCheckPlan> {
return unwrapSdkResponse(
await postDomainsByDomainCheckersByCheckerIdPlans({
path: { domain, checkerId },
body: plan as any,
} as any),
body: plan as HappydnsCheckPlanWritable,
}),
) as HappydnsCheckPlan;
}
@ -237,13 +238,13 @@ export async function updateDomainCheckPlan(
domain: string,
checkerId: string,
planId: string,
plan: HappydnsCheckPlan,
plan: HappydnsCheckPlan | HappydnsCheckPlanWritable,
): Promise<HappydnsCheckPlan> {
return unwrapSdkResponse(
await putDomainsByDomainCheckersByCheckerIdPlansByPlanId({
path: { domain, checkerId, planId },
body: plan as any,
} as any),
body: plan as HappydnsCheckPlanWritable,
}),
) as HappydnsCheckPlan;
}
@ -258,7 +259,7 @@ export async function listServiceCheckers(
return (unwrapSdkResponse(
await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckers({
path: { domain, zoneid, subdomain, serviceid },
} as any),
}),
) as HappydnsCheckerStatus[]) ?? [];
}
@ -274,10 +275,10 @@ export async function listServiceExecutions(
await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutions({
path: { domain, zoneid, subdomain, serviceid, checkerId },
query: {
...(options?.includePlanned ? { include_planned: "true" } : {}),
...(options?.limit ? { limit: String(options.limit) } : {}),
...(options?.includePlanned ? { include_planned: true } : {}),
...(options?.limit ? { limit: options.limit } : {}),
},
} as any),
}),
) as HappydnsExecution[]) ?? [];
}
@ -293,7 +294,7 @@ export async function triggerServiceCheck(
await postDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutions({
path: { domain, zoneid, subdomain, serviceid, checkerId },
body: request,
} as any),
}),
) as HappydnsExecution;
}
@ -308,7 +309,7 @@ export async function getServiceExecution(
return unwrapSdkResponse(
await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionId({
path: { domain, zoneid, subdomain, serviceid, checkerId, executionId },
} as any),
}),
) as HappydnsExecution;
}
@ -323,7 +324,7 @@ export async function deleteServiceExecution(
return unwrapEmptyResponse(
await deleteDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionId({
path: { domain, zoneid, subdomain, serviceid, checkerId, executionId },
} as any),
}),
);
}
@ -337,7 +338,7 @@ export async function deleteAllServiceExecutions(
return unwrapEmptyResponse(
await deleteDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutions({
path: { domain, zoneid, subdomain, serviceid, checkerId },
} as any),
}),
);
}
@ -352,7 +353,7 @@ export async function getServiceExecutionResults(
return unwrapSdkResponse(
await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionIdResults({
path: { domain, zoneid, subdomain, serviceid, checkerId, executionId },
} as any),
}),
) as HappydnsCheckEvaluation;
}
@ -367,7 +368,7 @@ export async function getServiceExecutionObservations(
return unwrapSdkResponse(
await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionIdObservations({
path: { domain, zoneid, subdomain, serviceid, checkerId, executionId },
} as any),
}),
) as HappydnsObservationSnapshot;
}
@ -381,7 +382,7 @@ export async function getServiceCheckOptions(
return (unwrapSdkResponse(
await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdOptions({
path: { domain, zoneid, subdomain, serviceid, checkerId },
} as any),
}),
) as HappydnsCheckerOptionsPositional[]) ?? [];
}
@ -396,8 +397,8 @@ export async function updateServiceCheckOptions(
return unwrapSdkResponse(
await putDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdOptions({
path: { domain, zoneid, subdomain, serviceid, checkerId },
body: options as any,
} as any),
body: options,
}),
) as HappydnsCheckerOptions;
}
@ -411,7 +412,7 @@ export async function getServiceCheckPlans(
return (unwrapSdkResponse(
await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdPlans({
path: { domain, zoneid, subdomain, serviceid, checkerId },
} as any),
}),
) as HappydnsCheckPlan[]) ?? [];
}
@ -421,13 +422,13 @@ export async function createServiceCheckPlan(
subdomain: string,
serviceid: string,
checkerId: string,
plan: HappydnsCheckPlan,
plan: HappydnsCheckPlan | HappydnsCheckPlanWritable,
): Promise<HappydnsCheckPlan> {
return unwrapSdkResponse(
await postDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdPlans({
path: { domain, zoneid, subdomain, serviceid, checkerId },
body: plan as any,
} as any),
body: plan as HappydnsCheckPlanWritable,
}),
) as HappydnsCheckPlan;
}
@ -438,13 +439,13 @@ export async function updateServiceCheckPlan(
serviceid: string,
checkerId: string,
planId: string,
plan: HappydnsCheckPlan,
plan: HappydnsCheckPlan | HappydnsCheckPlanWritable,
): Promise<HappydnsCheckPlan> {
return unwrapSdkResponse(
await putDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdPlansByPlanId({
path: { domain, zoneid, subdomain, serviceid, checkerId, planId },
body: plan as any,
} as any),
body: plan as HappydnsCheckPlanWritable,
}),
) as HappydnsCheckPlan;
}
@ -580,7 +581,7 @@ export async function getScopedCheckPlans(
export async function createScopedCheckPlan(
scope: CheckerScope,
checkerId: string,
plan: HappydnsCheckPlan,
plan: HappydnsCheckPlan | HappydnsCheckPlanWritable,
): Promise<HappydnsCheckPlan> {
if (isServiceScope(scope)) {
return createServiceCheckPlan(scope.domainId, scope.zoneId, scope.subdomain, scope.serviceId, checkerId, plan);
@ -592,7 +593,7 @@ export async function updateScopedCheckPlan(
scope: CheckerScope,
checkerId: string,
planId: string,
plan: HappydnsCheckPlan,
plan: HappydnsCheckPlan | HappydnsCheckPlanWritable,
): Promise<HappydnsCheckPlan> {
if (isServiceScope(scope)) {
return updateServiceCheckPlan(scope.domainId, scope.zoneId, scope.subdomain, scope.serviceId, checkerId, planId, plan);

View file

@ -138,6 +138,14 @@
>
{$t("menu.dns-resolver")}
</DropdownItem>
<DropdownItem
active={page.route &&
(page.route.id == "/checkers" ||
page.route.id?.startsWith("/checkers/"))}
href="/checkers"
>
{$t("menu.checkers")}
</DropdownItem>
<DropdownItem divider />
<DropdownItem active={page.route && page.route.id == "/me"} href="/me">
{$t("menu.my-account")}

View file

@ -0,0 +1,276 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-2026 happyDomain
Authors: Pierre-Olivier Mercier, et al.
This program is offered under a commercial and under the AGPL license.
For commercial licensing, contact us at <contact@happydomain.org>.
For AGPL licensing:
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
// SvelteKit imports
import { navigate } from "$lib/stores/config";
// Component imports
import {
Badge,
Button,
ButtonGroup,
Card,
CardHeader,
CardTitle,
Icon,
Spinner,
Table,
} from "@sveltestrap/sveltestrap";
// Store imports
import { currentExecution, currentCheckInfo, reportViewMode, showHTMLReport } from "$lib/stores/checkers";
import { toasts } from "$lib/stores/toasts";
// API imports
import {
triggerDomainCheck,
triggerServiceCheck,
} from "$lib/api/checkers";
// Utility imports
import { getStatusColor, getStatusI18nKey, formatCheckDate, downloadBlob } from "$lib/utils";
import { formatDuration } from "$lib/utils/datetime";
import { t } from "$lib/translations";
// Model imports
import type { Domain } from "$lib/model/domain";
// Props
interface Props {
domain: Domain;
checkerId: string;
rid: string;
checksBase: string;
serviceContext?: {
zoneId: string;
subdomain: string;
serviceid: string;
};
}
let { domain, checkerId, rid, checksBase, serviceContext }: Props = $props();
// Local state
let isRelaunching = $state(false);
// Functions
async function handleRelaunch() {
if (!$currentExecution) return;
isRelaunching = true;
try {
if (serviceContext) {
await triggerServiceCheck(
domain.id,
serviceContext.zoneId,
serviceContext.subdomain,
serviceContext.serviceid,
checkerId,
);
} else {
await triggerDomainCheck(domain.id, checkerId);
}
navigate(`${checksBase}/${encodeURIComponent(checkerId)}/executions`);
} catch (error: any) {
toasts.addErrorToast({
message: error.message || $t("checkers.result.relaunch-failed"),
});
} finally {
isRelaunching = false;
}
}
// TODO: Delete endpoint not yet available for executions
async function handleDelete() {
toasts.addErrorToast({ message: "Delete not yet available" });
}
// TODO: HTML report download endpoint not yet available in new API
async function downloadHTML() {
toasts.addErrorToast({ message: "HTML report download not yet available" });
}
function downloadJSON() {
if (!$currentExecution) return;
downloadBlob(
JSON.stringify($currentExecution.result, null, 2),
`${checkerId}-${rid}.json`,
"application/json",
);
}
</script>
{#if $currentExecution}
<Card class="mt-3">
<CardHeader class="px-2">
<div class="d-flex justify-content-between align-items-center">
<strong class="text-truncate">{$currentCheckInfo?.name || checkerId}</strong>
{#if $currentExecution.planId}
<Badge color="info" class="flex-shrink-0">
<Icon name="clock"></Icon>
{$t("checkers.result.type.scheduled")}
</Badge>
{:else}
<Badge color="secondary" class="flex-shrink-0">
<Icon name="hand-index"></Icon>
{$t("checkers.result.type.manual")}
</Badge>
{/if}
</div>
</CardHeader>
<div class="overflow-x-auto rounded-2">
<Table borderless size="sm" class="mb-0">
<tbody>
<tr>
<th style="width: 80px; white-space: nowrap">
{$t("checkers.result.field.executed-at")}
</th>
<td>{formatCheckDate($currentExecution.startedAt)}</td>
</tr>
{#if $currentExecution.endedAt && $currentExecution.startedAt}
<tr>
<th>{$t("checkers.result.field.duration")}</th>
<td>{formatDuration(($currentExecution.endedAt.getTime() - $currentExecution.startedAt.getTime()) * 1e6)}</td>
</tr>
{/if}
{#if $currentExecution.result}
<tr>
<th>{$t("checkers.result.field.status")}</th>
<td>
<Badge color={getStatusColor($currentExecution.result.status)}>
{$t(getStatusI18nKey($currentExecution.result.status))}
</Badge>
</td>
</tr>
{#if $currentExecution.result.message}
<tr>
<th>{$t("checkers.result.field.status-message")}</th>
<td class="text-truncate" style="max-width: 0">
{$currentExecution.result.message}
</td>
</tr>
{/if}
{/if}
{#if $currentExecution.error}
<tr>
<th>{$t("checkers.result.field.error")}</th>
<td class="text-danger text-truncate" style="max-width: 0">
{$currentExecution.error}
</td>
</tr>
{/if}
</tbody>
</Table>
</div>
</Card>
<div class="my-3 flex-fill"></div>
{#if $currentCheckInfo?.has_html_report || $currentCheckInfo?.has_metrics || $currentExecution.result != null}
{#if $currentCheckInfo?.has_metrics || $currentCheckInfo?.has_html_report}
<ButtonGroup class="w-100 mb-2">
{#if $currentCheckInfo?.has_metrics}
<Button
size="sm"
color="secondary"
outline
active={$reportViewMode === "metrics"}
onclick={() => {
reportViewMode.set("metrics");
showHTMLReport.set(false);
}}
>
<Icon name="graph-up"></Icon>
{$t("checkers.result.view-metrics")}
</Button>
{/if}
{#if $currentCheckInfo?.has_html_report}
<Button
size="sm"
color="secondary"
outline
active={$reportViewMode === "html" ||
(!$currentCheckInfo?.has_metrics && $showHTMLReport)}
onclick={() => {
reportViewMode.set("html");
showHTMLReport.set(true);
}}
>
<Icon name="file-earmark-richtext"></Icon>
{$t("checkers.result.view-html")}
</Button>
{/if}
<Button
size="sm"
color="secondary"
outline
active={$reportViewMode === "json" ||
(!$currentCheckInfo?.has_metrics && !$currentCheckInfo?.has_html_report)}
onclick={() => {
reportViewMode.set("json");
showHTMLReport.set(false);
}}
>
<Icon name="braces"></Icon>
{$t("checkers.result.view-json")}
</Button>
</ButtonGroup>
{/if}
<ButtonGroup class="w-100">
{#if $currentCheckInfo?.has_html_report}
<Button size="sm" color="outline-secondary" onclick={downloadHTML}>
<Icon name="download"></Icon>
{$t("checkers.result.download-html")}
</Button>
{/if}
{#if $currentExecution.result != null}
<Button size="sm" color="outline-secondary" onclick={downloadJSON}>
<Icon name="download"></Icon>
{$t("checkers.result.download-json")}
</Button>
{/if}
</ButtonGroup>
{/if}
{:else}
<div class="flex-fill"></div>
{/if}
<div class="mt-2 d-flex gap-2">
<Button
class="flex-fill"
color="primary"
outline
onclick={handleRelaunch}
disabled={!$currentExecution || isRelaunching}
>
{#if isRelaunching}
<Spinner size="sm" />
{:else}
<Icon name="arrow-repeat"></Icon>
{/if}
{$t("checkers.result.relaunch")}
</Button>
<Button color="danger" outline onclick={handleDelete} disabled={!$currentExecution}>
<Icon name="trash"></Icon>
</Button>
</div>

View file

@ -0,0 +1,187 @@
<!--
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, Card, Col, Icon, Row } from "@sveltestrap/sveltestrap";
import { t } from "$lib/translations";
import { checkers } from "$lib/stores/checkers";
import { toasts } from "$lib/stores/toasts";
import type {
HappydnsCheckPlan,
HappydnsCheckPlanWritable,
HappydnsCheckerOptionsPositional,
} from "$lib/api-base/types.gen";
import type { CheckerScope } from "$lib/api/checkers";
import {
getCheckStatus,
getScopedCheckOptions,
updateScopedCheckOptions,
} from "$lib/api/checkers";
import PageTitle from "$lib/components/PageTitle.svelte";
import CheckerScheduleCard from "./CheckerScheduleCard.svelte";
import CheckerRulesCard from "./CheckerRulesCard.svelte";
import CheckerOptionsPanel from "./CheckerOptionsPanel.svelte";
interface Props {
scope: CheckerScope;
checksBase: string;
checkerId: string;
domainName: string;
editableGroups: (status: any) => { label: string; opts: any[] }[];
readOnlyGroups: (status: any) => { key: string; label: string; opts: any[] }[];
}
let { scope, checksBase, checkerId, domainName, editableGroups, readOnlyGroups }: Props = $props();
let checkStatusPromise = $derived(getCheckStatus(checkerId));
let checkOptionsPromise = $derived(getScopedCheckOptions(scope, checkerId));
let resolvedStatus = $state<any>(null);
let optionValues = $state<Record<string, unknown>>({});
let inheritedValues = $state<Record<string, unknown>>({});
let savingOptions = $state(false);
let plan = $state<HappydnsCheckPlanWritable>({
enabled: {},
interval: 3600,
});
$effect(() => {
checkStatusPromise.then((status) => {
resolvedStatus = status;
if (status?.rules && Object.keys(plan.enabled ?? {}).length === 0) {
const enabled: Record<string, boolean> = {};
for (const rule of status.rules) {
if (rule.name) enabled[rule.name] = true;
}
plan.enabled = enabled;
}
});
});
$effect(() => {
checkOptionsPromise.then((positionals: HappydnsCheckerOptionsPositional[]) => {
const current =
positionals.length > 0 ? (positionals[positionals.length - 1]?.options ?? {}) : {};
const inherited: Record<string, unknown> = {};
for (let i = 0; i < positionals.length - 1; i++) {
for (const [k, v] of Object.entries(positionals[i].options ?? {})) {
inherited[k] = v;
}
}
optionValues = { ...current };
inheritedValues = inherited;
});
});
async function saveOptions() {
savingOptions = true;
try {
await updateScopedCheckOptions(scope, checkerId, optionValues);
checkOptionsPromise = getScopedCheckOptions(scope, checkerId);
toasts.addToast({
message: $t("checkers.messages.options-updated"),
type: "success",
timeout: 5000,
});
} catch (error) {
toasts.addErrorToast({
message: $t("checkers.messages.update-failed", { error: String(error) }),
timeout: 10000,
});
} finally {
savingOptions = false;
}
}
</script>
<svelte:head>
<title>{resolvedStatus?.name ?? checkerId} - {domainName} - happyDomain</title>
</svelte:head>
<div class="flex-fill mt-1 mb-5">
<PageTitle title={resolvedStatus?.name ?? checkerId} domain={domainName}>
{#if $checkers && (!$checkers[checkerId]?.availability || $checkers[checkerId].availability.applyToDomain || $checkers[checkerId].availability.applyToZone)}
<Button
color="info"
href={`${checksBase}/${encodeURIComponent(checkerId)}/executions`}
>
<Icon name="bar-chart-fill"></Icon>
{$t("checkers.list.view-results")}
</Button>
{/if}
</PageTitle>
{#await checkStatusPromise}
<Card body>
<p class="text-center mb-0">
<span class="spinner-border spinner-border-sm me-2"></span>
{$t("checkers.loading-info")}
</p>
</Card>
{:then status}
{#if status}
{@const editable = editableGroups(status)}
{@const readOnly = readOnlyGroups(status)}
<Row class="mb-4">
<Col md={6}>
<CheckerScheduleCard {scope} {checkerId} bind:plan />
{#if status.rules && status.rules.length > 0}
<CheckerRulesCard
rules={status.rules}
bind:optionValues
{inheritedValues}
saving={savingOptions}
onsave={saveOptions}
bind:plan
/>
{/if}
</Col>
<Col md={6}>
<CheckerOptionsPanel
{checkOptionsPromise}
editableGroups={editable}
readOnlyGroups={readOnly}
bind:optionValues
{inheritedValues}
saving={savingOptions}
onsave={saveOptions}
/>
</Col>
</Row>
{:else}
<Alert color="danger">
<Icon name="exclamation-triangle-fill"></Icon>
{$t("checkers.checker-info-not-found")}
</Alert>
{/if}
{:catch error}
<Alert color="danger">
<Icon name="exclamation-triangle-fill"></Icon>
{$t("checkers.error-loading-checker", { error: error.message })}
</Alert>
{/await}
</div>

View file

@ -0,0 +1,181 @@
<!--
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,
Card,
CardBody,
CardHeader,
Icon,
Table,
} from "@sveltestrap/sveltestrap";
import { t } from "$lib/translations";
import type { CheckerScope } from "$lib/api/checkers";
import { listScopedCheckers } from "$lib/api/checkers";
import { checkers } from "$lib/stores/checkers";
import type { HappydnsCheckerStatus } from "$lib/api-base/types.gen";
import { getStatusColor, getStatusI18nKey, formatCheckDate } from "$lib/utils";
import CheckersAvailabilityTable from "./CheckersAvailabilityTable.svelte";
import PageTitle from "$lib/components/PageTitle.svelte";
interface Props {
scope: CheckerScope;
checksBase: string;
title: string;
domainName: string;
filterAvailability: "applyToDomain" | "applyToService";
}
let { scope, checksBase, title, domainName, filterAvailability }: Props = $props();
let checkersPromise = $derived(listScopedCheckers(scope));
function getConfiguredCheckerIds(statuses: HappydnsCheckerStatus[]): Set<string> {
return new Set(statuses.map((s) => s.id).filter((id): id is string => !!id));
}
function getUnconfiguredCheckers(configuredIds: Set<string>): [string, any][] {
if (!$checkers) return [];
return Object.entries($checkers).filter(
([id, def]) => !configuredIds.has(id) && def.availability?.[filterAvailability],
);
}
</script>
<svelte:head>
<title>{$t("checkers.list.title")}{domainName} - happyDomain</title>
</svelte:head>
<div class="flex-fill mt-1 mb-5">
<PageTitle title={title} domain={domainName}></PageTitle>
{#await checkersPromise}
<Card body>
<p class="text-center mb-0">
<span class="spinner-border spinner-border-sm me-2"></span>
{$t("checkers.list.loading")}
</p>
</Card>
{:then checkerStatuses}
{#if checkerStatuses.length > 0}
<div class="table-responsive">
<Table hover class="mb-0">
<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 checkerStatuses as checker}
{@const status = checker.latestExecution?.result?.status}
<tr>
<td>
<strong>{checker.name || checker.id}</strong>
</td>
<td>
{#if checker.latestExecution}
<Badge color={getStatusColor(status)}>
{$t(getStatusI18nKey(status))}
</Badge>
{:else}
<Badge color="secondary">
{$t("checkers.status.not-run")}
</Badge>
{/if}
</td>
<td>
{#if checker.latestExecution?.startedAt}
{formatCheckDate(checker.latestExecution.startedAt)}
{:else}
{$t("checkers.never")}
{/if}
</td>
<td>
{#if checker.enabled}
<Badge color="success">
{$t("checkers.list.schedule.enabled")}
</Badge>
{:else}
<Badge color="secondary">
{$t("checkers.list.schedule.disabled")}
</Badge>
{/if}
</td>
<td>
<div class="d-flex gap-1">
<a
href="{checksBase}/{checker.id}"
class="btn btn-sm btn-outline-primary"
>
{$t("checkers.list.configure")}
</a>
<a
href="{checksBase}/{checker.id}/executions"
class="btn btn-sm btn-outline-secondary"
>
{$t("checkers.list.view-results")}
</a>
</div>
</td>
</tr>
{/each}
</tbody>
</Table>
</div>
{:else}
<Alert color="info" class="mb-4">
<Icon name="info-circle" />
{$t("checkers.list.no-checks")}
</Alert>
{/if}
{@const configuredIds = getConfiguredCheckerIds(checkerStatuses)}
{@const unconfigured = getUnconfiguredCheckers(configuredIds)}
{#if unconfigured.length > 0}
<Card>
<CardHeader>
<strong>{$t("checkers.other-checkers.title")}</strong>
</CardHeader>
<CardBody>
<p class="text-muted">{$t("checkers.other-checkers.description")}</p>
<CheckersAvailabilityTable
checkers={unconfigured}
basePath={checksBase}
/>
</CardBody>
</Card>
{/if}
{:catch error}
<Alert color="danger">
<Icon name="exclamation-triangle-fill" />
{$t("checkers.list.error-loading", { error: error.message })}
</Alert>
{/await}
</div>

View file

@ -0,0 +1,70 @@
<!--
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, Card, CardBody, CardHeader } from "@sveltestrap/sveltestrap";
import type { HappydnsCheckerOptionDocumentation } from "$lib/api-base/types.gen";
import { t } from "$lib/translations";
let {
groups,
}: {
groups: { key: string; label: string; opts: HappydnsCheckerOptionDocumentation[] }[];
} = $props();
function autoFillLabel(key: string): string {
const knownKeys: Record<string, string> = {
domain_name: $t("checkers.auto-fill.domain_name"),
subdomain: $t("checkers.auto-fill.subdomain"),
service_type: $t("checkers.auto-fill.service_type"),
};
return knownKeys[key] || $t("checkers.auto-fill.generic", { key });
}
</script>
{#each groups.filter((g) => g.opts.length > 0) as group}
<Card class="mb-3">
<CardHeader>
<strong>{group.label}</strong>
<Badge color="secondary" class="ms-2">{$t("checkers.detail.read-only")}</Badge>
</CardHeader>
<CardBody>
<dl class="row mb-0">
{#each group.opts as opt}
<dt class="col-sm-4">
{opt.label || opt.id}
{#if opt.autoFill}
<Badge color="info" class="ms-1">{autoFillLabel(opt.autoFill)}</Badge>
{/if}
</dt>
<dd class="col-sm-8">
<span class="text-muted small">{opt.type || "string"}</span>
{#if opt.description}
<div class="form-text">{opt.description}</div>
{/if}
</dd>
{/each}
</dl>
</CardBody>
</Card>
{/each}

View file

@ -0,0 +1,197 @@
<!--
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,
Card,
CardBody,
CardHeader,
Form,
Icon,
} from "@sveltestrap/sveltestrap";
import type {
HappydnsCheckerOptionDocumentation,
HappydnsCheckerOptionsPositional,
} from "$lib/api-base/types.gen";
import { t } from "$lib/translations";
import { withInheritedPlaceholders } from "$lib/utils/checkers";
import ResourceInput from "$lib/components/inputs/Resource.svelte";
import CheckerOptionsGroups from "./CheckerOptionsGroups.svelte";
interface EditableGroup {
label: string;
opts: HappydnsCheckerOptionDocumentation[];
}
interface ReadOnlyGroup {
key: string;
label: string;
opts: HappydnsCheckerOptionDocumentation[];
}
interface Props {
checkOptionsPromise: Promise<HappydnsCheckerOptionsPositional[]>;
editableGroups: EditableGroup[];
readOnlyGroups: ReadOnlyGroup[];
optionValues: Record<string, unknown>;
inheritedValues: Record<string, unknown>;
saving: boolean;
onsave: () => void;
orphanedOpts?: string[];
onclean?: () => void;
}
let {
checkOptionsPromise,
editableGroups,
readOnlyGroups,
optionValues = $bindable(),
inheritedValues,
saving,
onsave,
orphanedOpts = [],
onclean,
}: Props = $props();
// Filter out auto-fill fields from editable groups (they are system-provided).
let filteredEditableGroups = $derived(
editableGroups.map((g) => ({
...g,
opts: g.opts.filter((opt) => !opt.autoFill),
})),
);
// Collect auto-fill fields into read-only groups for display.
let autoFillOpts = $derived(
editableGroups.flatMap((g) => g.opts.filter((opt) => opt.autoFill)),
);
let hasAnyOpts = $derived(
filteredEditableGroups.some((g) => g.opts.length > 0) ||
readOnlyGroups.some((g) => g.opts.length > 0) ||
autoFillOpts.length > 0,
);
</script>
{#await checkOptionsPromise}
<Card>
<CardBody>
<p class="text-center mb-0">
<span class="spinner-border spinner-border-sm me-2"></span>
{$t("checkers.detail.loading-options")}
</p>
</CardBody>
</Card>
{:then _options}
{#if orphanedOpts.length > 0 && onclean}
<Alert color="warning" class="mb-3">
<div class="d-flex justify-content-between align-items-center">
<div>
<Icon name="exclamation-triangle-fill"></Icon>
{$t("checkers.detail.orphaned-options", {
options: orphanedOpts.join(", "),
})}
</div>
<Button type="button" color="danger" size="sm" onclick={onclean} disabled={saving}>
<Icon name="trash"></Icon>
{$t("checkers.detail.clean-up")}
</Button>
</div>
</Alert>
{/if}
{#each filteredEditableGroups.filter((g) => g.opts.length > 0) as group, gid}
<Card class="mb-3">
<CardHeader class="d-flex align-items-center justify-content-between">
<strong>{group.label}</strong>
<Button
color="success"
form={"group-" + gid}
size="sm"
onclick={onsave}
disabled={saving}
>
{#if saving}
<span class="spinner-border spinner-border-sm me-1"></span>
{:else}
<Icon name="check-circle"></Icon>
{/if}
{$t("checkers.detail.save")}
</Button>
</CardHeader>
<CardBody>
<Form id={"group-" + gid} onsubmit={onsave}>
{#each withInheritedPlaceholders(group.opts, optionValues, inheritedValues) as optDoc, index}
{#if optDoc.id}
<ResourceInput
edit
index={"" + index}
specs={optDoc}
type={optDoc.type || "string"}
bind:value={optionValues[optDoc.id]}
/>
{/if}
{/each}
</Form>
</CardBody>
</Card>
{/each}
{#if autoFillOpts.length > 0}
<CheckerOptionsGroups
groups={[
{
key: "auto-fill",
label: $t("checkers.detail.auto-fill"),
opts: autoFillOpts,
},
]}
/>
{/if}
<CheckerOptionsGroups groups={readOnlyGroups} />
{#if !hasAnyOpts}
<Card>
<CardBody>
<Alert color="info" class="mb-0">
<Icon name="info-circle"></Icon>
{$t("checkers.detail.no-configurable-options")}
</Alert>
</CardBody>
</Card>
{/if}
{:catch error}
<Card>
<CardBody>
<Alert color="danger" class="mb-0">
<Icon name="exclamation-triangle-fill"></Icon>
{$t("checkers.detail.error-loading-options", {
error: error.message,
})}
</Alert>
</CardBody>
</Card>
{/await}

View file

@ -0,0 +1,165 @@
<!--
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,
CardHeader,
Form,
Icon,
ListGroup,
ListGroupItem,
} from "@sveltestrap/sveltestrap";
import type { HappydnsCheckPlan, HappydnsCheckPlanWritable, HappydnsCheckRuleInfo } from "$lib/api-base/types.gen";
import { t } from "$lib/translations";
import { withInheritedPlaceholders } from "$lib/utils/checkers";
import ResourceInput from "$lib/components/inputs/Resource.svelte";
interface Props {
rules: HappydnsCheckRuleInfo[];
optionValues: Record<string, unknown>;
inheritedValues: Record<string, unknown>;
saving: boolean;
onsave: () => void;
plan?: HappydnsCheckPlan | HappydnsCheckPlanWritable;
}
let { rules, optionValues = $bindable(), inheritedValues, saving, onsave, plan = $bindable() }: Props = $props();
let hasRuleOpts = $derived(
rules.some((r) => (r.options?.adminOpts?.length ?? 0) + (r.options?.userOpts?.length ?? 0) > 0),
);
let allEnabled = $derived(
plan && rules.length > 0 && rules.every((r) => r.name && plan!.enabled?.[r.name]),
);
function toggleAll() {
if (!plan) return;
const newVal = !allEnabled;
const enabled: Record<string, boolean> = {};
for (const rule of rules) {
if (rule.name) enabled[rule.name] = newVal;
}
plan.enabled = enabled;
}
</script>
<Card>
<CardHeader class="d-flex align-items-center justify-content-between">
<div>
<strong>{$t("checkers.detail.check-rules")}</strong>
<Badge color="secondary" class="ms-2">{rules.length}</Badge>
</div>
{#if plan}
<div class="d-flex gap-2">
<div class="form-check form-switch">
<input
class="form-check-input"
type="checkbox"
checked={allEnabled}
onchange={toggleAll}
id="toggle-all-rules"
/>
<label class="form-check-label" for="toggle-all-rules">
All
</label>
</div>
</div>
{:else if hasRuleOpts}
<Button
type="button"
color="success"
size="sm"
onclick={onsave}
disabled={saving}
>
{#if saving}
<span class="spinner-border spinner-border-sm me-1"></span>
{:else}
<Icon name="check-circle"></Icon>
{/if}
{$t("checkers.detail.save")}
</Button>
{/if}
</CardHeader>
<ListGroup flush>
{#each rules as rule}
{@const ruleOpts = [
...(rule.options?.adminOpts || []),
...(rule.options?.userOpts || []),
]}
<ListGroupItem>
<div class="d-flex align-items-start gap-2 mb-1">
{#if plan}
<div class="form-check form-switch mt-1">
<input
class="form-check-input"
type="checkbox"
checked={plan.enabled?.[rule.name ?? ""] ?? false}
onchange={() => {
if (rule.name && plan) {
plan.enabled = {
...plan.enabled,
[rule.name]: !(plan.enabled?.[rule.name] ?? false),
};
}
}}
/>
</div>
{:else}
<Icon
name="check2-circle"
class="text-success mt-1 flex-shrink-0"
></Icon>
{/if}
<div class="flex-grow-1">
<strong>{rule.name}</strong>
{#if rule.description}
<p class="text-muted small mb-0">{rule.description}</p>
{/if}
</div>
</div>
{#if ruleOpts.length > 0}
<div class="ms-4 mt-2">
<Form onsubmit={onsave}>
{#each withInheritedPlaceholders(ruleOpts, optionValues, inheritedValues) as optDoc, index}
{#if optDoc.id}
<ResourceInput
edit
index={"" + index}
specs={optDoc}
type={optDoc.type || "string"}
bind:value={optionValues[optDoc.id]}
/>
{/if}
{/each}
</Form>
</div>
{/if}
</ListGroupItem>
{/each}
</ListGroup>
</Card>

View file

@ -0,0 +1,147 @@
<!--
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,
Card,
CardBody,
CardHeader,
FormGroup,
Icon,
Input,
Label,
} from "@sveltestrap/sveltestrap";
import type { HappydnsCheckPlan, HappydnsCheckPlanWritable } from "$lib/api-base/types.gen";
import { t } from "$lib/translations";
import { toasts } from "$lib/stores/toasts";
import type { CheckerScope } from "$lib/api/checkers";
import {
getScopedCheckPlans,
createScopedCheckPlan,
updateScopedCheckPlan,
} from "$lib/api/checkers";
interface Props {
scope: CheckerScope;
checkerId: string;
plan: HappydnsCheckPlan | HappydnsCheckPlanWritable;
}
let { scope, checkerId, plan = $bindable() }: Props = $props();
let existingPlanId = $state<string | undefined>(undefined);
let saving = $state(false);
let schedulesPromise = $derived(getScopedCheckPlans(scope, checkerId));
$effect(() => {
schedulesPromise.then((schedules: HappydnsCheckPlan[]) => {
if (schedules.length > 0) {
const s = schedules[0];
existingPlanId = s.id;
plan = {
enabled: s.enabled ?? {},
interval: s.interval ?? 3600,
};
}
});
});
async function save() {
saving = true;
try {
const planData: HappydnsCheckPlanWritable = {
enabled: plan.enabled,
interval: plan.interval,
};
if (existingPlanId) {
await updateScopedCheckPlan(scope, checkerId, existingPlanId, planData);
} else {
const created = await createScopedCheckPlan(scope, checkerId, planData);
existingPlanId = created.id;
}
toasts.addToast({
message: $t("checkers.schedule.saved"),
type: "success",
timeout: 5000,
});
} catch (error) {
toasts.addErrorToast({
message: $t("checkers.schedule.save-failed") + ": " + String(error),
timeout: 10000,
});
} finally {
saving = false;
}
}
function intervalHours(): number {
return Math.round((plan.interval ?? 3600) / 3600);
}
function setIntervalHours(hours: number) {
plan.interval = Math.max(3600, hours * 3600);
}
</script>
<Card class="mb-3">
<CardHeader class="d-flex align-items-center justify-content-between">
<strong>{$t("checkers.schedule.card-title")}</strong>
<Button form="form-schedule" color="success" size="sm" onclick={save} disabled={saving}>
{#if saving}
<span class="spinner-border spinner-border-sm me-1"></span>
{:else}
<Icon name="check-circle"></Icon>
{/if}
{$t("checkers.schedule.save")}
</Button>
</CardHeader>
<CardBody>
<form id="form-schedule">
<FormGroup>
<Label>{$t("checkers.schedule.interval-label")}</Label>
<div class="d-flex align-items-center gap-2">
<Input
type="number"
min={1}
value={intervalHours()}
oninput={(e: Event) =>
setIntervalHours(parseInt((e.target as HTMLInputElement).value) || 1)}
style="width: 100px"
/>
<span>{$t("checkers.schedule.hours")}</span>
</div>
<small class="text-muted">{$t("checkers.schedule.interval-hint")}</small>
</FormGroup>
</form>
{#if !existingPlanId}
<Alert color="info" class="mb-0 mt-2">
<Icon name="info-circle" />
{$t("checkers.schedule.no-schedule-yet")}
</Alert>
{/if}
</CardBody>
</Card>

View file

@ -0,0 +1,81 @@
<!--
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 { t } from "$lib/translations";
import { checkers } from "$lib/stores/checkers";
let {
currentCheckId,
}: {
currentCheckId: string;
} = $props();
</script>
<div class="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}
<ul class="list-unstyled mb-0 flex-fill overflow-auto">
{#each Object.entries($checkers) as [checkerId, checkerInfo]}
<li>
<a
href="/checkers/{checkerId}"
class="checker-item d-flex align-items-center gap-2 py-2 px-2 rounded text-decoration-none"
class:active={checkerId === currentCheckId}
>
<span class="text-truncate">
{checkerInfo.name || checkerId}
</span>
</a>
</li>
{/each}
</ul>
{:else}
<div class="d-flex gap-2 align-items-center justify-content-center my-3 text-muted">
<Spinner size="sm" color="primary" />
</div>
{/if}
</div>
<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>

View 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 { Badge, Table } from "@sveltestrap/sveltestrap";
import type { HappydnsCheckerAvailability, HappydnsCheckerDefinition } from "$lib/api-base/types.gen";
import { t } from "$lib/translations";
let {
checkers,
basePath = "/checkers",
}: {
checkers: [string, HappydnsCheckerDefinition][];
basePath?: string;
} = $props();
function availabilityBadges(availability: HappydnsCheckerAvailability | undefined): { label: string; color: string }[] {
if (!availability) return [];
const badges = [];
if (availability.applyToDomain) badges.push({ label: $t("checkers.availability.domain-level"), color: "success" });
if (availability.applyToZone) badges.push({ label: $t("checkers.availability.zone-level"), color: "info" });
if (availability.applyToService) badges.push({ label: $t("checkers.availability.service-level"), color: "primary" });
return badges;
}
</script>
<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>
{#each checkers as [checkerId, checkerInfo]}
{@const badges = availabilityBadges(checkerInfo.availability)}
<tr>
<td><strong>{checkerInfo.name || checkerId}</strong></td>
<td>
{#if badges.length > 0}
<div class="d-flex flex-wrap gap-1">
{#each badges as badge}
<Badge color={badge.color}>
{badge.label}
</Badge>
{/each}
</div>
{:else}
<Badge color="secondary">{$t("checkers.availability.general")}</Badge>
{/if}
</td>
<td>
<a href="{basePath}/{checkerId}" class="btn btn-sm btn-primary">
{$t("checkers.table.manage")}
</a>
</td>
</tr>
{/each}
</tbody>
</Table>
</div>

View file

@ -0,0 +1,103 @@
<!--
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 { Icon } from "@sveltestrap/sveltestrap";
import type { CheckerScope } from "$lib/api/checkers";
import { t } from "$lib/translations";
import type { Domain } from "$lib/model/domain";
import { thisZone } from "$lib/stores/thiszone";
import DomainCheckerSidebar from "./DomainCheckerSidebar.svelte";
import ExecutionSidebarContent from "./ExecutionSidebarContent.svelte";
interface Props {
domain: Domain;
checksBase: string;
backHref: string;
serviceContext?: {
zoneId: string;
subdomain: string;
serviceid: string;
};
}
let { domain, checksBase, backHref, serviceContext }: Props = $props();
let scope: CheckerScope = $derived(
serviceContext
? { domainId: domain.id, zoneId: serviceContext.zoneId, subdomain: serviceContext.subdomain, serviceId: serviceContext.serviceid }
: { domainId: domain.id },
);
let serviceType = $derived.by(() => {
if (!serviceContext) return undefined;
const svcs =
$thisZone?.services[serviceContext.subdomain == "@" ? "" : serviceContext.subdomain];
const svc = svcs?.find((s) => s._id === serviceContext.serviceid);
return svc?._svctype;
});
</script>
{#if page.params.execId}
<a
href={`${checksBase}/${encodeURIComponent(page.params.checkerId!)}/executions`}
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>
<ExecutionSidebarContent
{domain}
checkerId={page.params.checkerId!}
execId={page.params.execId}
{checksBase}
{scope}
/>
{:else if page.params.checkerId}
<a
href={checksBase}
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>
<DomainCheckerSidebar
class="mt-3"
domainName={domain.domain}
currentCheckerName={page.params.checkerId}
{checksBase}
scope={serviceContext ? "service" : "domain"}
{serviceType}
/>
<div class="flex-fill"></div>
{:else}
<a
href={backHref}
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")}
</a>
{/if}

View file

@ -0,0 +1,152 @@
<!--
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;
checksBase?: string;
scope?: "domain" | "service";
serviceType?: string;
}
let {
class: className = "",
domainName,
currentCheckerName,
checksBase: checksBaseProp = undefined,
scope = "domain",
serviceType = undefined,
}: Props = $props();
let checksBase = $derived(
checksBaseProp ?? `/domains/${encodeURIComponent(domainName)}/checks`,
);
let onResults = $derived(page.route.id?.includes("/executions") === true && !page.params.execId);
function isCheckVisible(checkerInfo: NonNullable<typeof $checkers>[string]): boolean {
const avail = checkerInfo.availability;
if (!avail) return true;
if (scope === "domain" && !avail.applyToDomain && !avail.applyToZone) return false;
if (scope === "service") {
if (!avail.applyToService) return false;
if (avail.limitToServices && avail.limitToServices.length > 0) {
if (!serviceType || !avail.limitToServices.includes(serviceType)) return false;
}
}
return true;
}
</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]}
{#if isCheckVisible(checkerInfo)}
{@const isActive = checkerName === currentCheckerName}
<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="{checksBase}/{encodeURIComponent(checkerName)}{onResults
? '/executions'
: ''}"
class="text-truncate flex-fill text-decoration-none {isActive
? 'text-primary'
: 'text-muted'}"
>
{checkerInfo.name || checkerName}
</a>
{#if onResults}
<a
href="{checksBase}/{encodeURIComponent(checkerName)}"
class="checker-action text-muted"
title="Configure"
>
<Icon name="gear" />
</a>
{:else}
<a
href="{checksBase}/{encodeURIComponent(checkerName)}/executions"
class="checker-action text-muted"
title="Results"
>
<Icon name="bar-chart-fill" />
</a>
{/if}
</div>
</li>
{/if}
{/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>

View file

@ -0,0 +1,102 @@
<!--
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 { onDestroy } from "svelte";
import { Alert, Card, Container, Icon } from "@sveltestrap/sveltestrap";
import { t } from "$lib/translations";
import type { CheckerScope } from "$lib/api/checkers";
import {
getScopedExecution,
getScopedExecutionObservations,
getCheckStatus,
} from "$lib/api/checkers";
import { currentExecution, currentCheckInfo, currentObservations } from "$lib/stores/checkers";
import ObservationReportCard from "./ObservationReportCard.svelte";
interface Props {
scope: CheckerScope;
checkerId: string;
execId: string;
}
let { scope, checkerId, execId }: Props = $props();
let checkerName = $state<string>("");
let loading = $state(true);
let error = $state<string | undefined>(undefined);
$effect(() => {
loading = true;
error = undefined;
Promise.all([
getScopedExecution(scope, checkerId, execId),
getCheckStatus(checkerId),
getScopedExecutionObservations(scope, checkerId, execId),
]).then(
([execution, checkerInfo, observations]) => {
currentExecution.set(execution);
currentCheckInfo.set(checkerInfo);
currentObservations.set(observations);
checkerName = checkerInfo.name ?? checkerId;
loading = false;
},
(err) => {
error = err.message;
loading = false;
},
);
});
onDestroy(() => {
currentExecution.set(undefined);
currentCheckInfo.set(undefined);
currentObservations.set(undefined);
});
</script>
<svelte:head>
<title>{$t("checkers.execution.title")} - {checkerName || checkerId} - happyDomain</title>
</svelte:head>
{#if loading}
<Container class="flex-fill d-flex align-items-start mt-5">
<Card body>
<p class="text-center mb-0">
<span class="spinner-border spinner-border-sm me-2"></span>
{$t("checkers.result.loading")}
</p>
</Card>
</Container>
{:else if error}
<Container class="flex-fill d-flex align-items-start mt-5">
<Alert class="flex-fill" color="danger">
<Icon name="exclamation-triangle-fill" />
{$t("checkers.result.error-loading", { error })}
</Alert>
</Container>
{:else if $currentObservations}
<ObservationReportCard observations={$currentObservations} />
{/if}

View file

@ -0,0 +1,275 @@
<!--
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, Icon, Table } from "@sveltestrap/sveltestrap";
import { t } from "$lib/translations";
import { toasts } from "$lib/stores/toasts";
import type { HappydnsExecution } from "$lib/api-base/types.gen";
import type { CheckerScope } from "$lib/api/checkers";
import { listScopedExecutions, getCheckStatus, deleteScopedExecution, deleteAllScopedExecutions } from "$lib/api/checkers";
import {
getExecutionStatusColor,
getExecutionStatusI18nKey,
getStatusColor,
getStatusI18nKey,
formatCheckDate,
} from "$lib/utils";
import PageTitle from "$lib/components/PageTitle.svelte";
import RunCheckModal from "$lib/components/modals/RunCheckModal.svelte";
interface Props {
scope: CheckerScope;
checksBase: string;
checkerId: string;
domainName: string;
}
let { scope, checksBase, checkerId, domainName }: Props = $props();
let resolvedName = $state<string>("");
let executions = $state<HappydnsExecution[]>([]);
let executionsPromise: Promise<HappydnsExecution[]> = $derived(loadExecutions());
let runCheckModal = $state<RunCheckModal>();
$effect(() => {
getCheckStatus(checkerId).then((s) => {
resolvedName = s.name ?? checkerId;
});
});
async function loadExecutions() {
try {
executions = await listScopedExecutions(scope, checkerId, { includePlanned: true });
return executions;
} catch (error) {
toasts.addErrorToast({
message: $t("checkers.executions.error-loading", { error: String(error) }),
timeout: 10000,
});
throw error;
}
}
async function deleteExecution(executionId: string) {
try {
await deleteScopedExecution(scope, checkerId, executionId);
executions = executions.filter((e) => e.id !== executionId);
} catch (error) {
toasts.addErrorToast({
message: $t("checkers.executions.error-deleting", { error: String(error) }),
timeout: 10000,
});
}
}
async function deleteAllExecutions() {
if (!confirm($t("checkers.executions.delete-all-confirm"))) {
return;
}
try {
await deleteAllScopedExecutions(scope, checkerId);
executions = executions.filter((e) => !e.id);
toasts.addToast({
message: $t("checkers.executions.deleted-all"),
type: "success",
});
} catch (error) {
toasts.addErrorToast({
message: $t("checkers.executions.error-deleting", { error: String(error) }),
timeout: 10000,
});
}
}
function pollForNewExecution() {
const previousCount = executions.length;
let attempts = 0;
const maxAttempts = 10;
const intervalMs = 3000;
const timer = setInterval(async () => {
attempts++;
try {
await loadExecutions();
if (executions.length > previousCount || attempts >= maxAttempts) {
clearInterval(timer);
}
} catch {
clearInterval(timer);
}
}, intervalMs);
}
</script>
<svelte:head>
<title>
{$t("checkers.executions.title", { count: executions.length })} - {resolvedName ||
checkerId} - happyDomain
</title>
</svelte:head>
<div class="flex-fill mt-1 mb-5">
<PageTitle
title={$t("checkers.executions.title", { count: executions.length })}
subtitle={resolvedName}
domain={domainName}
>
<div class="d-flex gap-2">
<Button color="dark" href="{checksBase}/{checkerId}">
<Icon name="gear-fill"></Icon>
{$t("checkers.executions.configure")}
</Button>
<Button
color="primary"
onclick={() => runCheckModal?.open(checkerId, resolvedName || checkerId)}
>
<Icon name="play-fill"></Icon>
{$t("checkers.executions.run-check-now")}
</Button>
<Button
color="danger"
outline
disabled={executions.filter((e) => e.id).length === 0}
onclick={deleteAllExecutions}
>
<Icon name="trash-fill"></Icon>
{$t("checkers.executions.delete-all")}
</Button>
</div>
</PageTitle>
{#await executionsPromise}
<Card body>
<p class="text-center mb-0">
<span class="spinner-border spinner-border-sm me-2"></span>
{$t("checkers.executions.loading")}
</p>
</Card>
{:then _executions}
{#if executions.length === 0}
<Alert color="info">
<Icon name="info-circle" />
{$t("checkers.executions.no-results")}
</Alert>
{:else}
<Table hover responsive>
<thead>
<tr>
<th>{$t("checkers.executions.table.executed-at")}</th>
<th>{$t("checkers.executions.table.status")}</th>
<th>{$t("checkers.executions.table.duration")}</th>
<th>{$t("checkers.executions.table.actions")}</th>
</tr>
</thead>
<tbody>
{#each executions.toSorted((a, b) => {
const aTime = a.startedAt ? new Date(a.startedAt).getTime() : Infinity;
const bTime = b.startedAt ? new Date(b.startedAt).getTime() : Infinity;
return bTime - aTime;
}) as execution}
{@const isPending = !execution.id}
{@const isRunning =
execution.id && execution.startedAt && !execution.endedAt}
{@const status = execution.status}
{@const duration =
execution.startedAt && execution.endedAt
? Math.round(
(new Date(execution.endedAt).getTime() -
new Date(execution.startedAt).getTime()) /
1000,
)
: null}
<tr>
<td>
{#if !execution.startedAt}
<span class="text-muted fst-italic">
{$t("checkers.status.planned")}
</span>
{:else if isPending}
<span class="text-muted fst-italic">
{formatCheckDate(execution.startedAt)}
</span>
{:else}
{formatCheckDate(execution.startedAt)}
{/if}
</td>
<td>
{#if isPending}
<Badge color="secondary">{$t("checkers.status.planned")}</Badge>
{:else if status == 2 && execution.result}
<Badge color={getStatusColor(execution.result.status)}>
{$t(getStatusI18nKey(execution.result.status))}
</Badge>
{:else}
<Badge color={getExecutionStatusColor(status)}>
{$t(getExecutionStatusI18nKey(status))}
</Badge>
{/if}
</td>
<td>
{#if isRunning}
<span class="text-muted fst-italic">
{$t("checkers.status.running")}
</span>
{:else if duration !== null}
{duration}s
{:else}
-
{/if}
</td>
<td>
<div class="d-flex gap-1">
<a
href="{checksBase}/{checkerId}/executions/{execution.id}"
class="btn btn-sm btn-outline-primary"
class:disabled={!execution.id && !isRunning}
>
{$t("checkers.executions.view")}
</a>
<Button
color="danger"
size="sm"
outline
disabled={!!isPending || !!isRunning}
onclick={() =>
execution.id && deleteExecution(execution.id)}
>
<Icon name="trash" />
</Button>
</div>
</td>
</tr>
{/each}
</tbody>
</Table>
{/if}
{/await}
</div>
<RunCheckModal
{scope}
onCheckTriggered={() => pollForNewExecution()}
bind:this={runCheckModal}
/>

View file

@ -0,0 +1,63 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-2026 happyDomain
Authors: Pierre-Olivier Mercier, et al.
This program is offered under a commercial and under the AGPL license.
For commercial licensing, contact us at <contact@happydomain.org>.
For AGPL licensing:
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import { Card, CardBody, CardHeader, Table } from "@sveltestrap/sveltestrap";
import { t } from "$lib/translations";
import type { HappydnsCheckEvaluation } from "$lib/api-base/types.gen";
interface Props {
evaluation: HappydnsCheckEvaluation;
}
let { evaluation }: Props = $props();
</script>
<Card>
<CardHeader>
<strong>{$t("checkers.detail.check-rules")}</strong>
</CardHeader>
<CardBody>
{#if evaluation.states && evaluation.states.length > 0}
<Table size="sm" borderless>
<thead>
<tr>
<th>{$t("checkers.result.field.rule")}</th>
<th>{$t("checkers.result.field.message")}</th>
</tr>
</thead>
<tbody>
{#each evaluation.states as state}
<tr>
<td><code>{state.code ?? ""}</code></td>
<td>{state.message ?? ""}</td>
</tr>
{/each}
</tbody>
</Table>
{:else}
<pre class="mb-0"><code>{JSON.stringify(evaluation, null, 2)}</code></pre>
{/if}
</CardBody>
</Card>

View file

@ -0,0 +1,79 @@
<!--
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, Icon } from "@sveltestrap/sveltestrap";
import { t } from "$lib/translations";
import type { CheckerScope } from "$lib/api/checkers";
import { getCheckStatus, getScopedExecutionResults } from "$lib/api/checkers";
import PageTitle from "$lib/components/PageTitle.svelte";
import ExecutionResultsCard from "./ExecutionResultsCard.svelte";
interface Props {
scope: CheckerScope;
checkerId: string;
execId: string;
domainName: string;
}
let { scope, checkerId, execId, domainName }: Props = $props();
let resultsPromise = $derived(getScopedExecutionResults(scope, checkerId, execId));
let checkerName = $state<string>("");
$effect(() => {
getCheckStatus(checkerId).then((s) => {
checkerName = s.name ?? checkerId;
});
});
</script>
<svelte:head>
<title>{$t("checkers.detail.check-rules")} - {checkerName || checkerId} - happyDomain</title>
</svelte:head>
<div class="flex-fill mt-1 mb-5">
<PageTitle title={$t("checkers.detail.check-rules")} subtitle={checkerName} domain={domainName} />
{#await resultsPromise}
<p class="text-center">
<span class="spinner-border spinner-border-sm me-2"></span>
{$t("checkers.result.loading")}
</p>
{:then evaluation}
{#if evaluation}
<ExecutionResultsCard {evaluation} />
{:else}
<Alert color="info">
<Icon name="info-circle" />
{$t("checkers.result.no-results")}
</Alert>
{/if}
{:catch error}
<Alert color="danger">
<Icon name="exclamation-triangle-fill" />
{$t("checkers.result.error-loading", { error: error.message })}
</Alert>
{/await}
</div>

View file

@ -0,0 +1,253 @@
<!--
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,
ButtonGroup,
Card,
CardHeader,
Icon,
Spinner,
Table,
} from "@sveltestrap/sveltestrap";
import { navigate } from "$lib/stores/config";
import { currentExecution, currentCheckInfo, currentObservations } from "$lib/stores/checkers";
import { toasts } from "$lib/stores/toasts";
import type { CheckerScope } from "$lib/api/checkers";
import {
triggerScopedCheck,
deleteScopedExecution,
} from "$lib/api/checkers";
import {
getExecutionStatusColor,
getExecutionStatusI18nKey,
getStatusColor,
getStatusI18nKey,
formatCheckDate,
downloadBlob,
} from "$lib/utils";
import { t } from "$lib/translations";
import type { Domain } from "$lib/model/domain";
interface Props {
domain: Domain;
checkerId: string;
execId: string;
checksBase: string;
scope: CheckerScope;
}
let { domain, checkerId, execId, checksBase, scope }: Props = $props();
let isRelaunching = $state(false);
async function handleRelaunch() {
isRelaunching = true;
try {
const execution = await triggerScopedCheck(scope, checkerId);
toasts.addToast({
message: $t("checkers.run-check.triggered-success", { id: execution.id ?? "" }),
type: "success",
timeout: 5000,
});
if (execution.id) {
navigate(
`${checksBase}/${encodeURIComponent(checkerId)}/executions/${execution.id}`,
);
}
} catch (error: any) {
toasts.addErrorToast({
message: error.message || $t("checkers.result.relaunch-failed"),
});
} finally {
isRelaunching = false;
}
}
let isDeleting = $state(false);
async function handleDelete() {
if (!$currentExecution?.id) return;
isDeleting = true;
try {
await deleteScopedExecution(scope, checkerId, $currentExecution.id);
navigate(`${checksBase}/${encodeURIComponent(checkerId)}/executions`);
} catch (error: any) {
toasts.addErrorToast({
message:
error.message ||
$t("checkers.executions.error-deleting", { error: String(error) }),
});
} finally {
isDeleting = false;
}
}
function downloadJSON() {
if (!$currentObservations?.data) return;
downloadBlob(
JSON.stringify($currentObservations.data, null, 2),
`${checkerId}-${execId}.json`,
"application/json",
);
}
</script>
{#if $currentExecution}
<Card class="mt-3">
<CardHeader class="px-2">
<div class="d-flex justify-content-between align-items-center">
<strong class="text-truncate">{$currentCheckInfo?.name || checkerId}</strong>
<Badge
color={getExecutionStatusColor($currentExecution.status)}
class="flex-shrink-0"
>
{$t(getExecutionStatusI18nKey($currentExecution.status))}
</Badge>
</div>
</CardHeader>
<div class="overflow-x-auto rounded-2">
<Table borderless size="sm" class="mb-0">
<tbody>
<tr>
<th style="width: 80px; white-space: nowrap">
{$t("checkers.result.field.executed-at")}
</th>
<td>{formatCheckDate($currentExecution.startedAt)}</td>
</tr>
{#if $currentExecution.endedAt}
<tr>
<th>{$t("checkers.execution.field.ended-at")}</th>
<td>{formatCheckDate($currentExecution.endedAt)}</td>
</tr>
{/if}
<tr>
<th>{$t("checkers.result.field.status")}</th>
<td class="d-flex gap-2 align-items-center">
<Badge color={getStatusColor($currentExecution.result?.status)}>
{$t(getStatusI18nKey($currentExecution.result?.status))}
</Badge>
<a
href="{checksBase}/{encodeURIComponent(
checkerId,
)}/executions/{encodeURIComponent(execId)}/rules"
>
{$t("checkers.detail.check-rules")}
</a>
</td>
</tr>
{#if $currentExecution.result?.message}
<tr>
<th>{$t("checkers.result.field.status-message")}</th>
<td class="text-truncate" style="max-width: 0">
{$currentExecution.result.message}
</td>
</tr>
{/if}
{#if $currentExecution.error}
<tr>
<th>{$t("checkers.result.field.error")}</th>
<td class="text-danger text-truncate" style="max-width: 0">
{$currentExecution.error}
</td>
</tr>
{/if}
{#if $currentExecution.trigger}
<tr>
<th>{$t("checkers.execution.field.trigger")}</th>
<td><code>{JSON.stringify($currentExecution.trigger)}</code></td>
</tr>
{/if}
</tbody>
</Table>
</div>
</Card>
<div class="my-3 flex-fill"></div>
<!-- TODO: Metrics and HTML report not yet implemented -->
<ButtonGroup class="w-100 mb-2">
<Button size="sm" color="secondary" outline disabled title="Not yet available">
<Icon name="graph-up"></Icon>
{$t("checkers.result.view-metrics")}
</Button>
<Button size="sm" color="secondary" outline disabled title="Not yet available">
<Icon name="file-earmark-richtext"></Icon>
{$t("checkers.result.view-html")}
</Button>
<Button size="sm" color="secondary" outline active>
<Icon name="braces"></Icon>
{$t("checkers.result.view-json")}
</Button>
</ButtonGroup>
<ButtonGroup class="w-100">
<!-- TODO: HTML report download not yet available -->
<Button size="sm" color="outline-secondary" disabled title="Not yet available">
<Icon name="download"></Icon>
{$t("checkers.result.download-html")}
</Button>
<Button
size="sm"
color="outline-secondary"
onclick={downloadJSON}
disabled={!$currentObservations?.data}
>
<Icon name="download"></Icon>
{$t("checkers.result.download-json")}
</Button>
</ButtonGroup>
{:else}
<div class="flex-fill"></div>
{/if}
<div class="mt-2 d-flex gap-2">
<Button
class="flex-fill"
color="primary"
outline
onclick={handleRelaunch}
disabled={!$currentExecution || isRelaunching}
>
{#if isRelaunching}
<Spinner size="sm" />
{:else}
<Icon name="arrow-repeat"></Icon>
{/if}
{$t("checkers.result.relaunch")}
</Button>
<Button
color="danger"
outline
onclick={handleDelete}
disabled={!$currentExecution?.id || isDeleting}
>
{#if isDeleting}
<Spinner size="sm" />
{:else}
<Icon name="trash"></Icon>
{/if}
</Button>
</div>

View file

@ -0,0 +1,43 @@
<!--
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 type { HappydnsObservationSnapshot } from "$lib/api-base/types.gen";
interface Props {
observations: HappydnsObservationSnapshot;
}
let { observations }: Props = $props();
</script>
{#if observations?.data && Object.keys(observations.data).length > 0}
<div
class="flex-fill"
style="overflow: auto; padding: var(--bs-card-spacer-y) var(--bs-card-spacer-x)"
>
<pre class="mb-0" style="width: 0; min-width: 100%"><code
>{JSON.stringify(observations.data, null, 2)}</code
></pre>
</div>
{/if}

View file

@ -31,6 +31,7 @@
import TableInput from "$lib/components/inputs/table.svelte";
import type { Field } from "$lib/model/custom_form.svelte";
import type { ServiceInfos } from "$lib/model/service_specs.svelte";
import type { HappydnsCheckerOptionDocumentation } from "$lib/api-base/types.gen";
const dispatch = createEventDispatcher();
@ -41,7 +42,7 @@
noDecorate?: boolean;
readonly?: boolean;
showDescription?: boolean;
specs?: Field | ServiceInfos;
specs?: Field | ServiceInfos | HappydnsCheckerOptionDocumentation;
type: string;
value: any;
}

View file

@ -0,0 +1,326 @@
<!--
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,
Input,
Label,
Modal,
ModalBody,
ModalFooter,
ModalHeader,
Spinner,
} from "@sveltestrap/sveltestrap";
import { getCheckStatus, getScopedCheckOptions, triggerScopedCheck } from "$lib/api/checkers";
import type { CheckerScope } from "$lib/api/checkers";
import { collectAllOptionDocs } from "$lib/utils/checkers";
import type {
HappydnsCheckerDefinition,
HappydnsCheckerOptionDocumentation,
HappydnsCheckerOptions,
HappydnsCheckerOptionsPositional,
HappydnsCheckerRunRequest,
} from "$lib/api-base/types.gen";
import Resource from "$lib/components/inputs/Resource.svelte";
import { toasts } from "$lib/stores/toasts";
import { t } from "$lib/translations";
interface Props {
scope: CheckerScope;
onCheckTriggered?: (execution_id: string) => void;
}
let { scope, onCheckTriggered }: Props = $props();
let isOpen = $state(false);
let checkName = $state<string>("");
let checkDisplayName = $state<string>("");
let checkStatusPromise = $state<Promise<any> | null>(null);
let scopedOptionsPromise = $state<Promise<any> | null>(null);
let resolvedStatus = $state<HappydnsCheckerDefinition | null>(null);
let runOptions = $state<Record<string, any>>({});
let scopedDefaults = $state<Record<string, any>>({});
let triggering = $state(false);
let showAdvanced = $state(false);
let activeRules = $state<Record<number, boolean>>({});
const toggle = () => (isOpen = !isOpen);
export function open(name: string, displayName: string) {
checkName = name;
checkDisplayName = displayName;
runOptions = {};
scopedDefaults = {};
showAdvanced = false;
activeRules = {};
resolvedStatus = null;
checkStatusPromise = getCheckStatus(name);
scopedOptionsPromise = getScopedCheckOptions(scope, name);
isOpen = true;
Promise.all([checkStatusPromise, scopedOptionsPromise]).then(
([status, options]: [
HappydnsCheckerDefinition,
HappydnsCheckerOptionsPositional[],
]) => {
resolvedStatus = status;
scopedDefaults = Object.assign({}, ...(options || []).map((p) => p.options || {}));
// For select fields (choices), set the value directly since placeholders don't work on <select>.
const allOpts = collectAllOptionDocs(status);
for (const opt of allOpts) {
if (opt.id && opt.choices?.length && opt.id in scopedDefaults) {
runOptions[opt.id] = scopedDefaults[opt.id];
}
}
},
);
}
function getActiveOptionIds(): Set<string> {
const ids = new Set<string>();
if (!resolvedStatus) return ids;
const addOpts = (opts: HappydnsCheckerOptionDocumentation[] | undefined) =>
opts?.forEach((o) => o.id && ids.add(o.id));
addOpts(resolvedStatus.options?.runOpts);
addOpts(resolvedStatus.options?.adminOpts);
addOpts(resolvedStatus.options?.userOpts);
addOpts(resolvedStatus.options?.domainOpts);
resolvedStatus.rules?.forEach((rule, idx) => {
if (activeRules[idx] !== false) {
if (rule.options) {
addOpts(rule.options.runOpts);
addOpts(rule.options.adminOpts);
addOpts(rule.options.userOpts);
addOpts(rule.options.domainOpts);
}
}
});
return ids;
}
function specsWithPlaceholder(
optDoc: HappydnsCheckerOptionDocumentation,
): HappydnsCheckerOptionDocumentation {
if (optDoc.id && optDoc.id in scopedDefaults && scopedDefaults[optDoc.id] != null) {
return { ...optDoc, placeholder: String(scopedDefaults[optDoc.id]) };
}
return optDoc;
}
async function handleRunCheck() {
triggering = true;
try {
const activeIds = getActiveOptionIds();
const filteredOptions: HappydnsCheckerOptions = {};
for (const [k, v] of Object.entries(runOptions)) {
if (!resolvedStatus || activeIds.has(k)) filteredOptions[k] = v;
}
// Build enabledRules map from activeRules (only if some rules are toggled off).
const rules = resolvedStatus?.rules ?? [];
let enabledRules: Record<string, boolean> | undefined;
if (rules.length > 0) {
const hasDisabled = rules.some((_r, idx) => activeRules[idx] === false);
if (hasDisabled) {
enabledRules = {};
for (let i = 0; i < rules.length; i++) {
const name = rules[i].name;
if (name) {
enabledRules[name] = activeRules[i] !== false;
}
}
}
}
const request: HappydnsCheckerRunRequest = {
options: filteredOptions,
...(enabledRules ? { enabledRules } : {}),
};
const result = await triggerScopedCheck(scope, checkName, request);
toasts.addToast({
message: $t("checkers.run-check.triggered-success", { id: result.id ?? "" }),
type: "success",
timeout: 5000,
});
isOpen = false;
if (onCheckTriggered && result.id) {
onCheckTriggered(result.id);
}
} 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 && scopedOptionsPromise}
{#await Promise.all([checkStatusPromise, scopedOptionsPromise])}
<div class="text-center py-3">
<Spinner />
<p class="mt-2">{$t("checkers.run-check.loading-options")}</p>
</div>
{:then [status, _domainOpts]}
{@const rules = status.rules || []}
{@const activeRulesForOpts = rules.map(
(r: HappydnsCheckerDefinition | null, i: number) =>
activeRules[i] !== false ? r : null,
)}
{@const runOpts = [
...(status.options?.runOpts || []),
...activeRulesForOpts.flatMap((r: any) => r?.options?.runOpts || []),
]}
{@const otherOpts = [
...(status.options?.adminOpts || []),
...(status.options?.userOpts || []),
...(status.options?.domainOpts || []),
...activeRulesForOpts.flatMap((r: any) => [
...(r?.options?.adminOpts || []),
...(r?.options?.userOpts || []),
...(r?.options?.domainOpts || []),
]),
].filter((o: any) => o.id)}
<Form
id="run-check-modal"
onsubmit={(e: Event) => {
e.preventDefault();
handleRunCheck();
}}
>
{#if runOpts.length > 0 || otherOpts.length > 0}
<p>
{#if runOpts.length > 0}
{$t("checkers.run-check.configure-info")}
{:else}
<Icon name="info-circle"></Icon>
{$t("checkers.run-check.no-run-options")}
{/if}
</p>
{#each runOpts as optDoc}
{#if optDoc.id}
{@const optName = optDoc.id}
<FormGroup>
<Resource
edit={true}
index={optName}
specs={specsWithPlaceholder(optDoc)}
type={optDoc.type || "string"}
readonly={!!optDoc.autoFill}
bind:value={runOptions[optName]}
/>
</FormGroup>
{/if}
{/each}
{#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 as any).id}
<FormGroup>
<Resource
edit={true}
index={optName}
specs={specsWithPlaceholder(optDoc)}
type={(optDoc as any).type || "string"}
readonly={!!(optDoc as any).autoFill}
bind:value={runOptions[optName]}
/>
</FormGroup>
{/each}
{/if}
{/if}
{:else}
<Alert color="info" class="mb-0">
<Icon name="info-circle"></Icon>
{$t("checkers.run-check.no-options")}
</Alert>
{/if}
{#if rules.length >= 1}
<hr />
<FormGroup>
<Label>{$t("checkers.run-check.rules")}</Label>
{#each rules as rule, idx}
{@const isActive = activeRules[idx] !== false}
<div class="form-check">
<Input
type="checkbox"
id="run-check-rule-{idx}"
label={rule.name ?? idx}
checked={isActive}
onchange={() => (activeRules[idx] = !isActive)}
/>
</div>
{/each}
</FormGroup>
{/if}
</Form>
{: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-check-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>

View file

@ -46,6 +46,7 @@ interface Params {
countdown?: string;
error?: string;
options?: string;
key?: string;
// add more parameters that are used here
}

View file

@ -124,11 +124,12 @@ export function downloadBlob(content: string, filename: string, mime: string) {
URL.revokeObjectURL(url);
}
export function formatCheckDate(date: string | undefined): string {
export function formatCheckDate(date: string | Date | undefined): string {
if (!date) return "";
try {
if (date instanceof Date) return date.toLocaleString();
return new Date(date).toLocaleString();
} catch {
return date;
return String(date);
}
}

View 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();
};

View file

@ -0,0 +1,99 @@
<!--
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,
Col,
Container,
Icon,
Input,
InputGroup,
InputGroupText,
Row,
} from "@sveltestrap/sveltestrap";
import PageTitle from "$lib/components/PageTitle.svelte";
import CheckersAvailabilityTable from "$lib/components/checkers/CheckersAvailabilityTable.svelte";
import { t } from "$lib/translations";
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>
<title>{$t("checkers.title")} - happyDomain</title>
</svelte:head>
<Container class="flex-fill my-5">
<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 mt-3">
<Col md={8} lg={6}>
<InputGroup>
<InputGroupText>
<Icon name="search"></Icon>
</InputGroupText>
<Input
type="text"
placeholder={$t("checkers.search-placeholder")}
bind:value={searchQuery}
/>
</InputGroup>
</Col>
</Row>
{#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>
{:else}
{#if Object.keys($checkers).length == 0}
<p class="text-center text-muted py-4">
{$t("checkers.no-checkers")}
</p>
{:else}
<CheckersAvailabilityTable
checkers={filteredCheckers}
basePath="/checkers"
/>
{/if}
{/if}
</Container>

View 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 checkerId = $derived(page.params.checkerId!);
</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={checkerId} />
</Col>
<Col sm={8} md={9} class="d-flex flex-column">
{@render children?.()}
</Col>
</Row>
</Container>

View file

@ -0,0 +1,239 @@
<!--
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,
Card,
CardBody,
CardHeader,
Col,
Icon,
Row,
} from "@sveltestrap/sveltestrap";
import { page } from "$app/state";
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/checkers";
import type { HappydnsCheckerAvailability, HappydnsCheckerOptionDocumentation, HappydnsCheckerOptionsPositional } from "$lib/api-base/types.gen";
import CheckerRulesCard from "$lib/components/checkers/CheckerRulesCard.svelte";
import CheckerOptionsPanel from "$lib/components/checkers/CheckerOptionsPanel.svelte";
function getAvailBadges(availability: HappydnsCheckerAvailability | undefined): { label: string; color: string }[] {
if (!availability) return [];
const badges = [];
if (availability.applyToDomain) badges.push({ label: $t("checkers.availability.domain-level"), color: "success" });
if (availability.applyToZone) badges.push({ label: $t("checkers.availability.zone-level"), color: "info" });
if (availability.applyToService) badges.push({ label: $t("checkers.availability.service-level"), color: "primary" });
return badges;
}
let checkerId = $derived(page.params.checkerId!);
let checkStatusPromise = $derived(getCheckStatus(checkerId));
let checkOptionsPromise = $derived(getCheckOptions(checkerId));
let optionValues = $state<Record<string, unknown>>({});
let inheritedValues = $state<Record<string, unknown>>({});
let resolvedStatus = $state<any>(null);
let saving = $state(false);
$effect(() => {
checkStatusPromise.then((status) => {
resolvedStatus = status;
});
});
$effect(() => {
checkOptionsPromise.then((positionals: HappydnsCheckerOptionsPositional[]) => {
const current = positionals.length > 0 ? (positionals[positionals.length - 1]?.options ?? {}) : {};
const inherited: Record<string, unknown> = {};
for (let i = 0; i < positionals.length - 1; i++) {
for (const [k, v] of Object.entries(positionals[i].options ?? {})) {
inherited[k] = v;
}
}
optionValues = { ...current };
inheritedValues = inherited;
});
});
async function saveOptions() {
saving = true;
try {
await updateCheckOptions(checkerId, optionValues);
checkOptionsPromise = getCheckOptions(checkerId);
toasts.addToast({
message: $t("checkers.messages.options-updated"),
type: "success",
timeout: 5000,
});
} catch (error) {
toasts.addErrorToast({
message: $t("checkers.messages.update-failed", { error: String(error) }),
timeout: 10000,
});
} finally {
saving = false;
}
}
async function cleanOrphanedOptions(allEditableOpts: HappydnsCheckerOptionDocumentation[]) {
const validOptIds = new Set(allEditableOpts.map((opt) => opt.id));
const cleanedOptions: Record<string, unknown> = {};
for (const [key, value] of Object.entries(optionValues)) {
if (validOptIds.has(key)) {
cleanedOptions[key] = value;
}
}
saving = true;
try {
await updateCheckOptions(checkerId, cleanedOptions);
checkOptionsPromise = getCheckOptions(checkerId);
toasts.addToast({
message: $t("checkers.messages.options-cleaned"),
type: "success",
timeout: 5000,
});
} catch (error) {
toasts.addErrorToast({
message: $t("checkers.messages.clean-failed", { error: String(error) }),
timeout: 10000,
});
} finally {
saving = false;
}
}
function getOrphanedOptions(
allEditableOpts: HappydnsCheckerOptionDocumentation[],
readOnlyGroups: { opts: HappydnsCheckerOptionDocumentation[] }[],
): string[] {
const validOptIds = new Set(allEditableOpts.map((opt) => opt.id));
for (const group of readOnlyGroups) {
for (const opt of group.opts) {
validOptIds.add(opt.id);
}
}
return Object.keys(optionValues).filter((key) => !validOptIds.has(key));
}
</script>
<svelte:head>
<title>{resolvedStatus?.name ?? checkerId} - {$t("checkers.title")} - happyDomain</title>
</svelte:head>
<div class="flex-fill mt-1 mb-5">
<PageTitle title={resolvedStatus?.name ?? checkerId}></PageTitle>
{#await checkStatusPromise}
<Card body>
<p class="text-center mb-0">
<span class="spinner-border spinner-border-sm me-2"></span>
{$t("checkers.loading-info")}
</p>
</Card>
{:then status}
{#if status}
{@const adminOpts = status.options?.adminOpts || []}
{@const userOpts = status.options?.userOpts || []}
{@const rulesAdminOpts = (status.rules || []).flatMap((r) => r.options?.adminOpts || [])}
{@const rulesUserOpts = (status.rules || []).flatMap((r) => r.options?.userOpts || [])}
{@const allEditableOpts = [...adminOpts, ...userOpts, ...rulesAdminOpts, ...rulesUserOpts]}
{@const editableGroups = [
{ label: $t("checkers.detail.admin-options"), opts: adminOpts },
{ label: $t("checkers.detail.configuration"), opts: userOpts },
]}
{@const readOnlyGroups = [
{ key: "domainOpts", label: $t("checkers.option-groups.domain-settings"), opts: status.options?.domainOpts || [] },
{ key: "serviceOpts", label: $t("checkers.option-groups.service-settings"), opts: status.options?.serviceOpts || [] },
{ key: "runOpts", label: $t("checkers.option-groups.checker-parameters"), opts: status.options?.runOpts || [] },
]}
{@const orphanedOpts = getOrphanedOptions(allEditableOpts, readOnlyGroups)}
<Row class="mb-4">
<Col md={6}>
<Card class="mb-3">
<CardHeader>
<strong>{$t("checkers.detail.checker-information")}</strong>
</CardHeader>
<CardBody>
<dl class="row mb-0">
<dt class="col-sm-4">{$t("checkers.detail.name")}</dt>
<dd class="col-sm-8">{status.name}</dd>
<dt class="col-sm-4">{$t("checkers.detail.availability")}</dt>
<dd class="col-sm-8">
{#each getAvailBadges(status.availability) as badge}
<Badge color={badge.color}>{badge.label}</Badge>
{:else}
<Badge color="secondary">
{$t("checkers.availability.general")}
</Badge>
{/each}
</dd>
</dl>
</CardBody>
</Card>
{#if status.rules && status.rules.length > 0}
<CheckerRulesCard
rules={status.rules}
bind:optionValues
{inheritedValues}
{saving}
onsave={saveOptions}
/>
{/if}
</Col>
<Col md={6}>
<CheckerOptionsPanel
{checkOptionsPromise}
{editableGroups}
{readOnlyGroups}
bind:optionValues
{inheritedValues}
{saving}
onsave={saveOptions}
{orphanedOpts}
onclean={() => cleanOrphanedOptions(allEditableOpts)}
/>
</Col>
</Row>
{:else}
<Alert color="danger">
<Icon name="exclamation-triangle-fill"></Icon>
{$t("checkers.checker-info-not-found")}
</Alert>
{/if}
{:catch error}
<Alert color="danger">
<Icon name="exclamation-triangle-fill"></Icon>
{$t("checkers.error-loading-checker", { error: error.message })}
</Alert>
{/await}
</div>

View file

@ -29,6 +29,7 @@
import { Button, Col, Container, Icon, Row, Spinner } from "@sveltestrap/sveltestrap";
import { deleteDomain as APIDeleteDomain } from "$lib/api/domains";
import ChecksSidebarContent from "$lib/components/checkers/ChecksSidebarContent.svelte";
import SelectDomain from "$lib/components/domains/SelectDomain.svelte";
import type { Domain } from "$lib/model/domain";
import type { ZoneMeta } from "$lib/model/zone";
@ -42,6 +43,7 @@
import ServiceDetailsOffcanvas from "./ServiceDetailsOffcanvas.svelte";
import ServiceSidebar from "./ServiceSidebar.svelte";
import ZoneSidebar from "./ZoneSidebar.svelte";
import { thisZone } from "$lib/stores/thiszone";
interface Props {
data: { domain: Domain };
@ -57,13 +59,15 @@
"/domains/" +
encodeURIComponent(domainLink(dn)) +
(page.route.id
? page.route.id.startsWith("/domains/[dn]/logs")
? "/logs"
: page.route.id.startsWith("/domains/[dn]/history")
? "/history"
: page.route.id.startsWith("/domains/[dn]/[[historyid]]/export")
? "/export"
: ""
? page.route.id.startsWith("/domains/[dn]/checks")
? "/checks"
: page.route.id.startsWith("/domains/[dn]/logs")
? "/logs"
: page.route.id.startsWith("/domains/[dn]/history")
? "/history"
: page.route.id.startsWith("/domains/[dn]/[[historyid]]/export")
? "/export"
: ""
: ""),
);
}
@ -145,7 +149,15 @@
<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")}
<ChecksSidebarContent
domain={data.domain}
checksBase={"/domains/" +
encodeURIComponent(domainLink(selectedDomain)) +
"/checks"}
backHref={"/domains/" + encodeURIComponent(domainLink(selectedDomain))}
/>
{: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"))}
<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"
@ -153,6 +165,32 @@
<Icon name="chevron-left" />
{$t("zones.return-to")}
</a>
{:else if page.route.id && page.route.id.startsWith("/domains/[dn]/[[historyid]]/[subdomain]/[serviceid]/checks")}
<ChecksSidebarContent
domain={data.domain}
checksBase={"/domains/" +
encodeURIComponent(domainLink(selectedDomain)) +
"/" +
encodeURIComponent(page.data.history ?? "") +
"/" +
encodeURIComponent(page.params.subdomain ?? "") +
"/" +
encodeURIComponent(page.data.serviceid ?? "") +
"/checks"}
backHref={"/domains/" +
encodeURIComponent(domainLink(selectedDomain)) +
"/" +
encodeURIComponent(page.data.history ?? "") +
"/" +
encodeURIComponent(page.params.subdomain ?? "") +
"/" +
encodeURIComponent(page.data.serviceid ?? "")}
serviceContext={{
zoneId: page.data.zoneId ?? "",
subdomain: page.data.subdomain ?? "",
serviceid: page.data.serviceid ?? "",
}}
/>
{:else if page.route.id === "/domains/[dn]/[[historyid]]/[subdomain]/[serviceid]"}
<ServiceSidebar
origin={data.domain}
@ -169,9 +207,8 @@
/>
{/if}
<div class="flex-fill"></div>
{#if !(data.domain.zone_history && $domains_idx[selectedDomain] && data.domain.id === $domains_idx[selectedDomain].id && selectedHistory)}
<div class="flex-fill"></div>
<Button
color="danger"
class="mt-3"
@ -186,7 +223,8 @@
{/if}
{$t("domains.stop")}
</Button>
{:else}
{:else if $domains_idx[data.domain.id] && $thisZone}
<div class="flex-fill"></div>
<ButtonZonePublish domain={data.domain} history={selectedHistory} />
{/if}
{:else}
@ -195,9 +233,14 @@
</div>
{/if}
</Col>
<Col sm={8} md={9} class="d-flex">
<div
class="col-sm-8 col-md-9 d-flex"
class:p-0={page.route &&
(page.route.id == "/domains/[dn]/checks/[checkerId]/executions/[execId]" ||
page.route.id == "/domains/[dn]/[[historyid]]/[subdomain]/[serviceid]/checks/[checkerId]/executions/[execId]")}
>
{@render children?.()}
</Col>
</div>
</Row>
</Container>

View file

@ -31,6 +31,7 @@
<script lang="ts">
import {
Badge,
Button,
Icon,
Input,
@ -40,6 +41,7 @@
Spinner,
} from "@sveltestrap/sveltestrap";
import { listServiceCheckers } from "$lib/api/checkers";
import { getServiceSpec } from "$lib/api/service_specs";
import { deleteZoneService, updateZoneService } from "$lib/api/zone";
import ServiceBadges from "./[[historyid]]/ServiceBadges.svelte";
@ -48,10 +50,12 @@
import { collectRRs } from "$lib/dns";
import type { Domain } from "$lib/model/domain";
import { navigate } from "$lib/stores/config";
import { checkers } from "$lib/stores/checkers";
import { domainLink } from "$lib/stores/domains";
import { servicesSpecs, servicesSpecsLoaded } from "$lib/stores/services";
import { thisZone } from "$lib/stores/thiszone";
import { t } from "$lib/translations";
import { getStatusColor, getStatusI18nKey } from "$lib/utils";
interface Props {
domain: Domain;
@ -97,6 +101,19 @@
service._svctype !== "abstract.NSOnlyOrigin",
);
const zoneId = $derived(selectedHistory || domain.zone_history?.[0] || "");
const subdomain = $derived(service._domain || "@");
const checksPromise = $derived(
service._id && zoneId
? listServiceCheckers(domain.id, zoneId, subdomain, service._id)
: null,
);
const serviceChecksPath = $derived(
service._id && zoneId
? `/domains/${encodeURIComponent(domain.domain)}/${encodeURIComponent(zoneId)}/${encodeURIComponent(service._domain || "@")}/${encodeURIComponent(service._id)}/checks`
: null,
);
let ttlSaveInProgress = $state(false);
function saveTtl() {
@ -156,6 +173,59 @@
{/await}
{/if}
<PropagationStatus propagatedAt={service._propagated_at} />
{#if checksPromise}
<div class="mt-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<small class="text-muted fw-semibold text-uppercase">
{$t("checkers.service-checks")}
</small>
{#if serviceChecksPath}
<a href={serviceChecksPath} class="small" onclick={() => (isOpen = false)}>
{$t("checkers.view-all")}
</a>
{/if}
</div>
{#await checksPromise}
<div class="text-center py-2">
<Spinner size="sm" />
</div>
{:then checkerStatuses}
{#if checkerStatuses && checkerStatuses.length > 0}
<div class="d-flex flex-column gap-1">
{#each checkerStatuses as check}
<div class="d-flex justify-content-between align-items-center">
<a
href={serviceChecksPath +
"/" +
check.id +
"/executions"}
class="text-truncate me-2"
onclick={() => (isOpen = false)}
>
{$checkers?.[check.id ?? ""]?.name ??
check.name ??
check.id}
</a>
{#if check.latestExecution?.result}
<Badge color={getStatusColor(check.latestExecution.result.status)}>
{$t(getStatusI18nKey(check.latestExecution.result.status))}
</Badge>
{:else}
<Badge color="secondary">
{$t("checkers.status.not-run")}
</Badge>
{/if}
</div>
{/each}
</div>
{:else}
<small class="text-muted fst-italic">{$t("checkers.no-checks")}</small>
{/if}
{:catch}
<small class="text-danger">{$t("checkers.load-error")}</small>
{/await}
</div>
{/if}
<div class="flex-fill"></div>
{#if service._id}
<div class="d-flex align-items-center gap-2 mt-2">

View file

@ -113,6 +113,9 @@
<DropdownItem href={`/domains/${domainLink(selectedDomain)}/logs`}>
{$t("domains.actions.audit")}
</DropdownItem>
<DropdownItem href={`/domains/${domainLink(selectedDomain)}/checks`}>
{$t("domains.actions.checks")}
</DropdownItem>
<DropdownItem divider />
<DropdownItem on:click={viewZone} disabled={!$sortedDomains}>
{$t("domains.actions.view")}

View file

@ -39,7 +39,9 @@
import type { Domain } from "$lib/model/domain";
import type { ServiceWithValue } from "$lib/model/service.svelte";
import { servicesSpecs, servicesSpecsLoaded } from "$lib/stores/services";
import { thisZone } from "$lib/stores/thiszone";
import { t } from "$lib/translations";
import { getStatusColor, getStatusIcon } from "$lib/utils";
import PropagationCountdown from "$lib/components/services/PropagationCountdown.svelte";
interface Props {
@ -83,7 +85,11 @@
<Icon name="plus-circle" /> {$t("service.new")}
{/if}
</CardTitle>
{#if service?._propagated_at && isPropagating}
{#if service?._id && $thisZone?.services_check_status?.[service._id] !== undefined}
<span class={"text-" + getStatusColor($thisZone.services_check_status![service._id])}>
<Icon name={getStatusIcon($thisZone.services_check_status![service._id])} />
</span>
{:else if service?._propagated_at && isPropagating}
<Progress
class="rounded"
barClassName="px-2 text-end"

View file

@ -178,6 +178,14 @@
<div class="d-flex justify-content-end align-items-center gap-2 mt-3">
{#if service._id}
<Button
color="info"
outline
href="checks"
>
<Icon name="shield-check" />
{$t("checkers.title")}
</Button>
<Button
disabled={addServiceInProgress}
form="addSvcForm"

View file

@ -0,0 +1,19 @@
import type { Load } from "@sveltejs/kit";
import { get } from "svelte/store";
import { checkers, refreshCheckers } from "$lib/stores/checkers";
export const load: Load = async ({ parent, params }) => {
const data = await parent();
if (!get(checkers)) await refreshCheckers();
const subdomain = params.subdomain === "@" ? "" : params.subdomain;
const serviceid = params.serviceid;
return {
...data,
subdomain,
serviceid,
};
};

View file

@ -0,0 +1,48 @@
<!--
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 { t } from "$lib/translations";
import type { Domain } from "$lib/model/domain";
import { fqdn } from "$lib/dns";
import { domainLink } from "$lib/stores/domains";
import CheckerListPage from "$lib/components/checkers/CheckerListPage.svelte";
let domain: Domain = $derived(page.data.domain);
let zoneId: string = $derived(page.data.zoneId);
let subdomain: string = $derived(page.data.subdomain);
let serviceid: string = $derived(page.data.serviceid);
let checksBase = $derived(
`/domains/${domainLink(domain.id)}/${encodeURIComponent(zoneId)}/${encodeURIComponent(page.params.subdomain!)}/${encodeURIComponent(serviceid)}/checks`,
);
</script>
<CheckerListPage
scope={{ domainId: domain.id, zoneId, subdomain, serviceId: serviceid }}
{checksBase}
title={$t("checkers.list.title") + fqdn(subdomain, domain.domain)}
domainName={fqdn(subdomain, domain.domain)}
filterAvailability="applyToService"
/>

View file

@ -0,0 +1,74 @@
<!--
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 { t } from "$lib/translations";
import type { Domain } from "$lib/model/domain";
import { fqdn } from "$lib/dns";
import { domainLink } from "$lib/stores/domains";
import CheckerConfigPage from "$lib/components/checkers/CheckerConfigPage.svelte";
let domain: Domain = $derived(page.data.domain);
let zoneId: string = $derived(page.data.zoneId);
let subdomain: string = $derived(page.data.subdomain);
let serviceid: string = $derived(page.data.serviceid);
let checkerId = $derived(page.params.checkerId!);
let checksBase = $derived(
`/domains/${domainLink(domain.id)}/${encodeURIComponent(zoneId)}/${encodeURIComponent(page.params.subdomain!)}/${encodeURIComponent(serviceid)}/checks`,
);
</script>
<CheckerConfigPage
scope={{ domainId: domain.id, zoneId, subdomain, serviceId: serviceid }}
{checksBase}
{checkerId}
domainName={fqdn(subdomain, domain.domain)}
editableGroups={(status) => [
{
label: $t("checkers.option-groups.service-settings"),
opts: status.options?.serviceOpts || [],
},
{
label: $t("checkers.detail.admin-options"),
opts: status.options?.adminOpts || [],
},
{
label: $t("checkers.detail.configuration"),
opts: status.options?.userOpts || [],
},
]}
readOnlyGroups={(status) => [
{
key: "domainOpts",
label: $t("checkers.option-groups.domain-settings"),
opts: status.options?.domainOpts || [],
},
{
key: "runOpts",
label: $t("checkers.option-groups.checker-parameters"),
opts: status.options?.runOpts || [],
},
]}
/>

View file

@ -0,0 +1,47 @@
<!--
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 { Domain } from "$lib/model/domain";
import { fqdn } from "$lib/dns";
import { domainLink } from "$lib/stores/domains";
import ExecutionListPage from "$lib/components/checkers/ExecutionListPage.svelte";
let domain: Domain = $derived(page.data.domain);
let zoneId: string = $derived(page.data.zoneId);
let subdomain: string = $derived(page.data.subdomain);
let serviceid: string = $derived(page.data.serviceid);
let checkerId = $derived(page.params.checkerId!);
let checksBase = $derived(
`/domains/${domainLink(domain.id)}/${encodeURIComponent(zoneId)}/${encodeURIComponent(page.params.subdomain!)}/${encodeURIComponent(serviceid)}/checks`,
);
</script>
<ExecutionListPage
scope={{ domainId: domain.id, zoneId, subdomain, serviceId: serviceid }}
{checksBase}
{checkerId}
domainName={fqdn(subdomain, domain.domain)}
/>

View file

@ -0,0 +1,42 @@
<!--
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 { Domain } from "$lib/model/domain";
import ExecutionDetailPage from "$lib/components/checkers/ExecutionDetailPage.svelte";
let domain: Domain = $derived(page.data.domain);
let zoneId: string = $derived(page.data.zoneId);
let subdomain: string = $derived(page.data.subdomain);
let serviceid: string = $derived(page.data.serviceid);
let checkerId = $derived(page.params.checkerId!);
let execId = $derived(page.params.execId!);
</script>
<ExecutionDetailPage
scope={{ domainId: domain.id, zoneId, subdomain, serviceId: serviceid }}
{checkerId}
{execId}
/>

View file

@ -0,0 +1,44 @@
<!--
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 { Domain } from "$lib/model/domain";
import { fqdn } from "$lib/dns";
import ExecutionRulesPage from "$lib/components/checkers/ExecutionRulesPage.svelte";
let domain: Domain = $derived(page.data.domain);
let zoneId: string = $derived(page.data.zoneId);
let subdomain: string = $derived(page.data.subdomain);
let serviceid: string = $derived(page.data.serviceid);
let checkerId = $derived(page.params.checkerId!);
let execId = $derived(page.params.execId!);
</script>
<ExecutionRulesPage
scope={{ domainId: domain.id, zoneId, subdomain, serviceId: serviceid }}
{checkerId}
{execId}
domainName={fqdn(subdomain, domain.domain)}
/>

View file

@ -0,0 +1,14 @@
import type { Load } from "@sveltejs/kit";
import { get } from "svelte/store";
import { checkers, refreshCheckers } from "$lib/stores/checkers";
export const load: Load = async ({ parent }) => {
const data = await parent();
if (!get(checkers)) await refreshCheckers();
return {
...data,
};
};

View file

@ -0,0 +1,42 @@
<!--
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 { t } from "$lib/translations";
import type { Domain } from "$lib/model/domain";
import { domainLink } from "$lib/stores/domains";
import CheckerListPage from "$lib/components/checkers/CheckerListPage.svelte";
let domain: Domain = $derived(page.data.domain);
let checksBase = $derived(`/domains/${domainLink(domain.id)}/checks`);
</script>
<CheckerListPage
scope={{ domainId: domain.id }}
{checksBase}
title={$t("checkers.list.title") + domain.domain}
domainName={domain.domain}
filterAvailability="applyToDomain"
/>

View file

@ -0,0 +1,68 @@
<!--
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 { t } from "$lib/translations";
import type { Domain } from "$lib/model/domain";
import { domainLink } from "$lib/stores/domains";
import CheckerConfigPage from "$lib/components/checkers/CheckerConfigPage.svelte";
let domain: Domain = $derived(page.data.domain);
let checkerId = $derived(page.params.checkerId!);
let checksBase = $derived(`/domains/${domainLink(domain.id)}/checks`);
</script>
<CheckerConfigPage
scope={{ domainId: domain.id }}
{checksBase}
{checkerId}
domainName={domain.domain}
editableGroups={(status) => [
{
label: $t("checkers.option-groups.domain-settings"),
opts: status.options?.domainOpts || [],
},
{
label: $t("checkers.detail.admin-options"),
opts: status.options?.adminOpts || [],
},
{
label: $t("checkers.detail.configuration"),
opts: status.options?.userOpts || [],
},
]}
readOnlyGroups={(status) => [
{
key: "serviceOpts",
label: $t("checkers.option-groups.service-settings"),
opts: status.options?.serviceOpts || [],
},
{
key: "runOpts",
label: $t("checkers.option-groups.checker-parameters"),
opts: status.options?.runOpts || [],
},
]}
/>

View file

@ -0,0 +1,41 @@
<!--
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 { Domain } from "$lib/model/domain";
import { domainLink } from "$lib/stores/domains";
import ExecutionListPage from "$lib/components/checkers/ExecutionListPage.svelte";
let domain: Domain = $derived(page.data.domain);
let checkerId = $derived(page.params.checkerId!);
let checksBase = $derived(`/domains/${domainLink(domain.id)}/checks`);
</script>
<ExecutionListPage
scope={{ domainId: domain.id }}
{checksBase}
{checkerId}
domainName={domain.domain}
/>

View file

@ -0,0 +1,39 @@
<!--
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 { Domain } from "$lib/model/domain";
import ExecutionDetailPage from "$lib/components/checkers/ExecutionDetailPage.svelte";
let domain: Domain = $derived(page.data.domain);
let checkerId = $derived(page.params.checkerId!);
let execId = $derived(page.params.execId!);
</script>
<ExecutionDetailPage
scope={{ domainId: domain.id }}
{checkerId}
{execId}
/>

View file

@ -0,0 +1,35 @@
<!--
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 type { HappydnsObservationSnapshot } from "$lib/api-base/types.gen";
import ObservationReportCard from "$lib/components/checkers/ObservationReportCard.svelte";
interface Props {
observations: HappydnsObservationSnapshot;
}
let { observations }: Props = $props();
</script>
<ObservationReportCard {observations} />

View file

@ -0,0 +1,40 @@
<!--
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 { Domain } from "$lib/model/domain";
import ExecutionRulesPage from "$lib/components/checkers/ExecutionRulesPage.svelte";
let domain: Domain = $derived(page.data.domain);
let checkerId = $derived(page.params.checkerId!);
let execId = $derived(page.params.execId!);
</script>
<ExecutionRulesPage
scope={{ domainId: domain.id }}
{checkerId}
{execId}
domainName={domain.domain}
/>