web-admin: Implement plugins interface with option editor
This commit is contained in:
parent
bf63fa5708
commit
b4bac7e7ec
5 changed files with 556 additions and 0 deletions
|
|
@ -111,6 +111,7 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.Engine) {
|
||||||
// Routes to virtual content
|
// Routes to virtual content
|
||||||
router.GET("/auth_users/*_", serveOrReverse("/", cfg))
|
router.GET("/auth_users/*_", serveOrReverse("/", cfg))
|
||||||
router.GET("/domains/*_", serveOrReverse("/", cfg))
|
router.GET("/domains/*_", serveOrReverse("/", cfg))
|
||||||
|
router.GET("/plugins/*_", serveOrReverse("/", cfg))
|
||||||
router.GET("/providers/*_", serveOrReverse("/", cfg))
|
router.GET("/providers/*_", serveOrReverse("/", cfg))
|
||||||
router.GET("/sessions/*_", serveOrReverse("/", cfg))
|
router.GET("/sessions/*_", serveOrReverse("/", cfg))
|
||||||
router.GET("/users/*_", serveOrReverse("/", cfg))
|
router.GET("/users/*_", serveOrReverse("/", cfg))
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,9 @@
|
||||||
<NavItem>
|
<NavItem>
|
||||||
<NavLink href="/sessions" active={page && page.url.pathname.startsWith('/sessions')}>Sessions</NavLink>
|
<NavLink href="/sessions" active={page && page.url.pathname.startsWith('/sessions')}>Sessions</NavLink>
|
||||||
</NavItem>
|
</NavItem>
|
||||||
|
<NavItem>
|
||||||
|
<NavLink href="/plugins" active={page && page.url.pathname.startsWith('/plugins')}>Plugins</NavLink>
|
||||||
|
</NavItem>
|
||||||
</Nav>
|
</Nav>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
</Navbar>
|
</Navbar>
|
||||||
|
|
|
||||||
143
web-admin/src/routes/plugins/+page.svelte
Normal file
143
web-admin/src/routes/plugins/+page.svelte
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
<!--
|
||||||
|
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 { getPluginsTests } from '$lib/api-admin';
|
||||||
|
|
||||||
|
let pluginsQ = $state(getPluginsTests());
|
||||||
|
|
||||||
|
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>
|
||||||
|
Plugins Management
|
||||||
|
</h1>
|
||||||
|
<p class="d-flex gap-3 align-items-center text-muted">
|
||||||
|
<span class="lead">
|
||||||
|
Manage all test plugins
|
||||||
|
</span>
|
||||||
|
{#await pluginsQ then pluginsR}
|
||||||
|
<span>Total: {Object.keys(pluginsR.data ?? {}).length} plugins</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 plugins..."
|
||||||
|
bind:value={searchQuery}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{#await pluginsQ}
|
||||||
|
Please wait...
|
||||||
|
{:then pluginsR}
|
||||||
|
{@const plugins = pluginsR.data}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<Table hover bordered>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Plugin Name</th>
|
||||||
|
<th>Version</th>
|
||||||
|
<th>Availability</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#if !plugins || Object.keys(plugins).length == 0}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="text-center text-muted py-2">
|
||||||
|
No plugins available
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{:else}
|
||||||
|
{#each Object.entries(plugins ?? {}).filter(([name, _info]) => name.toLowerCase().indexOf(searchQuery.toLowerCase()) > -1) as [pluginName, pluginInfo]}
|
||||||
|
<tr>
|
||||||
|
<td><strong>{pluginInfo.name || pluginName}</strong></td>
|
||||||
|
<td>{pluginInfo.version}</td>
|
||||||
|
<td>
|
||||||
|
{#if pluginInfo.availableOn}
|
||||||
|
{#if pluginInfo.availableOn.applyToDomain}
|
||||||
|
<Badge color="success">Domain</Badge>
|
||||||
|
{/if}
|
||||||
|
{#if pluginInfo.availableOn.limitToProviders && pluginInfo.availableOn.limitToProviders.length > 0}
|
||||||
|
<Badge color="primary" title={pluginInfo.availableOn.limitToProviders.join(', ')}>
|
||||||
|
Provider-specific
|
||||||
|
</Badge>
|
||||||
|
{/if}
|
||||||
|
{#if pluginInfo.availableOn.limitToServices && pluginInfo.availableOn.limitToServices.length > 0}
|
||||||
|
<Badge color="info" title={pluginInfo.availableOn.limitToServices.join(', ')}>
|
||||||
|
Service-specific
|
||||||
|
</Badge>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<Badge color="secondary">General</Badge>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="/plugins/{pluginName}" 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 plugins: {error.message}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
{/await}
|
||||||
|
</Container>
|
||||||
325
web-admin/src/routes/plugins/[pname]/+page.svelte
Normal file
325
web-admin/src/routes/plugins/[pname]/+page.svelte
Normal file
|
|
@ -0,0 +1,325 @@
|
||||||
|
<!--
|
||||||
|
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 { getPluginsTestsByPnameOptions, putPluginsTestsByPnameOptions } from "$lib/api-admin";
|
||||||
|
import { getPluginStatus } from "$lib/api/plugins";
|
||||||
|
import Resource from "$lib/components/inputs/Resource.svelte";
|
||||||
|
import PluginOptionsGroups from "$lib/components/plugins/PluginOptionsGroups.svelte";
|
||||||
|
|
||||||
|
let pname = $derived(page.params.pname!);
|
||||||
|
|
||||||
|
let pluginStatusQ = $derived(getPluginStatus(pname));
|
||||||
|
let pluginOptionsQ = $derived(getPluginsTestsByPnameOptions({ path: { pname } }));
|
||||||
|
let optionValues = $state<Record<string, any>>({});
|
||||||
|
let saving = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
pluginOptionsQ.then((optionsR) => {
|
||||||
|
optionValues = { ...((optionsR.data as Record<string, unknown>) || {}) };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function saveOptions() {
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
await putPluginsTestsByPnameOptions({
|
||||||
|
path: { pname },
|
||||||
|
body: { options: optionValues },
|
||||||
|
});
|
||||||
|
pluginOptionsQ = getPluginsTestsByPnameOptions({ path: { pname } });
|
||||||
|
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 putPluginsTestsByPnameOptions({
|
||||||
|
path: { pname },
|
||||||
|
body: { options: cleanedOptions },
|
||||||
|
});
|
||||||
|
pluginOptionsQ = getPluginsTestsByPnameOptions({ path: { pname } });
|
||||||
|
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="/plugins" class="mb-2">
|
||||||
|
<Icon name="arrow-left"></Icon>
|
||||||
|
Back to Plugins
|
||||||
|
</Button>
|
||||||
|
<h1 class="display-5">
|
||||||
|
<Icon name="puzzle-fill"></Icon>
|
||||||
|
{pname}
|
||||||
|
</h1>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{#await pluginStatusQ}
|
||||||
|
<Card body>
|
||||||
|
<p class="text-center mb-0">
|
||||||
|
<span class="spinner-border spinner-border-sm me-2"></span>
|
||||||
|
Loading plugin status...
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
{:then status}
|
||||||
|
{#if status}
|
||||||
|
<Row class="mb-4">
|
||||||
|
<Col md={6}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<strong>Plugin 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">Version:</dt>
|
||||||
|
<dd class="col-sm-8">{status.version}</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 pluginOptionsQ}
|
||||||
|
<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}
|
||||||
|
|
||||||
|
<PluginOptionsGroups groups={readOnlyOptGroups} />
|
||||||
|
|
||||||
|
{#if !hasAnyOpts}
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<Alert color="info" class="mb-0">
|
||||||
|
<Icon name="info-circle"></Icon>
|
||||||
|
This plugin 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: Plugin data not found
|
||||||
|
</Alert>
|
||||||
|
{/if}
|
||||||
|
{:catch error}
|
||||||
|
<Alert color="danger">
|
||||||
|
<Icon name="exclamation-triangle-fill"></Icon>
|
||||||
|
Error loading plugin: {error.message}
|
||||||
|
</Alert>
|
||||||
|
{/await}
|
||||||
|
</Container>
|
||||||
84
web/src/lib/components/plugins/PluginOptionsGroups.svelte
Normal file
84
web/src/lib/components/plugins/PluginOptionsGroups.svelte
Normal 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}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue