web-admin: Implement checkers interface with option editor

This commit is contained in:
nemunaire 2026-01-24 11:13:02 +08:00
commit 787685a52c
5 changed files with 560 additions and 0 deletions

View file

@ -110,6 +110,7 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.Engine) {
// Routes to virtual content
router.GET("/auth_users/*_", serveOrReverse("/", cfg))
router.GET("/checks/*_", serveOrReverse("/", cfg))
router.GET("/domains/*_", serveOrReverse("/", cfg))
router.GET("/providers/*_", serveOrReverse("/", cfg))
router.GET("/sessions/*_", serveOrReverse("/", cfg))

View file

@ -101,6 +101,9 @@
<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>
</Nav>
</Collapse>
</Navbar>

View file

@ -0,0 +1,150 @@
<!--
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 { getChecks } from "$lib/api-admin";
let checkersQ = $state(getChecks());
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="4" 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 [checkerName, checkerInfo]}
<tr>
<td><strong>{checkerInfo.name || checkerName}</strong></td>
<td>
{#if checkerInfo.availability}
{#if checkerInfo.availability.applyToDomain}
<Badge color="success">Domain</Badge>
{/if}
{#if checkerInfo.availability.limitToProviders && checkerInfo.availability.limitToProviders.length > 0}
<Badge
color="primary"
title={checkerInfo.availability.limitToProviders.join(
", ",
)}
>
Provider-specific
</Badge>
{/if}
{#if checkerInfo.availability.limitToServices && checkerInfo.availability.limitToServices.length > 0}
<Badge
color="info"
title={checkerInfo.availability.limitToServices.join(
", ",
)}
>
Service-specific
</Badge>
{/if}
{:else}
<Badge color="secondary">General</Badge>
{/if}
</td>
<td>
<a
href="/checkers/{checkerName}"
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,322 @@
<!--
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,
FormGroup,
Icon,
Row,
} from "@sveltestrap/sveltestrap";
import { page } from "$app/state";
import { toasts } from "$lib/stores/toasts";
import { getChecksByCnameOptions, putChecksByCnameOptions } from "$lib/api-admin";
import { getCheckStatus } from "$lib/api/checks";
import Resource from "$lib/components/inputs/Resource.svelte";
import CheckerOptionsGroups from "$lib/components/checkers/CheckerOptionsGroups.svelte";
let cname = $derived(page.params.cname!);
let checkerStatusQ = $derived(getCheckStatus(cname));
let checkerOptionsQ = $derived(getChecksByCnameOptions({ path: { cname } }));
let optionValues = $state<Record<string, any>>({});
let saving = $state(false);
$effect(() => {
checkerOptionsQ.then((optionsR) => {
optionValues = { ...((optionsR.data as Record<string, unknown>) || {}) };
});
});
async function saveOptions() {
saving = true;
try {
await putChecksByCnameOptions({
path: { cname },
body: { options: optionValues },
});
checkerOptionsQ = getChecksByCnameOptions({ path: { cname } });
toasts.addToast({
message: `Plugin 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: any[]) {
const validOptIds = new Set(adminOpts.map((opt) => opt.id));
const cleanedOptions: Record<string, any> = {};
for (const [key, value] of Object.entries(optionValues)) {
if (validOptIds.has(key)) {
cleanedOptions[key] = value;
}
}
saving = true;
try {
await putChecksByCnameOptions({
path: { cname },
body: { options: cleanedOptions },
});
checkerOptionsQ = getChecksByCnameOptions({ path: { cname } });
toasts.addToast({
message: `Orphaned options removed successfully`,
type: "success",
timeout: 5000,
});
} catch (error) {
toasts.addErrorToast({
message: "Failed to clean options: " + error,
timeout: 10000,
});
} finally {
saving = false;
}
}
function getOrphanedOptions(adminOpts: any[]): string[] {
const validOptIds = new Set(adminOpts.map((opt) => opt.id));
return Object.keys(optionValues).filter((key) => !validOptIds.has(key));
}
</script>
<Container class="flex-fill my-5">
<Row class="mb-4">
<Col>
<Button color="link" href="/checks" class="mb-2">
<Icon name="arrow-left"></Icon>
Back to checkers
</Button>
<h1 class="display-5">
<Icon name="puzzle-fill"></Icon>
{cname}
</h1>
</Col>
</Row>
{#await checkerStatusQ}
<Card body>
<p class="text-center mb-0">
<span class="spinner-border spinner-border-sm me-2"></span>
Loading checker status...
</p>
</Card>
{:then status}
{#if status}
<Row class="mb-4">
<Col md={6}>
<Card>
<CardHeader>
<strong>Checker Information</strong>
</CardHeader>
<CardBody>
<dl class="row mb-0">
<dt class="col-sm-4">Name:</dt>
<dd class="col-sm-8">{status.name}</dd>
<dt class="col-sm-4">Availability:</dt>
<dd class="col-sm-8">
{#if status.availableOn}
<div class="d-flex flex-wrap gap-1">
{#if status.availableOn.applyToDomain}
<Badge color="success">Domain-level</Badge>
{/if}
{#if status.availableOn.limitToProviders && status.availableOn.limitToProviders.length > 0}
<Badge color="primary">
Providers: {status.availableOn.limitToProviders.join(
", ",
)}
</Badge>
{/if}
{#if status.availableOn.limitToServices && status.availableOn.limitToServices.length > 0}
<Badge color="info">
Services: {status.availableOn.limitToServices.join(
", ",
)}
</Badge>
{/if}
{#if !status.availableOn.applyToDomain && (!status.availableOn.limitToProviders || status.availableOn.limitToProviders.length === 0) && (!status.availableOn.limitToServices || status.availableOn.limitToServices.length === 0)}
<Badge color="secondary">General</Badge>
{/if}
</div>
{:else}
<Badge color="secondary">General</Badge>
{/if}
</dd>
</dl>
</CardBody>
</Card>
</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 = status.options?.adminOpts || []}
{@const readOnlyOptGroups = [
{
key: "userOpts",
label: "User Options",
opts: status.options?.userOpts || [],
},
{
key: "domainOpts",
label: "Domain Options",
opts: status.options?.domainOpts || [],
},
{
key: "serviceOpts",
label: "Service Options",
opts: status.options?.serviceOpts || [],
},
{
key: "runOpts",
label: "Run Options",
opts: status.options?.runOpts || [],
},
]}
{@const hasAnyOpts =
adminOpts.length > 0 ||
readOnlyOptGroups.some((g) => g.opts.length > 0)}
{@const orphanedOpts = getOrphanedOptions(adminOpts)}
{#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(adminOpts)}
disabled={saving}
>
<Icon name="trash"></Icon>
Clean Up
</Button>
</div>
</Alert>
{/if}
{#if adminOpts.length > 0}
<Card class="mb-3">
<CardHeader>
<strong>Admin Options</strong>
</CardHeader>
<CardBody>
<Form on:submit={saveOptions}>
{#each adminOpts as optDoc}
{#if optDoc.id}
{@const optName = optDoc.id}
<FormGroup>
<Resource
edit={true}
index={optName}
specs={optDoc}
type={optDoc.type || "string"}
bind:value={optionValues[optName]}
/>
</FormGroup>
{/if}
{/each}
<div class="d-flex gap-2">
<Button type="submit" color="success" disabled={saving}>
{#if saving}
<span
class="spinner-border spinner-border-sm me-1"
></span>
{/if}
<Icon name="check-circle"></Icon>
Save Changes
</Button>
</div>
</Form>
</CardBody>
</Card>
{/if}
<CheckerOptionsGroups groups={readOnlyOptGroups} />
{#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,84 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-2026 happyDomain
Authors: Pierre-Olivier Mercier, et al.
This program is offered under a commercial and under the AGPL license.
For commercial licensing, contact us at <contact@happydomain.org>.
For AGPL licensing:
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import { Card, CardBody, CardHeader } from "@sveltestrap/sveltestrap";
interface OptionDef {
id?: string;
label?: string;
type?: string;
default?: unknown;
placeholder?: string;
description?: string;
required?: boolean;
}
interface OptionGroup {
label: string;
opts: OptionDef[];
}
interface Props {
groups: OptionGroup[];
}
let { groups }: Props = $props();
</script>
{#each groups as optGroup}
{#if optGroup.opts.length > 0}
<Card class="mb-3">
<CardHeader>
<strong>{optGroup.label}</strong>
<small class="text-muted ms-2">(Read-only)</small>
</CardHeader>
<CardBody>
<dl class="row mb-0">
{#each optGroup.opts as optDoc}
{@const optName = optDoc.id!}
<dt class="col-sm-4">
{optDoc.label || optDoc.id}:
</dt>
<dd class="col-sm-8">
{#if optDoc.default}
<span class="text-muted d-block">{optDoc.default}</span>
{:else if optDoc.placeholder}
<em class="text-muted d-block">{optDoc.placeholder}</em>
{/if}
{#if optDoc.description}
<small class="text-muted d-block">{optDoc.description}</small>
{/if}
<small class="text-muted">
Type: {optDoc.type || "string"}
</small>
{#if optDoc.required}
<small class="text-danger ms-2"> Required </small>
{/if}
</dd>
{/each}
</dl>
</CardBody>
</Card>
{/if}
{/each}