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 25c1ff41c7
46 changed files with 4408 additions and 147 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,131 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-2026 happyDomain
Authors: Pierre-Olivier Mercier, et al.
This program is offered under a commercial and under the AGPL license.
For commercial licensing, contact us at <contact@happydomain.org>.
For AGPL licensing:
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import {
Card,
Col,
Container,
Icon,
Input,
InputGroup,
InputGroupText,
Table,
Row,
Badge,
} from "@sveltestrap/sveltestrap";
import { getCheckers } from "$lib/api-base";
import { availabilityBadges } from "$lib/utils";
let checkersQ = $state(getCheckers());
let searchQuery = $state("");
</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,420 @@
<!--
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 { availabilityBadges, formatDuration, getOrphanedOptionKeys, filterValidOptions } 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,
});
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[]) {
saving = true;
try {
await putCheckersByCheckerIdOptions({
path: { checkerId },
body: filterValidOptions(optionValues, adminOpts),
});
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;
}
}
</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 = getOrphanedOptionKeys(optionValues, 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

@ -132,6 +132,14 @@
<Icon name="search" class="me-2" />
{$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">
<Icon name="gear" class="me-2" />

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, 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 { splitPositionalOptions } from "$lib/utils";
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, inherited } = splitPositionalOptions(positionals);
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 { CheckerCheckerDefinition, 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, CheckerCheckerDefinition][] {
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 { CheckerCheckerOptionDocumentation } from "$lib/api-base/types.gen";
import { t } from "$lib/translations";
let {
groups,
}: {
groups: { key: string; label: string; opts: CheckerCheckerOptionDocumentation[] }[];
} = $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 {
CheckerCheckerOptionDocumentation,
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: CheckerCheckerOptionDocumentation[];
}
interface ReadOnlyGroup {
key: string;
label: string;
opts: CheckerCheckerOptionDocumentation[];
}
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 { CheckerCheckRuleInfo, HappydnsCheckPlan, HappydnsCheckPlanWritable } 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: CheckerCheckRuleInfo[];
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/{encodeURIComponent(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,75 @@
<!--
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 { CheckerCheckerDefinition } from "$lib/api-base/types.gen";
import { t } from "$lib/translations";
import { availabilityBadges } from "$lib/utils";
let {
checkers,
basePath = "/checkers",
}: {
checkers: [string, CheckerCheckerDefinition][];
basePath?: string;
} = $props();
</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, $t)}
<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,286 @@
<!--
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, 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);
// Keep only planned executions (those without an id); completed ones were deleted server-side.
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,
});
}
}
let pollTimer: ReturnType<typeof setInterval> | undefined;
function pollForNewExecution() {
if (pollTimer) clearInterval(pollTimer);
const previousCount = executions.length;
let attempts = 0;
const maxAttempts = 10;
const intervalMs = 3000;
pollTimer = setInterval(async () => {
attempts++;
try {
await loadExecutions();
if (executions.length > previousCount || attempts >= maxAttempts) {
clearInterval(pollTimer);
pollTimer = undefined;
}
} catch {
clearInterval(pollTimer);
pollTimer = undefined;
}
}, intervalMs);
}
onDestroy(() => {
if (pollTimer) clearInterval(pollTimer);
});
</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 { CheckerCheckerOptionDocumentation } 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 | CheckerCheckerOptionDocumentation;
type: string;
value: any;
}

View file

@ -0,0 +1,328 @@
<!--
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 {
CheckerCheckerDefinition,
CheckerCheckerOptionDocumentation,
CheckerCheckRuleInfo,
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<CheckerCheckerDefinition> | null>(null);
let scopedOptionsPromise = $state<Promise<HappydnsCheckerOptionsPositional[]> | null>(null);
let resolvedStatus = $state<CheckerCheckerDefinition | null>(null);
let runOptions = $state<Record<string, unknown>>({});
let scopedDefaults = $state<Record<string, unknown>>({});
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]: [
CheckerCheckerDefinition,
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: CheckerCheckerOptionDocumentation[] | undefined) =>
opts?.forEach((o) => o.id && !o.noOverride && ids.add(o.id));
addOpts(resolvedStatus.options?.runOpts);
addOpts(resolvedStatus.options?.adminOpts);
addOpts(resolvedStatus.options?.userOpts);
addOpts(resolvedStatus.options?.domainOpts);
resolvedStatus.rules?.forEach((rule: CheckerCheckRuleInfo, idx: number) => {
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: CheckerCheckerOptionDocumentation,
): CheckerCheckerOptionDocumentation {
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: CheckerCheckRuleInfo, idx: number) => 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: CheckerCheckRuleInfo, i: number) =>
activeRules[i] !== false ? r : null,
)}
{@const runOpts = [
...(status.options?.runOpts || []),
...activeRulesForOpts.flatMap((r: CheckerCheckRuleInfo | null) => r?.options?.runOpts || []),
].filter((o: CheckerCheckerOptionDocumentation) => !o.noOverride)}
{@const otherOpts = [
...(status.options?.adminOpts || []),
...(status.options?.userOpts || []),
...(status.options?.domainOpts || []),
...activeRulesForOpts.flatMap((r: CheckerCheckRuleInfo | null) => [
...(r?.options?.adminOpts || []),
...(r?.options?.userOpts || []),
...(r?.options?.domainOpts || []),
]),
].filter((o: CheckerCheckerOptionDocumentation) => o.id && !o.noOverride)}
<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.id}
{#if optName}
<FormGroup>
<Resource
edit={true}
index={optName}
specs={specsWithPlaceholder(optDoc)}
type={optDoc.type || "string"}
readonly={!!optDoc.autoFill}
bind:value={runOptions[optName]}
/>
</FormGroup>
{/if}
{/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 ?? String(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"
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

@ -27,21 +27,17 @@
"signup": "Sign up!",
"success": "Registration successfully performed!",
"login-now": "You can now login with your credentials."
},
"see-again": "Happy to see you again!"
}
},
"common": {
"add": "Add",
"add-new-thing": "Add new {{thing}}",
"add-object": "Add a {{thing}} object to this service",
"by": "by",
"cancel": "Cancel",
"cancel-edit": "Cancel edit",
"copy-clipboard": "Copy to clipboard",
"continue": "Continue",
"create-thing": "Create {{thing}}",
"delete": "Delete",
"delete-thing": "Delete {{thing}}",
"domain": "Domain or subdomain",
"edit": "Edit",
"field": "Field",
@ -49,7 +45,6 @@
"go": "Go!",
"go-back": "Go Back",
"got-it": "Got it!",
"name": "Name",
"next": "Next",
"no-content": "No content",
"no-thing": "No {{thing}}",
@ -61,7 +56,6 @@
"resolver": "Resolver",
"run": "Run the request!",
"survey": "A remark? A comment to share? Don't hesitate to write to us!",
"spinning": "Spinning",
"update": "Update",
"update-what": "Update {{what}}",
"welcome": {
@ -79,7 +73,6 @@
"actions": {
"audit": "View changes logs",
"checks": "Domain checks",
"do-migration": "Migrate now",
"history": "View changes history",
"propagate": "Publish my changes",
"reimport": "Fetch current deployed zone",
@ -90,11 +83,7 @@
"others": "More actions on {{domain}}"
},
"alert": {
"remove": "This action only removes {{domain}} from your happyDomain managed domains. All history and abstracted zones will be discarded. The domain {{domain}} remains fully intact at the provider. Are you sure you want to continue?",
"unable-retrieve": {
"description": "Unfortunately, we were unable to retrieve information for the domain {{domain}}:",
"title": "Unable to retrieve domain information"
}
"remove": "This action only removes {{domain}} from your happyDomain managed domains. All history and abstracted zones will be discarded. The domain {{domain}} remains fully intact at the provider. Are you sure you want to continue?"
},
"and-more-filtered": "{{count:eq; 0:no more filtered domain; 1:1 more filtered domain; default:{{count}} more filtered domains}}",
"add-a-subdomain": "Add a subdomain",
@ -111,10 +100,6 @@
"button": "Apply modifications",
"deletions": "{{count:eq; 0:no deletions; 1:{{count}} deletion; default:{{count}} deletions}}",
"double-check": "You see this confirmation dialog because you choose in your preference to always confirm before applying corrections.",
"done": {
"title": "Zone applied successfully!",
"description": "Your zone {{zone}} have been successfully propagated!"
},
"modifications": "{{count:eq; 0:no modification; 1:{{count}} modification; default:{{count}} modifications}}",
"nochange": "There is no changes to apply! Current zone is in sync with the server.",
"change-already-applied": "Changes you requested seems to be already applied.",
@ -132,8 +117,6 @@
"drop-pointer": "Drop pointer",
"edit-target": "Edit target",
"give-explicit-name": "Give an explicit name in order to easily find this service.",
"history": "History",
"list": "List importable domains",
"n-aliases": "{{count:lt; 2:{{count}} alias; default:{{count}} aliases}}",
"n-services": "{{count:lt; 2:{{count}} service; default:{{count}} services}}",
"filtered-no-result": "There is no domain with those filters.",
@ -142,17 +125,12 @@
"save-modifications": "Save those modifications",
"stop": "Stop managing this domain",
"view": {
"abstract": "Abstract zone",
"cancel-title": "Keep my domain in happyDomain",
"description": "Review the modifications that will be applied to {{domain}}",
"live": "Live records",
"monitoring": "Monitoring",
"summary": "Summary",
"provider": "Zone hosted on",
"title": "View zone",
"subtitle": "Your zone in standard BIND format"
},
"see-records": "See corresponding records",
"alias-creation": "Add an alias pointing to {{domain}}:",
"alias-creation-sample": "This will create the alias:",
"placeholder-search": "my.domain.",
@ -198,26 +176,19 @@
"account-delete": "An error occurs when trying to delete your account",
"address": "Email address is required",
"address-valid": "A valid email address is required",
"domain-access": "An error occurs when trying to access domain's list.",
"domain-attach": "An error occurs when attaching the domain to happyDomain",
"domain-have": "It appears you don't have any domain name registered on this provider.",
"domain-import": "An error occurs when trying to synchronize this domain:",
"domain-list": "This provider doesn't permit to list existing domains. Use the above form to add one.",
"error": "Error",
"login": "Login error",
"rate-limited": "Too many failed login attempts. Please wait a moment before trying again.",
"logout": "Logout error",
"occurs": "An error occurs when {{when}}!",
"password": "Password is required",
"password-change": "Unable to change your password account",
"password-match": "Password and its confirmation doesn't match.",
"password-weak": "Password needs to be stronger: at least 8 characters with numbers, lower case and upper case characters.",
"provider-delete": "Something went wrong during provider deletion",
"recovery": "Password recovery problem",
"resolve": "An error occurs when trying to resolve the domain.",
"registration": "Registration problem",
"rr-add": "An error occurs when trying to add RR to the zone:",
"rr-delete": "An error occurs when trying to delete RR in the zone:",
"session": {
"title": "Authentication timeout",
"content": "Invalid session, you have been logged out: {{err}}. Please login again."
@ -285,18 +256,6 @@
"unified": {
"title": "Unified Management",
"description": "Manage all your domains in one place, regardless of where they're registered."
},
"comprehensive": {
"title": "Comprehensive Features",
"description": "Experience the future of DNS management with history, diff, tests, ..."
},
"intuitive": {
"title": "Intuitive Interface",
"description": "Simple and user-friendly interface for both beginners and DNS experts."
},
"templates": {
"title": "One-Click Templates",
"description": "Configure common services with pre-built templates in seconds."
}
},
"connect": {
@ -346,14 +305,11 @@
"password": {
"change": "Change my password",
"changed": "Password Successfully Changed",
"confirm-new": "Confirm your new password",
"confirm-title": "Confirm New Password",
"confirm-description": "Re-enter your new password to confirm",
"confirmation": "Password confirmation",
"current-title": "Current Password",
"current-description": "Enter your current password to verify your identity",
"enter": "Enter your current password",
"enter-new": "Enter your new password",
"new-title": "New Password",
"new-description": "Choose a strong password with at least 8 characters, including numbers, lowercase and uppercase letters",
"fill": "In order to recover your account, please fill the following form, with a fresh password.",
@ -429,7 +385,6 @@
"delete": "Delete this record",
"update": "Update this record",
"form-new": "Add a new record to {{domain}}",
"new": "New record",
"rrtype": "type",
"ttl": "Time-to-live"
},
@ -447,11 +402,9 @@
"associations": "{{count:eq; 0:no domain associated; 1:{{count}} domain associated; default:{{count}} domains associated}}",
"check-config": "Check your provider configuration",
"description": "Manage your DNS provider connections and credentials",
"create-domain": "Create a new domain on {{provider}}",
"delete": "Delete this domain provider",
"empty": "You have no provider defined currently. Try {{action}}!",
"empty-action": "adding one",
"find": "Can't find your domain provider here?",
"name-your": "Name your domain provider",
"no-name": "No name",
"provider": "Domains living on {{provider}}",
@ -460,7 +413,6 @@
"provider-name": "Host's name",
"provider-type": "Hosting provider type",
"title": "Your domain providers",
"available-types": "Resources Types available",
"import-domains": "Import all domains",
"new-form": "New domain provider form",
"linked-domains": "Linked domains",
@ -476,11 +428,8 @@
"formating": "Please wait while we format your zone…",
"importing": "Please wait while we are importing your domain…",
"loading": "Loading domain's services…",
"loading-account": "Loading your account…",
"loading-record": "Loading records…",
"retrieving-setting": "Retrieving host settings' form...",
"updating": "Updating your domain name host",
"validating": "Validating domain …",
"wait": "Please wait",
"retrieving-domains": "Retrieving your domains...",
"retrieving-provider": "Retrieving hosting provider information...",
@ -510,14 +459,12 @@
"title": "Security & Access",
"description": "Manage your authentication and security settings",
"sessions": {
"title": "Active Sessions",
"description": "Manage your active login sessions and API keys"
"title": "Active Sessions"
},
"password": {
"description": "Set a new password for your account"
}
},
"sessions": "Sessions",
"showrrtypes": "Show resource type associated with services (for users familiar with DNS)",
"success": "Continue to enjoy happyDomain.",
"success-change": "Your settings has been saved.",
@ -562,7 +509,6 @@
"domain-description": "Indicate the domain you search the records. For example, you can try {{domain}}.",
"error-description": "The DNS query could not be completed due to an error.",
"field-description": "What kind of DNS record you want to see. For example: A is for IPv4, AAAA is for IPv6, ...",
"field-description-more-info": "More information here",
"no-answer": "The DNS query completed successfully, but no records were found for this domain and type.",
"no-records-description": "Your query returned no results.",
"page-description": "Query DNS records for any domain",
@ -584,7 +530,6 @@
"page-title": "DNS Record Generator",
"description": "Free online {{name}} DNS record generator. Configure and preview your {{name}} record in zone file format.",
"not-found": "Service type <code>{{svctype}}</code> not found.",
"browse-all": "Browse all generators",
"domain-settings": "Your Domain Name",
"domain-help": "Enter the domain name where the record will be created. The generated records will use this domain.",
"configure-record": "Fill the Required Information",
@ -605,13 +550,13 @@
"upload": "Import a zone",
"import-text": "Import from text",
"import-file": "Import from file",
"return-to": "Go to the zone"
"return-to": "Go to the zone",
"return-to-results": "Back to results"
},
"checkers": {
"run-check": {
"title": "Run Check",
"loading-options": "Loading checker options...",
"select-rule": "Rule to check",
"configure-info": "Configure checker options below. Pre-filled values are from domain-level settings.",
"no-options": "This checker has no configurable options. Click \"Run Check\" to execute with default settings.",
"no-run-options": "This checker has no run-time options. You can still override advanced settings below.",
@ -623,13 +568,6 @@
"rules": "Rules"
},
"never": "Never",
"na": "N/A",
"relative": {
"in-less-than-a-minute": "in less than a minute",
"just-now": "just now",
"in": "in {{label}}",
"ago": "{{label}} ago"
},
"status": {
"ok": "OK",
"info": "Info",
@ -637,23 +575,17 @@
"critical": "Critical",
"error": "Error",
"unknown": "Unknown",
"pending": "Pending",
"planned": "Planned",
"running": "Running",
"not-run": "Not run"
},
"list": {
"title": "Checks for ",
"title-service": "Checks for {{service}}",
"loading": "Loading checkers...",
"loading-checkers": "Loading checker information...",
"no-checks": "No checks available for this domain.",
"no-checks-service": "No checks available for this service.",
"run-check": "Run Check",
"view-results": "View Results",
"configure": "Configure",
"error-loading": "Error loading checkers: {{error}}",
"unknown-version": "Unknown",
"table": {
"checker": "Checker",
"status": "Status",
@ -664,26 +596,17 @@
"schedule": {
"enabled": "Enabled",
"disabled": "Disabled"
},
"loading-checks": "Loading checker information..."
}
},
"other-checkers": {
"title": "Other available checkers",
"description": "These checkers are not directly associated with this domain but can be configured with domain-specific options.",
"no-checkers": "No other checkers available.",
"configure": "Configure"
"description": "These checkers are not directly associated with this domain but can be configured with domain-specific options."
},
"schedule": {
"title": "Schedule",
"card-title": "Automatic scheduling",
"auto-enabled": "Run automatically",
"auto-disabled": "Disabled (run manually only)",
"interval-label": "Check interval",
"hours": "hours",
"interval-hint": "Minimum 1 hour. The check will run once per interval.",
"interval-hint-bounded": "Between {{min}} and {{max}} hours.",
"next-run": "Next scheduled run",
"last-run": "Last run",
"no-schedule-yet": "No schedule created yet. Save to create one.",
"save": "Save",
"save-failed": "Failed to save schedule",
@ -694,43 +617,25 @@
"no-results": "No check results yet. Click \"Run Check Now\" to execute the check.",
"title": "Check Executions ({{count}})",
"run-check-now": "Run Check Now",
"back-to-checks": "Back to checks",
"delete-all": "Delete All",
"delete-confirm": "Are you sure you want to delete this check result?",
"delete-all-confirm": "Are you sure you want to delete ALL check results for this checker? This cannot be undone.",
"deleted-all": "All check results have been deleted.",
"delete-failed": "Failed to delete result",
"delete-all-failed": "Failed to delete results",
"configure": "Configure",
"domain-level": "Domain-level",
"error-loading": "Error loading checker results: {{error}}",
"error-deleting": "Error deleting execution: {{error}}",
"table": {
"executed-at": "Executed At",
"status": "Status",
"message": "Message",
"duration": "Duration",
"type": "Type",
"actions": "Actions"
},
"type": {
"scheduled": "Scheduled",
"manual": "Manual"
},
"pending": {
"queued": "Queued",
"queued-description": "Queued, waiting to run\u2026",
"running": "Running",
"running-description": "Check is currently running\u2026"
},
"view": "View"
},
"execution": {
"title": "Check Execution Details",
"field": {
"ended-at": "Ended At:",
"trigger": "Trigger:",
"error": "Error:"
"trigger": "Trigger:"
},
"status": {
"pending": "Pending",
@ -741,30 +646,25 @@
}
},
"result": {
"title": "Check Result Details",
"loading": "Loading check result...",
"relaunch": "Relaunch Check",
"delete": "Delete Result",
"relaunch-failed": "Failed to relaunch check",
"delete-confirm": "Are you sure you want to delete this check?",
"delete-failed": "Failed to delete result",
"error-loading": "Error loading check: {{error}}",
"milliseconds": "milliseconds",
"seconds": "seconds",
"type": {
"scheduled": "Scheduled Check",
"manual": "Manual Check"
},
"check-options": "Check Options",
"full-report": "Full Report",
"field": {
"domain": "Domain:",
"executed-at": "Executed At:",
"duration": "Duration:",
"status": "Status:",
"status-message": "Message:",
"error": "Error:"
"error": "Error:",
"rule": "Rule",
"message": "Message"
},
"no-results": "No results available for this execution.",
"view-metrics": "Metrics",
"view-html": "HTML Report",
"view-json": "Raw JSON",
@ -785,7 +685,6 @@
"error-loading": "Error loading checkers: {{error}}",
"error-loading-checker": "Error loading checker: {{error}}",
"checker-info-not-found": "Error: Checker information not found",
"back-to-checkers": "Back to checkers",
"table": {
"name": "Checker Name",
"availability": "Availability",
@ -795,21 +694,10 @@
"availability": {
"domain": "Domain",
"zone": "Zone",
"provider-specific": "Provider-specific",
"service-specific": "Service-specific",
"general": "General",
"user-level": "User-level",
"domain-level": "Domain-level",
"zone-level": "Zone-level",
"service-level": "Service-level",
"providers": "Providers: {{providers}}",
"services": "Services: {{services}}"
},
"actions": {
"configure": "Configure"
},
"sidebar": {
"back-to-list": "Back to checkers list"
"service-level": "Service-level"
},
"detail": {
"checker-information": "Checker Information",
@ -820,21 +708,17 @@
"admin-options": "Admin Options",
"configuration": "Configuration",
"save": "Save",
"save-changes": "Save Changes",
"no-configurable-options": "This checker has no configurable options.",
"error-loading-options": "Error loading options: {{error}}",
"orphaned-options": "Orphaned options detected: {{options}}",
"clean-up": "Clean Up",
"read-only": "Read-only"
"read-only": "Read-only",
"auto-fill": "Auto-filled fields"
},
"option-groups": {
"global-settings": "Global Settings",
"domain-settings": "Domain-specific Settings",
"service-settings": "Service-specific Settings",
"checker-parameters": "Checker Parameters",
"type": "Type: {{type}}",
"required": "Required",
"auto-fill": "Auto-filled Fields"
"checker-parameters": "Checker Parameters"
},
"auto-fill": {
"domain_name": "auto-filled: domain name",

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

@ -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,211 @@
<!--
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 { CheckerCheckerOptionDocumentation, CheckerCheckRuleInfo, HappydnsCheckerOptionsPositional } from "$lib/api-base/types.gen";
import CheckerRulesCard from "$lib/components/checkers/CheckerRulesCard.svelte";
import CheckerOptionsPanel from "$lib/components/checkers/CheckerOptionsPanel.svelte";
import { availabilityBadges, splitPositionalOptions, getOrphanedOptionKeys, filterValidOptions } from "$lib/utils";
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, inherited } = splitPositionalOptions(positionals);
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: CheckerCheckerOptionDocumentation[]) {
saving = true;
try {
await updateCheckOptions(checkerId, filterValidOptions(optionValues, allEditableOpts));
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: CheckerCheckerOptionDocumentation[],
readOnlyGroups: { opts: CheckerCheckerOptionDocumentation[] }[],
): string[] {
const allKnownOpts = [...allEditableOpts, ...readOnlyGroups.flatMap((g) => g.opts)];
return getOrphanedOptionKeys(optionValues, allKnownOpts);
}
</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: CheckerCheckRuleInfo) => r.options?.adminOpts || [])}
{@const rulesUserOpts = (status.rules || []).flatMap((r: CheckerCheckRuleInfo) => 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 availabilityBadges(status.availability, $t) 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 { listScopedCheckers } 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
? listScopedCheckers({ domainId: domain.id, zoneId, subdomain, serviceId: 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,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}
/>