Merge branch 'worktree-agent-adf19957537754d9f'

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sir_melonchen
2026-10-14 14:51:58 +02:00
co-authored by Claude Opus 5.5
29 changed files with 5091 additions and 213 deletions
+12 -2
View File
@@ -165,7 +165,8 @@ describe('addresses', () => {
renderApp('/addresses');
const row = (await screen.findByText('192.0.2.150')).closest('tr');
if (!row) throw new Error('row not found');
await userEvent.click(within(row).getByRole('button', { name: 'Fest reservieren' }));
expect(within(row).getByText('aktiv')).toBeInTheDocument();
await userEvent.click(within(row).getByRole('button', { name: 'Als Reservierung übernehmen' }));
const dialog = await screen.findByRole('dialog');
expect(
@@ -257,8 +258,17 @@ describe('config preview', () => {
'GET /api/v1/config/preview': () => ({
body: {
dns: [],
dhcp: [{ path: 'kea-dhcp4.conf', content: '{"Dhcp4": {}}', create_only: false }],
dhcp: [
{
path: 'kea-dhcp4.conf',
content: '{"Dhcp4": {}}',
create_only: false,
target: '',
secret: false,
},
],
warnings: [{ code: 'dns.no_domain', params: { ip: '192.0.2.9', hostname: 'pc' } }],
targets: [],
dns_error: { code: 'dns_settings_missing' },
},
}),
+112 -124
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
import { QueryClientProvider } from '@tanstack/react-query';
import { useState, type ReactNode } from 'react';
import { lazy, useState, type ComponentType, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import {
createBrowserRouter,
@@ -16,20 +16,90 @@ import { SessionProvider } from './auth/session';
import { Layout } from './components/Layout';
import { Spinner } from './components/ui';
import { AccountPage, ForcedPasswordPage } from './pages/AccountPage';
import { AddressesPage } from './pages/AddressesPage';
import { AgentDetailPage, AgentsPage } from './pages/AgentsPage';
import { AuditPage } from './pages/AuditPage';
import { ConfigPage } from './pages/ConfigPage';
import { GeneratorSettingsPage } from './pages/GeneratorSettingsPage';
import { LeasesPage } from './pages/LeasesPage';
import { LicensePage } from './pages/LicensePage';
import { LoginPage } from './pages/LoginPage';
import { NetworkDetailPage, NetworksPage } from './pages/NetworksPage';
import { OIDCSettingsPage } from './pages/OIDCSettingsPage';
import { RevisionDetailPage, RevisionsPage } from './pages/RevisionsPage';
import { UsersPage } from './pages/UsersPage';
import { createQueryClient } from './queryClient';
/**
* Pages behind the login are split into chunks of their own and loaded on
* first use; Layout shows a spinner meanwhile (Suspense).
*/
function lazyPage<M>(load: () => Promise<M>, pick: (m: M) => ComponentType) {
return lazy(() => load().then((m) => ({ default: pick(m) })));
}
const AddressesPage = lazyPage(
() => import('./pages/AddressesPage'),
(m) => m.AddressesPage,
);
const AgentsPage = lazyPage(
() => import('./pages/AgentsPage'),
(m) => m.AgentsPage,
);
const AgentDetailPage = lazyPage(
() => import('./pages/AgentsPage'),
(m) => m.AgentDetailPage,
);
const AuditPage = lazyPage(
() => import('./pages/AuditPage'),
(m) => m.AuditPage,
);
const ClustersPage = lazyPage(
() => import('./pages/ClustersPage'),
(m) => m.ClustersPage,
);
const ConfigPage = lazyPage(
() => import('./pages/ConfigPage'),
(m) => m.ConfigPage,
);
const GeneratorSettingsPage = lazyPage(
() => import('./pages/GeneratorSettingsPage'),
(m) => m.GeneratorSettingsPage,
);
const LeasesPage = lazyPage(
() => import('./pages/LeasesPage'),
(m) => m.LeasesPage,
);
const LicensePage = lazyPage(
() => import('./pages/LicensePage'),
(m) => m.LicensePage,
);
const NetworksPage = lazyPage(
() => import('./pages/NetworksPage'),
(m) => m.NetworksPage,
);
const NetworkDetailPage = lazyPage(
() => import('./pages/NetworksPage'),
(m) => m.NetworkDetailPage,
);
const OIDCSettingsPage = lazyPage(
() => import('./pages/OIDCSettingsPage'),
(m) => m.OIDCSettingsPage,
);
const RevisionsPage = lazyPage(
() => import('./pages/RevisionsPage'),
(m) => m.RevisionsPage,
);
const RevisionDetailPage = lazyPage(
() => import('./pages/RevisionsPage'),
(m) => m.RevisionDetailPage,
);
const TSIGKeysPage = lazyPage(
() => import('./pages/TSIGKeysPage'),
(m) => m.TSIGKeysPage,
);
const UsersPage = lazyPage(
() => import('./pages/UsersPage'),
(m) => m.UsersPage,
);
const ZonesPage = lazyPage(
() => import('./pages/ZonesPage'),
(m) => m.ZonesPage,
);
const ZoneDetailPage = lazyPage(
() => import('./pages/ZonesPage'),
(m) => m.ZoneDetailPage,
);
/** Redirects to the login page unless a complete session exists. */
function RequireSession() {
const { session, loading } = useSession();
@@ -60,6 +130,18 @@ function NotFound() {
);
}
/** A route rendering Page for users with the permission. */
function guarded(path: string, perm: Permission, Page: ComponentType): RouteObject {
return {
path,
element: (
<RequirePermission perm={perm}>
<Page />
</RequirePermission>
),
};
}
export const routes: RouteObject[] = [
{ path: '/login', element: <LoginPage /> },
{
@@ -70,118 +152,24 @@ export const routes: RouteObject[] = [
element: <Layout />,
children: [
{ index: true, element: <Home /> },
{
path: 'networks',
element: (
<RequirePermission perm="ipam.read">
<NetworksPage />
</RequirePermission>
),
},
{
path: 'networks/:id',
element: (
<RequirePermission perm="ipam.read">
<NetworkDetailPage />
</RequirePermission>
),
},
{
path: 'addresses',
element: (
<RequirePermission perm="ipam.read">
<AddressesPage />
</RequirePermission>
),
},
{
path: 'license',
element: (
<RequirePermission perm="license.read">
<LicensePage />
</RequirePermission>
),
},
{
path: 'users',
element: (
<RequirePermission perm="users.admin">
<UsersPage />
</RequirePermission>
),
},
{
path: 'audit',
element: (
<RequirePermission perm="audit.read">
<AuditPage />
</RequirePermission>
),
},
{
path: 'settings/oidc',
element: (
<RequirePermission perm="settings.admin">
<OIDCSettingsPage />
</RequirePermission>
),
},
{
path: 'config',
element: (
<RequirePermission perm="ipam.read">
<ConfigPage />
</RequirePermission>
),
},
{
path: 'revisions',
element: (
<RequirePermission perm="ipam.read">
<RevisionsPage />
</RequirePermission>
),
},
{
path: 'revisions/:number',
element: (
<RequirePermission perm="ipam.read">
<RevisionDetailPage />
</RequirePermission>
),
},
{
path: 'agents',
element: (
<RequirePermission perm="ipam.read">
<AgentsPage />
</RequirePermission>
),
},
{
path: 'agents/:id',
element: (
<RequirePermission perm="ipam.read">
<AgentDetailPage />
</RequirePermission>
),
},
{
path: 'leases',
element: (
<RequirePermission perm="ipam.read">
<LeasesPage />
</RequirePermission>
),
},
{
path: 'settings/generator',
element: (
<RequirePermission perm="settings.admin">
<GeneratorSettingsPage />
</RequirePermission>
),
},
guarded('networks', 'ipam.read', NetworksPage),
guarded('networks/:id', 'ipam.read', NetworkDetailPage),
guarded('addresses', 'ipam.read', AddressesPage),
guarded('zones', 'ipam.read', ZonesPage),
guarded('zones/:id', 'ipam.read', ZoneDetailPage),
guarded('license', 'license.read', LicensePage),
guarded('users', 'users.admin', UsersPage),
guarded('audit', 'audit.read', AuditPage),
guarded('settings/oidc', 'settings.admin', OIDCSettingsPage),
guarded('config', 'ipam.read', ConfigPage),
guarded('revisions', 'ipam.read', RevisionsPage),
guarded('revisions/:number', 'ipam.read', RevisionDetailPage),
guarded('agents', 'ipam.read', AgentsPage),
guarded('agents/:id', 'ipam.read', AgentDetailPage),
guarded('clusters', 'ipam.read', ClustersPage),
guarded('leases', 'ipam.read', LeasesPage),
guarded('settings/generator', 'settings.admin', GeneratorSettingsPage),
guarded('settings/tsig-keys', 'settings.admin', TSIGKeysPage),
{ path: 'account', element: <AccountPage /> },
{ path: '*', element: <NotFound /> },
],
Generated Vendored
+954
View File
File diff suppressed because it is too large Load Diff
+56
View File
@@ -658,6 +658,62 @@ details.file summary {
cursor: pointer;
}
div.file-secret {
border-top: 1px solid var(--border);
padding: 6px 0;
}
.target-group + .target-group {
margin-top: 12px;
}
.target-group h3 {
margin: 8px 0 4px;
font-size: 0.95rem;
}
/* ---- Clusters, zones, records ---------------------------------------- */
.member-row {
display: grid;
grid-template-columns: minmax(10rem, 2fr) minmax(7rem, 1fr) minmax(9rem, 1.5fr) auto;
gap: 8px;
align-items: start;
margin-bottom: 6px;
}
.member-list {
list-style: none;
margin: 0;
padding: 0;
}
.member-list li + li {
margin-top: 2px;
}
.text-row {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 6px;
}
.text-row input {
flex: 1;
}
.secret-value {
word-break: break-all;
white-space: pre-wrap;
}
.check-list {
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
}
.file-content {
max-height: 480px;
overflow: auto;
+128
View File
@@ -0,0 +1,128 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
import { useQuery } from '@tanstack/react-query';
import type { TFunction } from 'i18next';
import { useTranslation } from 'react-i18next';
import { api, unwrap, type Schemas } from './api/client';
export type Cluster = Schemas['Cluster'];
export type AgentRole = Schemas['AgentRole'];
export type MemberRole = Schemas['MemberRole'];
type Agent = Schemas['Agent'];
type RevisionTarget = Schemas['RevisionTarget'];
export const clustersKey = ['clusters'] as const;
export const tsigKeysKey = ['tsig-keys'] as const;
export const zonesKey = ['zones'] as const;
/** All clusters of both roles (sorted by role and name by the server). */
export function useClusters(enabled = true) {
return useQuery({
queryKey: clustersKey,
queryFn: () => unwrap(api.GET('/clusters')),
enabled,
});
}
/** TSIG keys without secrets. */
export function useTSIGKeys(enabled = true) {
return useQuery({
queryKey: tsigKeysKey,
queryFn: () => unwrap(api.GET('/tsig-keys')),
enabled,
});
}
/** The agent list; shares its cache with the agents page. */
export function useAgents(enabled = true) {
return useQuery({
queryKey: ['agents'],
queryFn: () => unwrap(api.GET('/agents')),
enabled,
});
}
/** The IP part of a remote address such as "192.0.2.53:41234" or "[2001:db8::1]:41234". */
export function hostOf(remote: string | undefined): string {
if (!remote) return '';
const v6 = /^\[([^\]]+)\](?::\d+)?$/.exec(remote);
if (v6) return v6[1] ?? '';
const v4 = /^([^:]+):\d+$/.exec(remote);
return v4 ? (v4[1] ?? '') : remote;
}
/**
* Label of a generated-file target: the default group, or
* "Cluster X · Primary · host". Names come from the revision's target
* description, hostnames from the agent list (fallback: the agent ID).
*/
export function targetLabel(
t: TFunction,
target: string,
info: RevisionTarget | undefined,
agent: Agent | undefined,
): string {
if (target === '') return t('targets.default');
const clusterName = info ? info.cluster_name : agent?.cluster?.name;
const memberRole = info ? info.member_role : agent?.cluster?.role;
const parts: string[] = [];
if (clusterName) parts.push(t('targets.cluster', { name: clusterName }));
if (memberRole) parts.push(t(`clusters.memberRoles.${memberRole}`));
parts.push(agent?.hostname || target);
return parts.join(' · ');
}
/** Whether files belong to cluster targets (only then are they grouped). */
export function hasClusterTargets(files: { target: string }[]): boolean {
return files.some((f) => f.target !== '');
}
export interface TargetGroup<F> {
role: AgentRole;
target: string;
files: F[];
}
/**
* Groups files by role and target: the default group first, then the
* targets in the order of the revision's target list.
*/
export function groupByTarget<F extends { target: string }>(
files: F[],
roleOf: (f: F) => AgentRole,
targets: RevisionTarget[] = [],
): TargetGroup<F>[] {
const groups = new Map<string, TargetGroup<F>>();
for (const f of files) {
const role = roleOf(f);
const key = `${role} ${f.target}`;
const g = groups.get(key);
if (g) g.files.push(f);
else groups.set(key, { role, target: f.target, files: [f] });
}
const order = targets.map((x) => `${x.role} ${x.target}`);
const rank = (g: TargetGroup<F>) => {
const i = order.indexOf(`${g.role} ${g.target}`);
return (
(g.role === 'dns' ? 0 : 2) + (g.target === '' ? 0 : 1) + (i < 0 ? order.length : i) / 1e6
);
};
return [...groups.values()].sort((a, b) => rank(a) - rank(b));
}
/**
* Returns a labeller for targets. The agent list is only fetched when
* needed (cluster targets present).
*/
export function useTargetLabels(targets: RevisionTarget[], needed: boolean) {
const { t } = useTranslation();
const agents = useAgents(needed);
const list = agents.data?.items ?? [];
return (role: AgentRole, target: string) =>
targetLabel(
t,
target,
targets.find((x) => x.role === role && x.target === target),
list.find((a) => a.id === target),
);
}
+36
View File
@@ -0,0 +1,36 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
import { useClusters, type AgentRole } from '../clusters';
interface Props {
id: string;
role: AgentRole;
/** Cluster ID; null for the default. */
value: string | null;
onChange: (value: string | null) => void;
/** Text of the empty option (default cluster or default group). */
emptyLabel: string;
}
/** Select of the clusters of one role with an empty option for the default. */
export function ClusterSelect({ id, role, value, onChange, emptyLabel }: Props) {
const clusters = useClusters();
const list = (clusters.data?.items ?? []).filter((c) => c.role === role);
return (
<select
id={id}
value={value ?? ''}
onChange={(e) => {
onChange(e.target.value || null);
}}
>
<option value="">{emptyLabel}</option>
{list.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
{/* Keep an unknown value selectable until the list has loaded. */}
{value && !list.some((c) => c.id === value) && <option value={value}>{value}</option>}
</select>
);
}
+55 -8
View File
@@ -3,11 +3,13 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { Schemas } from '../api/client';
import { groupByTarget, hasClusterTargets, useTargetLabels } from '../clusters';
import { parseUnifiedDiff, type DiffLineKind } from '../diff';
import { Badge } from './ui';
type FileDiff = Schemas['FileDiff'];
type DiffStatus = FileDiff['status'];
type RevisionTarget = Schemas['RevisionTarget'];
const statusKind: Record<DiffStatus, string> = {
added: 'success',
@@ -20,9 +22,30 @@ const sign: Record<DiffLineKind, string> = { add: '+', del: '−', ctx: '', hunk
/** Up to this many changed lines in total, all files start expanded. */
const autoOpenLines = 400;
/**
* Secret files (key material) come without diff text (ADR 0023); every
* other changed file has at least one hunk.
*/
function isSecret(file: FileDiff): boolean {
return file.diff === '';
}
function FileDiffView({ file, defaultOpen }: { file: FileDiff; defaultOpen: boolean }) {
const { t } = useTranslation();
const [open, setOpen] = useState(defaultOpen);
if (isSecret(file)) {
return (
<div className="file-diff">
<div className="file-diff-head">
<span className="mono">{file.path}</span>
<Badge>{t(`agents.roles.${file.role}`)}</Badge>
<Badge kind={statusKind[file.status]}>{t(`diff.status.${file.status}`)}</Badge>
<Badge kind="warning">{t('config.secret')}</Badge>
<span className="muted">{t('config.secretContent')}</span>
</div>
</div>
);
}
return (
<div className="file-diff">
<div className="file-diff-head">
@@ -79,19 +102,43 @@ function FileDiffView({ file, defaultOpen }: { file: FileDiff; defaultOpen: bool
);
}
/** Unified diffs per file (revision comparison or pending changes). */
export function DiffView({ files, emptyText }: { files: FileDiff[]; emptyText?: string }) {
/**
* Unified diffs per file (revision comparison or pending changes). With
* cluster targets, files are grouped per role and target ("default group",
* "Cluster X · Primary · host").
*/
export function DiffView({
files,
emptyText,
targets = [],
}: {
files: FileDiff[];
emptyText?: string;
/** Target descriptions for the labels (e.g. of the revision shown). */
targets?: RevisionTarget[];
}) {
const { t } = useTranslation();
const grouped = hasClusterTargets(files);
const label = useTargetLabels(targets, grouped);
if (files.length === 0) return <p>{emptyText ?? t('diff.empty')}</p>;
const total = files.reduce((n, f) => n + f.inserted + f.deleted, 0);
const view = (f: FileDiff) => (
<FileDiffView
key={`${f.role} ${f.target} ${f.path}`}
file={f}
defaultOpen={total <= autoOpenLines || f.inserted + f.deleted <= 40}
/>
);
if (!grouped) return <div className="diff-files">{files.map(view)}</div>;
return (
<div className="diff-files">
{files.map((f) => (
<FileDiffView
key={`${f.role} ${f.path}`}
file={f}
defaultOpen={total <= autoOpenLines || f.inserted + f.deleted <= 40}
/>
{groupByTarget(files, (f) => f.role, targets).map((g) => (
<div key={`${g.role} ${g.target}`} className="target-group">
<h3>
{t(`agents.roles.${g.role}`)} · {label(g.role, g.target)}
</h3>
{g.files.map(view)}
</div>
))}
</div>
);
+9 -1
View File
@@ -1,4 +1,5 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
import { Suspense } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, NavLink, Outlet, useNavigate } from 'react-router';
@@ -11,6 +12,7 @@ import { reasonText, useLicense } from '../license';
import { LiveContext, useLiveEvents } from '../live';
import { usePendingChanges } from '../pending';
import { themes, useTheme, type Theme } from '../theme';
import { Spinner } from './ui';
const themeLabelKey = {
system: 'settings.themeSystem',
@@ -104,13 +106,16 @@ export function Layout() {
{ to: '/networks', label: t('nav.networks'), show: can('ipam.read') },
{ to: '/addresses', label: t('nav.addresses'), show: can('ipam.read') },
{ to: '/leases', label: t('nav.leases'), show: can('ipam.read') },
{ to: '/zones', label: t('nav.zones'), show: can('ipam.read') },
{ to: '/config', label: t('nav.config'), show: can('ipam.read') },
{ to: '/revisions', label: t('nav.revisions'), show: can('ipam.read') },
{ to: '/agents', label: t('nav.agents'), show: can('ipam.read') },
{ to: '/clusters', label: t('nav.clusters'), show: can('ipam.read') },
{ to: '/license', label: t('nav.license'), show: can('license.read') },
{ to: '/users', label: t('nav.users'), show: can('users.admin') },
{ to: '/audit', label: t('nav.audit'), show: can('audit.read') },
{ to: '/settings/generator', label: t('nav.generator'), show: can('settings.admin') },
{ to: '/settings/tsig-keys', label: t('nav.tsigKeys'), show: can('settings.admin') },
{ to: '/settings/oidc', label: t('nav.settings'), show: can('settings.admin') },
];
@@ -147,7 +152,10 @@ export function Layout() {
<LicenseBanner />
{can('ipam.read') && <PendingBanner />}
<main className="app-main">
<Outlet />
{/* Pages are loaded on demand (code splitting, see App.tsx). */}
<Suspense fallback={<Spinner />}>
<Outlet />
</Suspense>
</main>
<footer className="app-footer">
<span>
+20 -1
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
import { useEffect, useId, useRef, type ReactNode } from 'react';
import { useEffect, useId, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { ApiError } from '../api/client';
@@ -187,6 +187,25 @@ export function UsageBar({ used, total, label }: { used: number; total: number;
);
}
/** Copies text to the clipboard; hidden without clipboard access. */
export function CopyButton({ text }: { text: string }) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
if (!('clipboard' in navigator)) return null;
return (
<button
type="button"
onClick={() => {
void navigator.clipboard.writeText(text).then(() => {
setCopied(true);
});
}}
>
{copied ? t('common.copied') : t('common.copy')}
</button>
);
}
export function Spinner() {
const { t } = useTranslation();
return (
+2
View File
@@ -46,6 +46,7 @@ const noPending = {
const fileDiff = {
role: 'dns',
target: '',
path: 'zones/db.office.test',
status: 'modified',
diff: '@@ -2,3 +2,3 @@\n $TTL 3600\n-printer A 192.0.2.10\n+printer A 192.0.2.11\n ns1 A 192.0.2.53\n',
@@ -104,6 +105,7 @@ function revision(number: number) {
dns: [],
dhcp: [],
warnings: [],
targets: [],
};
}
+10 -2
View File
@@ -33,9 +33,17 @@ function parse(data: unknown): Record<string, unknown> {
}
}
/**
* Configuration data any user can change; the server signals such changes
* as "pending" (they wait for the next revision).
*/
const configKeys = ['config-pending', 'config-preview', 'clusters', 'tsig-keys', 'zones'];
function invalidateAgent(qc: QueryClient, id: unknown) {
void qc.invalidateQueries({ queryKey: ['agents'], exact: true });
if (typeof id === 'string' && id !== '') void qc.invalidateQueries({ queryKey: ['agents', id] });
// Cluster members show the connection state of their agents.
void qc.invalidateQueries({ queryKey: ['clusters'] });
}
/** Query invalidations per event type. Events carry IDs only. */
@@ -53,13 +61,13 @@ const handlers: Record<string, (qc: QueryClient, data: Record<string, unknown>)
void qc.invalidateQueries({ queryKey: ['agents'] });
},
pending: (qc) => {
void qc.invalidateQueries({ queryKey: ['config-pending'] });
for (const key of configKeys) void qc.invalidateQueries({ queryKey: [key] });
},
};
/** Everything an event could have changed, after missing events while offline. */
function invalidateAll(qc: QueryClient) {
for (const key of ['agents', 'deployments', 'revisions', 'config-pending']) {
for (const key of ['agents', 'deployments', 'revisions', ...configKeys]) {
void qc.invalidateQueries({ queryKey: [key] });
}
}
+247 -10
View File
@@ -18,7 +18,10 @@
"generator": "DNS/DHCP-Einstellungen",
"agents": "Agents",
"revisions": "Ausrollen",
"leases": "Leases"
"leases": "Leases",
"zones": "Zonen",
"clusters": "Cluster",
"tsigKeys": "TSIG-Schlüssel"
},
"common": {
"save": "Speichern",
@@ -200,7 +203,9 @@
"deleteForce": "Das Netz enthält {{count}} Adressen. Netz und alle Adressen löschen?",
"created": "Netz angelegt.",
"updated": "Netz gespeichert.",
"deleted": "Netz gelöscht."
"deleted": "Netz gelöscht.",
"dhcpCluster": "DHCP-Cluster",
"dhcpClusterHint": "Welcher DHCP-Cluster dieses Netz bedient. Leer: der Standard-Cluster aus den DNS/DHCP-Einstellungen bzw. alle DHCP-Server ohne Cluster."
},
"mapState": {
"free": "frei",
@@ -274,7 +279,8 @@
"updated": "Adresse gespeichert.",
"deleted": "Adresse gelöscht.",
"deleteConfirm": "Adresse {{ip}} löschen?",
"toReservation": "Fest reservieren"
"toReservation": "Als Reservierung übernehmen",
"leaseExpires": "Lease läuft ab: {{time}}"
},
"effects": {
"dns.add_records": "DNS: A- und PTR-Record {{fqdn}} → {{ip}} werden angelegt.",
@@ -400,7 +406,20 @@
"revision_limit": "Die höchste mögliche Revisionsnummer ist erreicht.",
"rollback_required": "Ältere Revisionen lassen sich nicht auf Agents im Modus „Automatisch holen“ ausrollen – sie würden sofort wieder auf die neueste Revision wechseln. Verwenden Sie „Auf diese Revision zurücksetzen“ (erzeugt eine neue Revision mit diesem Inhalt) oder schalten Sie die betroffenen Agents auf „Manuell“.",
"rollback_unavailable": "Diese Revision kann nicht wiederhergestellt werden, weil sie vor der Rollback-Funktion erzeugt wurde und ihre Eingabedaten fehlen.",
"events_unavailable": "Live-Aktualisierungen sind auf diesem Server nicht verfügbar."
"events_unavailable": "Live-Aktualisierungen sind auf diesem Server nicht verfügbar.",
"cluster_in_use": "Der Cluster wird noch verwendet (Zonen, Netze oder als Standard-Cluster in den DNS/DHCP-Einstellungen).",
"cluster_role_mismatch": "Der Agent bzw. Cluster hat die falsche Rolle (DNS/DHCP).",
"agent_in_other_cluster": "Der Agent gehört bereits zu einem anderen Cluster.",
"cluster_primary_exists": "Der Cluster hat bereits einen Primary.",
"cluster_member_limit": "Ein DHCP-Cluster hat höchstens einen Primary und einen Secondary.",
"key_in_use": "Der Schlüssel wird noch von Zonen für dynamische Updates verwendet.",
"key_owned_by_cluster": "Das ist der Transfer-Schlüssel eines Clusters; er wird mit dem Cluster gelöscht.",
"key_exists": "Ein Schlüssel mit diesem Namen existiert bereits.",
"invalid_key_name": "Ungültiger Schlüsselname.",
"zone_exists": "Diese Zone existiert bereits.",
"zone_kind_mismatch": "Name und Art passen nicht zusammen: Reverse-Zonen enden auf in-addr.arpa oder ip6.arpa, Forward-Zonen nicht.",
"zone_not_empty": "Die Zone enthält noch {{count}} Records.",
"record_exists": "Diesen Record gibt es in der Zone bereits."
},
"problems": {
"required": "Pflichtfeld.",
@@ -475,7 +494,19 @@
"out_of_range": "Der Wert liegt außerhalb des erlaubten Bereichs.",
"rollback_required": "{{hostname}} hat bereits Revision {{applied_revision}} oder läuft im Modus „Automatisch holen“; eine ältere Revision dorthin nur per Rollback.",
"unknown_agent": "Unbekannter Agent.",
"agent_revoked": "Der Agent ist gesperrt."
"agent_revoked": "Der Agent ist gesperrt.",
"cluster_role_mismatch": "Falsche Rolle (DNS/DHCP).",
"agent_in_other_cluster": "Gehört bereits zu einem anderen Cluster.",
"cluster_primary_exists": "Es gibt bereits einen Primary.",
"cluster_member_limit": "Höchstens ein Primary und ein Secondary.",
"key_exists": "Name bereits vergeben.",
"invalid_key_name": "Ungültiger Schlüsselname (Kleinbuchstaben, Ziffern, - und .).",
"zone_exists": "Diese Zone existiert bereits.",
"zone_kind_mismatch": "Passt nicht zur Art der Zone.",
"record_exists": "Diesen Record gibt es bereits.",
"invalid_label": "Ungültiger Name (Labels aus a–z, 0–9, - und _, höchstens 63 Zeichen).",
"invalid_txt": "Ungültiger Text.",
"invalid_port": "Port zwischen 1 und 65535."
},
"config": {
"title": "Erzeugte Konfiguration",
@@ -486,7 +517,9 @@
"warnings": "Hinweise",
"empty": "Keine Dateien.",
"copy": "Kopieren",
"settingsLink": "DNS/DHCP-Einstellungen"
"settingsLink": "DNS/DHCP-Einstellungen",
"secret": "geheim",
"secretContent": "geheimer Inhalt (nicht angezeigt)"
},
"generatorErrors": {
"dns_settings_missing": "Für DNS fehlen noch Primär-Nameserver, Hostmaster und Nameserver in den DNS/DHCP-Einstellungen.",
@@ -500,7 +533,13 @@
"dhcp.no_subnet_id": "Netz {{cidr}} hat keine Kea-Subnet-ID und wird übersprungen.",
"dhcp.reservation_without_mac": "Reservierung {{ip}} ohne MAC-Adresse übersprungen.",
"dns.zone_conflict": "Die Reverse-Zone {{zone}} entfällt, weil eine Forward-Zone gleichen Namens existiert.",
"dns.duplicate_ptr": "Für {{ip}} gibt es mehrere Namen; {{fqdn}} erhält keinen PTR-Eintrag."
"dns.duplicate_ptr": "Für {{ip}} gibt es mehrere Namen; {{fqdn}} erhält keinen PTR-Eintrag.",
"dns.no_zone": "{{fqdn}} ({{ip}}): Es gibt keine passende Zone, daher kein DNS-Eintrag.",
"dns.cname_conflict": "{{name}} in Zone {{zone}}: Ein CNAME darf nicht neben anderen Daten stehen; der Konflikt wurde verworfen.",
"dns.unknown_cluster": "Zone {{zone}} verweist auf einen unbekannten Cluster und wird übersprungen.",
"dns.cluster_without_primary": "Cluster {{cluster}} hat keinen Primary und bedient daher keine Zonen.",
"dns.unknown_key": "Zone {{zone}}: Der TSIG-Schlüssel {{key}} ist unbekannt und wird ignoriert.",
"dhcp.unknown_cluster": "Netz {{cidr}} verweist auf einen unbekannten DHCP-Cluster und wird übersprungen."
},
"generator": {
"title": "DNS/DHCP-Einstellungen",
@@ -529,7 +568,13 @@
"leaseFile": "Lease-Datei (Kea)",
"hooksDir": "Hook-Verzeichnis (Kea)",
"hooksDirHint": "Leer lassen für das einkompilierte Verzeichnis (Pflicht ab Kea 3.0).",
"invalidLine": "Ungültige Zeile: {{line}}"
"invalidLine": "Ungültige Zeile: {{line}}",
"defaultDNSCluster": "Standard-DNS-Cluster",
"defaultDNSClusterHint": "Zonen ohne eigenen Cluster werden von diesem Cluster bedient. Leer: Standardgruppe (alle DNS-Server ohne Cluster, jede Zone als Primary).",
"defaultDHCPCluster": "Standard-DHCP-Cluster",
"defaultDHCPClusterHint": "Netze ohne eigenen Cluster werden von diesem Cluster bedient. Leer: Standardgruppe (alle DHCP-Server ohne Cluster, ohne HA).",
"haPasswordFile": "HA-Passwortdatei (Kea)",
"haPasswordFileHint": "Pfad der Passwortdatei für Kea-HA auf den DHCP-Servern; leer für /etc/kea/ddi-suite-ha-password."
},
"agents": {
"title": "Agents",
@@ -573,7 +618,8 @@
"revoke": "Sperren",
"revokeConfirm": "Agent {{host}} sperren? Die Verbindung wird sofort getrennt; der Agent braucht danach eine neue Registrierung. BIND bzw. Kea laufen mit der letzten Konfiguration weiter.",
"deleteConfirm": "Agent {{host}} löschen? Er wird gesperrt und mit seinen Deployments und Leases entfernt.",
"deleted": "Der Agent wurde gelöscht."
"deleted": "Der Agent wurde gelöscht.",
"cluster": "Cluster"
},
"tokens": {
"title": "Registrierungs-Tokens",
@@ -707,7 +753,7 @@
},
"leases": {
"title": "Leases",
"hint": "Von den DHCP-Agents gemeldete Leases. Die automatische Übernahme in die Adresstabelle folgt mit M5.",
"hint": "Von den DHCP-Agents gemeldete Leases. Aktive Leases in einem Pool werden automatisch als dynamische Adressen in die Adresstabelle übernommen.",
"network": "Netz",
"allNetworks": "Alle Netze",
"state": "Zustand",
@@ -726,5 +772,196 @@
"expires": "Läuft ab",
"updated": "Letzte Aktivität",
"subnet": "Subnetz-ID {{id}}"
},
"targets": {
"default": "Standardgruppe",
"cluster": "Cluster {{name}}"
},
"clusters": {
"title": "Cluster",
"hint": "Cluster fassen DNS-Server zu Primary und Secondaries (NOTIFY, Zonentransfer mit TSIG) und DHCP-Server zu einem HA-Paar (Kea-HA) zusammen. Server ohne Cluster bilden die Standardgruppe und erhalten alle Zonen bzw. Netze ohne Cluster. Änderungen wirken mit der nächsten Revision.",
"section": {
"dns": "DNS-Cluster",
"dhcp": "DHCP-Cluster"
},
"empty": {
"dns": "Noch kein DNS-Cluster. Alle DNS-Server bedienen jede Zone als Primary.",
"dhcp": "Noch kein DHCP-Cluster. Alle DHCP-Server arbeiten als Einzelserver."
},
"newDNS": "DNS-Cluster anlegen",
"newDHCP": "DHCP-Cluster anlegen",
"editTitle": "Cluster {{name}} bearbeiten",
"name": "Name",
"description": "Beschreibung",
"members": "Mitglieder",
"options": "Einstellungen",
"usage": "Verwendung",
"noMembers": "Keine Mitglieder.",
"noCluster": "kein Cluster",
"agent": "Agent",
"pickAgent": "– Agent wählen –",
"memberRole": "Rolle im Cluster",
"memberRoles": {
"primary": "Primary",
"secondary": "Secondary"
},
"address": "Adresse",
"addressHint": "Adresse: die IP, unter der die anderen Mitglieder diesen Server erreichen (DNS: Port 53 für NOTIFY und Zonentransfer, DHCP: HA-Port). Vorgeschlagen wird die Adresse, von der sich der Agent verbindet.",
"addMember": "Mitglied hinzufügen",
"removeMember": "Entfernen",
"removeMemberN": "Mitglied {{n}} entfernen",
"membersHintDNS": "Ein Primary, beliebig viele Secondaries. Zur Auswahl stehen DNS-Agents, die keinem anderen Cluster angehören.",
"membersHintDHCP": "Höchstens ein Primary und ein Secondary. Mit beiden entsteht ein HA-Paar, mit einem Mitglied ein Einzelserver. Zur Auswahl stehen DHCP-Agents, die keinem anderen Cluster angehören.",
"agentRequired": "Bitte einen Agent wählen.",
"addressRequired": "Bitte die Adresse angeben.",
"dnsOnePrimary": "Ein DNS-Cluster hat genau einen Primary.",
"dhcpPair": "Ein DHCP-Cluster hat höchstens einen Primary und einen Secondary.",
"noPrimaryHint": "Ohne Primary bedient der Cluster keine Zonen.",
"dnsOptions": "DNS-Einstellungen des Clusters",
"dnsOptionsHint": "Optional: SOA-Primärserver und NS-Records der Zonen dieses Clusters. Leer: Werte aus den DNS/DHCP-Einstellungen.",
"dhcpOptions": "Hochverfügbarkeit (Kea-HA)",
"dhcpOptionsHint": "Gilt, sobald Primary und Secondary eingetragen sind. Erfordert Kea 3.0 oder neuer; Benutzer und Passwort für die Peers erzeugt der Server.",
"haMode": "HA-Modus",
"haModes": {
"hot-standby": "Hot-Standby",
"load-balancing": "Lastverteilung"
},
"haModeHelp": {
"hot-standby": "Der Primary vergibt alle Leases, der Secondary übernimmt bei Ausfall.",
"load-balancing": "Beide Server vergeben Leases und teilen sich die Clients."
},
"haPort": "HA-Port",
"transferKey": "Transfer-Schlüssel",
"fromSettings": "aus den Einstellungen",
"singleServer": "Einzelserver (kein HA)",
"zoneCount_one": "{{count}} Zone",
"zoneCount_other": "{{count}} Zonen",
"networkCount_one": "{{count}} Netz",
"networkCount_other": "{{count}} Netze",
"deleteConfirm": "Cluster {{name}} löschen? Die Mitglieder gehören danach zur Standardgruppe; der Transfer-Schlüssel wird gelöscht.",
"defaultCluster": "Standard",
"defaultGroup": "keiner (Standardgruppe)"
},
"tsig": {
"title": "TSIG-Schlüssel",
"hint": "TSIG-Schlüssel sichern dynamische Updates (z. B. von nsupdate oder den DHCP-Servern der Domain Controller) und Zonentransfers. Die Geheimnisse erzeugt der Server; die DNS-Server erhalten sie mit der nächsten Revision.",
"new": "Schlüssel anlegen",
"editTitle": "Schlüssel {{name}} bearbeiten",
"empty": "Noch keine TSIG-Schlüssel.",
"name": "Name",
"nameHint": "Kleinbuchstaben, Ziffern, Bindestriche und Punkte, z. B. dc1.example.com.",
"nameImmutable": "Name und Algorithmus können nicht geändert werden.",
"algorithm": "Algorithmus",
"description": "Beschreibung",
"createHint": "Das Geheimnis wird zufällig erzeugt und kann danach angezeigt werden.",
"usage": "Verwendung",
"transferKey": "Transfer-Schlüssel",
"ofCluster": "von Cluster {{name}}",
"zones_one": "Updates in {{count}} Zone",
"zones_other": "Updates in {{count}} Zonen",
"unused": "nicht verwendet",
"rotatedAt": "Geheimnis vom",
"showSecret": "Geheimnis anzeigen",
"rotate": "Erneuern",
"rotateConfirm": "Neues Geheimnis für {{name}} erzeugen? Die DNS-Server erhalten es mit der nächsten Revision. Clients, die den Schlüssel verwenden (z. B. nsupdate oder DHCP-Server der Domain Controller), brauchen danach das neue Geheimnis.",
"rotated": "Für {{name}} wurde ein neues Geheimnis erzeugt. Es wirkt mit der nächsten Revision.",
"deleteConfirm": "Schlüssel {{name}} löschen?",
"secretTitle": "Geheimnis von {{name}}",
"secretConfirm": "Das Geheimnis des Schlüssels {{name}} anzeigen?",
"secretAudited": "Jeder Abruf eines Geheimnisses wird im Audit-Log protokolliert. Geben Sie es nur an Systeme weiter, die es benötigen.",
"reveal": "Geheimnis anzeigen",
"secret": "Geheimnis",
"statementHint": "Schlüsseldatei für nsupdate (-k) oder BIND:",
"secretOnce": "Das Geheimnis wird nur jetzt angezeigt. Für eine erneute Anzeige ist ein weiterer, ebenfalls protokollierter Abruf nötig."
},
"zones": {
"title": "Zonen",
"hint": "Explizite Zonen mit eigenen Records, Cluster-Zuordnung und dynamischen Updates. Zonen, die nur aus den Netzen abgeleitet werden, stehen unten und lassen sich übernehmen.",
"new": "Zone anlegen",
"editTitle": "Zone {{name}} bearbeiten",
"empty": "Noch keine expliziten Zonen.",
"back": "Zurück zu allen Zonen",
"name": "Zone",
"nameHint": "Vollständiger Name ohne abschließenden Punkt, z. B. example.com oder 2.0.192.in-addr.arpa.",
"nameImmutable": "Name und Art können nicht geändert werden.",
"kind": "Art",
"kindAuto": "automatisch (aus dem Namen)",
"kinds": {
"forward": "Forward",
"reverse": "Reverse"
},
"mode": "Modus",
"modes": {
"managed": "verwaltet",
"dynamic": "dynamisch"
},
"modeHelp": {
"managed": "Die Zone wird vollständig aus den Daten hier erzeugt.",
"dynamic": "BIND verwaltet die Zone selbst und nimmt dynamische Updates an (z. B. von Active Directory). Records von hier gehen nur in die Anfangsdatei."
},
"cluster": "DNS-Cluster",
"clusterHint": "Leer: Standard-DNS-Cluster aus den Einstellungen bzw. Standardgruppe.",
"defaultTTL": "Standard-TTL (Sekunden)",
"ttlHint": "Leer: Wert aus den DNS/DHCP-Einstellungen.",
"ttlDefault": "Einstellung",
"description": "Beschreibung",
"records": "Records",
"updates": "Dynamische Updates",
"allowUpdate": "Updates erlaubt von (Netze)",
"allowUpdateHint": "Netze oder Adressen in CIDR-Schreibweise, mit Komma getrennt.",
"updateKeys": "Updates erlaubt mit TSIG-Schlüssel",
"noKeys": "Keine TSIG-Schlüssel vorhanden (Administratoren legen sie unter „TSIG-Schlüssel“ an).",
"adTitle": "Active Directory",
"adHelp": "Für Active Directory die AD-Domäne als dynamische Zone anlegen; die Records unter _msdcs, _sites, _tcp und _udp schreiben die Domain Controller selbst in diese Zone. Updates nur von den IP-Adressen der Domain Controller (als /32) oder per TSIG-Schlüssel erlauben. Sichere Updates mit GSS-TSIG (Kerberos) werden nicht unterstützt. Details: docs/ACTIVE-DIRECTORY.md.",
"deleteConfirm": "Zone {{name}} löschen?",
"deleteForce_one": "Die Zone {{name}} enthält {{count}} Record. Zone mit allen Records löschen?",
"deleteForce_other": "Die Zone {{name}} enthält {{count}} Records. Zone mit allen Records löschen?",
"derivedTitle": "Abgeleitete Zonen",
"derivedHint": "Diese Zonen entstehen automatisch aus den Domänen und Adressbereichen der Netze. Übernehmen macht daraus eine explizite Zone, der Sie Records, einen Cluster oder dynamische Updates zuordnen können.",
"derivedEmpty": "Alle Zonen der Netze sind als explizite Zonen angelegt.",
"networks": "Netze",
"takeOver": "Übernehmen",
"takeOverName": "{{name}} übernehmen",
"takeOverHint": "Die abgeleitete Zone wird als explizite Zone angelegt. Die Records aus der Adresstabelle bleiben erhalten."
},
"records": {
"title": "Records",
"new": "Record anlegen",
"editTitle": "Record bearbeiten",
"empty": "Noch keine Records in dieser Zone.",
"name": "Name",
"nameHint": "Relativ zur Zone, @ für die Zone selbst. Ergibt: {{fqdn}}",
"type": "Typ",
"typeHelp": {
"A": "IPv4-Adresse",
"AAAA": "IPv6-Adresse",
"CNAME": "Alias auf einen anderen Namen (keine weiteren Records mit diesem Namen)",
"MX": "Mailserver mit Priorität",
"TXT": "Text, z. B. SPF oder Domain-Verifizierung",
"SRV": "Dienst mit Priorität, Gewichtung, Port und Ziel",
"NS": "Nameserver, z. B. für eine delegierte Subdomain",
"PTR": "Rückwärtsauflösung auf einen Namen"
},
"ttl": "TTL (Sekunden)",
"ttlHint": "Leer: Standard-TTL der Zone.",
"ttlZone": "Zone",
"address": "Adresse",
"target": "Ziel",
"targetHint": "Vollständiger Name (FQDN) ohne abschließenden Punkt.",
"preference": "Priorität (Preference)",
"priority": "Priorität",
"weight": "Gewichtung",
"port": "Port",
"text": "Text",
"textHint": "Ein Eintrag pro Zeichenkette. Lange Texte werden beim Erzeugen automatisch in Stücke zu 255 Byte geteilt.",
"textN": "Text {{n}}",
"addText": "Zeichenkette hinzufügen",
"removeText": "Entfernen",
"removeTextN": "Text {{n}} entfernen",
"data": "Daten",
"description": "Beschreibung",
"deleteConfirm": "Record {{name}} ({{type}}) löschen?",
"autoHint": "A-, AAAA- und PTR-Records für die Einträge der Adresstabelle erzeugt der Generator automatisch; sie erscheinen hier nicht.",
"dynamicHint": "Dynamische Zone: Diese Records gelangen nur in die Anfangsdatei der Zone. Danach verwaltet BIND die Zone selbst; spätere Änderungen hier erreichen die Server nicht mehr."
}
}
+247 -10
View File
@@ -18,7 +18,10 @@
"generator": "DNS/DHCP settings",
"agents": "Agents",
"revisions": "Deploy",
"leases": "Leases"
"leases": "Leases",
"zones": "Zones",
"clusters": "Clusters",
"tsigKeys": "TSIG keys"
},
"common": {
"save": "Save",
@@ -200,7 +203,9 @@
"deleteForce": "The network contains {{count}} addresses. Delete the network and all its addresses?",
"created": "Network created.",
"updated": "Network saved.",
"deleted": "Network deleted."
"deleted": "Network deleted.",
"dhcpCluster": "DHCP cluster",
"dhcpClusterHint": "The DHCP cluster serving this network. Empty: the default cluster from the DNS/DHCP settings, or all DHCP servers without a cluster."
},
"mapState": {
"free": "free",
@@ -274,7 +279,8 @@
"updated": "Address saved.",
"deleted": "Address deleted.",
"deleteConfirm": "Delete address {{ip}}?",
"toReservation": "Reserve permanently"
"toReservation": "Convert to reservation",
"leaseExpires": "Lease expires: {{time}}"
},
"effects": {
"dns.add_records": "DNS: A and PTR record {{fqdn}} → {{ip}} will be created.",
@@ -400,7 +406,20 @@
"revision_limit": "The highest possible revision number has been reached.",
"rollback_required": "Older revisions cannot be deployed to agents in \"pull automatically\" mode – they would switch back to the latest revision right away. Use \"Roll back to this revision\" (creates a new revision with this content) or switch the affected agents to \"manual\".",
"rollback_unavailable": "This revision cannot be restored because it was created before the rollback feature and its input data is missing.",
"events_unavailable": "Live updates are not available on this server."
"events_unavailable": "Live updates are not available on this server.",
"cluster_in_use": "The cluster is still in use (zones, networks or as default cluster in the DNS/DHCP settings).",
"cluster_role_mismatch": "The agent or cluster has the wrong role (DNS/DHCP).",
"agent_in_other_cluster": "The agent already belongs to another cluster.",
"cluster_primary_exists": "The cluster already has a primary.",
"cluster_member_limit": "A DHCP cluster has at most one primary and one secondary.",
"key_in_use": "Zones still use the key for dynamic updates.",
"key_owned_by_cluster": "This is the transfer key of a cluster; it is deleted together with the cluster.",
"key_exists": "A key with this name already exists.",
"invalid_key_name": "Invalid key name.",
"zone_exists": "This zone already exists.",
"zone_kind_mismatch": "Name and kind do not match: reverse zones end in in-addr.arpa or ip6.arpa, forward zones do not.",
"zone_not_empty": "The zone still contains {{count}} records.",
"record_exists": "This record already exists in the zone."
},
"problems": {
"required": "Required.",
@@ -475,7 +494,19 @@
"out_of_range": "The value is out of the allowed range.",
"rollback_required": "{{hostname}} already runs revision {{applied_revision}} or pulls automatically; use a rollback to go back to an older revision.",
"unknown_agent": "Unknown agent.",
"agent_revoked": "The agent is revoked."
"agent_revoked": "The agent is revoked.",
"cluster_role_mismatch": "Wrong role (DNS/DHCP).",
"agent_in_other_cluster": "Already belongs to another cluster.",
"cluster_primary_exists": "There already is a primary.",
"cluster_member_limit": "At most one primary and one secondary.",
"key_exists": "Name already taken.",
"invalid_key_name": "Invalid key name (lower-case letters, digits, - and .).",
"zone_exists": "This zone already exists.",
"zone_kind_mismatch": "Does not match the kind of the zone.",
"record_exists": "This record already exists.",
"invalid_label": "Invalid name (labels of a–z, 0–9, - and _, at most 63 characters).",
"invalid_txt": "Invalid text.",
"invalid_port": "Port between 1 and 65535."
},
"config": {
"title": "Generated configuration",
@@ -486,7 +517,9 @@
"warnings": "Notes",
"empty": "No files.",
"copy": "Copy",
"settingsLink": "DNS/DHCP settings"
"settingsLink": "DNS/DHCP settings",
"secret": "secret",
"secretContent": "secret content (not shown)"
},
"generatorErrors": {
"dns_settings_missing": "DNS still needs a primary name server, hostmaster and name servers in the DNS/DHCP settings.",
@@ -500,7 +533,13 @@
"dhcp.no_subnet_id": "Network {{cidr}} has no Kea subnet ID and is skipped.",
"dhcp.reservation_without_mac": "Reservation {{ip}} without MAC address skipped.",
"dns.zone_conflict": "Reverse zone {{zone}} is dropped because a forward zone has the same name.",
"dns.duplicate_ptr": "{{ip}} has several names; {{fqdn}} gets no PTR record."
"dns.duplicate_ptr": "{{ip}} has several names; {{fqdn}} gets no PTR record.",
"dns.no_zone": "{{fqdn}} ({{ip}}): no matching zone, so no DNS record.",
"dns.cname_conflict": "{{name}} in zone {{zone}}: a CNAME cannot coexist with other data; the conflict was dropped.",
"dns.unknown_cluster": "Zone {{zone}} refers to an unknown cluster and is skipped.",
"dns.cluster_without_primary": "Cluster {{cluster}} has no primary and therefore serves no zones.",
"dns.unknown_key": "Zone {{zone}}: TSIG key {{key}} is unknown and ignored.",
"dhcp.unknown_cluster": "Network {{cidr}} refers to an unknown DHCP cluster and is skipped."
},
"generator": {
"title": "DNS/DHCP settings",
@@ -529,7 +568,13 @@
"leaseFile": "Lease file (Kea)",
"hooksDir": "Hooks directory (Kea)",
"hooksDirHint": "Leave empty for the built-in directory (required from Kea 3.0).",
"invalidLine": "Invalid line: {{line}}"
"invalidLine": "Invalid line: {{line}}",
"defaultDNSCluster": "Default DNS cluster",
"defaultDNSClusterHint": "Zones without a cluster of their own are served by this cluster. Empty: default group (all DNS servers without a cluster, every zone as primary).",
"defaultDHCPCluster": "Default DHCP cluster",
"defaultDHCPClusterHint": "Networks without a cluster of their own are served by this cluster. Empty: default group (all DHCP servers without a cluster, no HA).",
"haPasswordFile": "HA password file (Kea)",
"haPasswordFileHint": "Path of the Kea HA password file on the DHCP servers; empty for /etc/kea/ddi-suite-ha-password."
},
"agents": {
"title": "Agents",
@@ -573,7 +618,8 @@
"revoke": "Revoke",
"revokeConfirm": "Revoke agent {{host}}? The connection is closed immediately; the agent needs a new enrollment afterwards. BIND or Kea keep running with the last configuration.",
"deleteConfirm": "Delete agent {{host}}? It is revoked and removed together with its deployments and leases.",
"deleted": "The agent was deleted."
"deleted": "The agent was deleted.",
"cluster": "Cluster"
},
"tokens": {
"title": "Enrollment tokens",
@@ -707,7 +753,7 @@
},
"leases": {
"title": "Leases",
"hint": "Leases reported by the DHCP agents. Automatic import into the address table follows with M5.",
"hint": "Leases reported by the DHCP agents. Active leases in a pool are added to the address table as dynamic addresses automatically.",
"network": "Network",
"allNetworks": "All networks",
"state": "State",
@@ -726,5 +772,196 @@
"expires": "Expires",
"updated": "Last activity",
"subnet": "Subnet ID {{id}}"
},
"targets": {
"default": "Default group",
"cluster": "Cluster {{name}}"
},
"clusters": {
"title": "Clusters",
"hint": "Clusters group DNS servers into a primary and secondaries (NOTIFY, TSIG-secured zone transfers) and DHCP servers into an HA pair (Kea HA). Servers without a cluster form the default group and serve all zones and networks without a cluster. Changes take effect with the next revision.",
"section": {
"dns": "DNS clusters",
"dhcp": "DHCP clusters"
},
"empty": {
"dns": "No DNS cluster yet. Every DNS server serves every zone as primary.",
"dhcp": "No DHCP cluster yet. Every DHCP server works standalone."
},
"newDNS": "New DNS cluster",
"newDHCP": "New DHCP cluster",
"editTitle": "Edit cluster {{name}}",
"name": "Name",
"description": "Description",
"members": "Members",
"options": "Settings",
"usage": "Usage",
"noMembers": "No members.",
"noCluster": "no cluster",
"agent": "Agent",
"pickAgent": "– choose an agent –",
"memberRole": "Role in the cluster",
"memberRoles": {
"primary": "Primary",
"secondary": "Secondary"
},
"address": "Address",
"addressHint": "Address: the IP at which the other members reach this server (DNS: port 53 for NOTIFY and zone transfers, DHCP: HA port). The address the agent connects from is suggested.",
"addMember": "Add member",
"removeMember": "Remove",
"removeMemberN": "Remove member {{n}}",
"membersHintDNS": "One primary, any number of secondaries. DNS agents that belong to no other cluster can be chosen.",
"membersHintDHCP": "At most one primary and one secondary. Both form an HA pair; a single member works standalone. DHCP agents that belong to no other cluster can be chosen.",
"agentRequired": "Please choose an agent.",
"addressRequired": "Please enter the address.",
"dnsOnePrimary": "A DNS cluster has exactly one primary.",
"dhcpPair": "A DHCP cluster has at most one primary and one secondary.",
"noPrimaryHint": "Without a primary the cluster serves no zones.",
"dnsOptions": "DNS settings of the cluster",
"dnsOptionsHint": "Optional: SOA primary server and NS records of the zones of this cluster. Empty: values from the DNS/DHCP settings.",
"dhcpOptions": "High availability (Kea HA)",
"dhcpOptionsHint": "Applies once a primary and a secondary are set. Requires Kea 3.0 or newer; the server generates the user and password for the peers.",
"haMode": "HA mode",
"haModes": {
"hot-standby": "Hot standby",
"load-balancing": "Load balancing"
},
"haModeHelp": {
"hot-standby": "The primary hands out all leases; the secondary takes over on failure.",
"load-balancing": "Both servers hand out leases and share the clients."
},
"haPort": "HA port",
"transferKey": "Transfer key",
"fromSettings": "from the settings",
"singleServer": "standalone (no HA)",
"zoneCount_one": "{{count}} zone",
"zoneCount_other": "{{count}} zones",
"networkCount_one": "{{count}} network",
"networkCount_other": "{{count}} networks",
"deleteConfirm": "Delete cluster {{name}}? Its members then belong to the default group; the transfer key is deleted.",
"defaultCluster": "Default",
"defaultGroup": "none (default group)"
},
"tsig": {
"title": "TSIG keys",
"hint": "TSIG keys secure dynamic updates (e.g. from nsupdate or the DHCP servers of domain controllers) and zone transfers. The server generates the secrets; DNS servers receive them with the next revision.",
"new": "New key",
"editTitle": "Edit key {{name}}",
"empty": "No TSIG keys yet.",
"name": "Name",
"nameHint": "Lower-case letters, digits, hyphens and dots, e.g. dc1.example.com.",
"nameImmutable": "Name and algorithm cannot be changed.",
"algorithm": "Algorithm",
"description": "Description",
"createHint": "The secret is generated randomly and can be shown afterwards.",
"usage": "Usage",
"transferKey": "Transfer key",
"ofCluster": "of cluster {{name}}",
"zones_one": "updates in {{count}} zone",
"zones_other": "updates in {{count}} zones",
"unused": "not used",
"rotatedAt": "Secret from",
"showSecret": "Show secret",
"rotate": "Rotate",
"rotateConfirm": "Generate a new secret for {{name}}? DNS servers receive it with the next revision. Clients using the key (e.g. nsupdate or DHCP servers of domain controllers) need the new secret afterwards.",
"rotated": "A new secret was generated for {{name}}. It takes effect with the next revision.",
"deleteConfirm": "Delete key {{name}}?",
"secretTitle": "Secret of {{name}}",
"secretConfirm": "Show the secret of key {{name}}?",
"secretAudited": "Every retrieval of a secret is recorded in the audit log. Only pass it on to systems that need it.",
"reveal": "Show secret",
"secret": "Secret",
"statementHint": "Key file for nsupdate (-k) or BIND:",
"secretOnce": "The secret is shown only now. Showing it again takes another retrieval, which is recorded as well."
},
"zones": {
"title": "Zones",
"hint": "Explicit zones with their own records, cluster assignment and dynamic updates. Zones derived from networks only are listed below and can be taken over.",
"new": "New zone",
"editTitle": "Edit zone {{name}}",
"empty": "No explicit zones yet.",
"back": "Back to all zones",
"name": "Zone",
"nameHint": "Full name without trailing dot, e.g. example.com or 2.0.192.in-addr.arpa.",
"nameImmutable": "Name and kind cannot be changed.",
"kind": "Kind",
"kindAuto": "automatic (from the name)",
"kinds": {
"forward": "Forward",
"reverse": "Reverse"
},
"mode": "Mode",
"modes": {
"managed": "managed",
"dynamic": "dynamic"
},
"modeHelp": {
"managed": "The zone is generated entirely from the data here.",
"dynamic": "BIND manages the zone itself and accepts dynamic updates (e.g. from Active Directory). Records from here go into the initial file only."
},
"cluster": "DNS cluster",
"clusterHint": "Empty: default DNS cluster from the settings, or the default group.",
"defaultTTL": "Default TTL (seconds)",
"ttlHint": "Empty: value from the DNS/DHCP settings.",
"ttlDefault": "setting",
"description": "Description",
"records": "Records",
"updates": "Dynamic updates",
"allowUpdate": "Updates allowed from (networks)",
"allowUpdateHint": "Networks or addresses in CIDR notation, separated by commas.",
"updateKeys": "Updates allowed with TSIG key",
"noKeys": "No TSIG keys available (administrators create them under “TSIG keys”).",
"adTitle": "Active Directory",
"adHelp": "For Active Directory, create the AD domain as a dynamic zone; the domain controllers write the records under _msdcs, _sites, _tcp and _udp into this zone themselves. Allow updates only from the IP addresses of the domain controllers (as /32) or with a TSIG key. Secure updates with GSS-TSIG (Kerberos) are not supported. Details: docs/ACTIVE-DIRECTORY.md.",
"deleteConfirm": "Delete zone {{name}}?",
"deleteForce_one": "Zone {{name}} contains {{count}} record. Delete the zone with all records?",
"deleteForce_other": "Zone {{name}} contains {{count}} records. Delete the zone with all records?",
"derivedTitle": "Derived zones",
"derivedHint": "These zones are created automatically from the domains and address ranges of the networks. Taking one over turns it into an explicit zone that can have records, a cluster or dynamic updates.",
"derivedEmpty": "All zones of the networks exist as explicit zones.",
"networks": "Networks",
"takeOver": "Take over",
"takeOverName": "Take over {{name}}",
"takeOverHint": "The derived zone is created as an explicit zone. The records from the address table stay."
},
"records": {
"title": "Records",
"new": "New record",
"editTitle": "Edit record",
"empty": "No records in this zone yet.",
"name": "Name",
"nameHint": "Relative to the zone, @ for the zone itself. Results in: {{fqdn}}",
"type": "Type",
"typeHelp": {
"A": "IPv4 address",
"AAAA": "IPv6 address",
"CNAME": "Alias of another name (no other records with this name)",
"MX": "Mail server with preference",
"TXT": "Text, e.g. SPF or domain verification",
"SRV": "Service with priority, weight, port and target",
"NS": "Name server, e.g. for a delegated subdomain",
"PTR": "Reverse lookup to a name"
},
"ttl": "TTL (seconds)",
"ttlHint": "Empty: default TTL of the zone.",
"ttlZone": "zone",
"address": "Address",
"target": "Target",
"targetHint": "Fully qualified name (FQDN) without trailing dot.",
"preference": "Preference",
"priority": "Priority",
"weight": "Weight",
"port": "Port",
"text": "Text",
"textHint": "One entry per string. Long texts are split into 255-byte parts automatically when generated.",
"textN": "Text {{n}}",
"addText": "Add string",
"removeText": "Remove",
"removeTextN": "Remove text {{n}}",
"data": "Data",
"description": "Description",
"deleteConfirm": "Delete record {{name}} ({{type}})?",
"autoHint": "A, AAAA and PTR records for the entries of the address table are generated automatically; they do not appear here.",
"dynamicHint": "Dynamic zone: these records go into the initial zone file only. Afterwards BIND manages the zone itself; later changes here no longer reach the servers."
}
}
+864
View File
@@ -0,0 +1,864 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { changeLanguage } from './i18n';
import { adminSession, mockApi, problem, renderApp, validLicense } from './test-utils';
const base = {
'GET /api/v1/auth/session': () => ({ body: adminSession }),
'GET /api/v1/license': () => ({ body: validLicense }),
'GET /api/v1/config/pending': () => ({
body: { latest_revision: 4, changed: false, files: [], changes: [] },
}),
};
const stamp = '2026-10-14T10:00:00Z';
function agent(over: Record<string, unknown>) {
return {
id: 'a-ns1',
role: 'dns',
hostname: 'ns1',
agent_version: '0.6.0',
service_version: 'BIND 9.20.3',
connected: true,
mode: 'auto_pull',
enrolled_at: stamp,
last_seen_at: stamp,
cert_expires_at: stamp,
applied_revision: 4,
last_good_revision: 4,
desired_revision: 4,
target_revision: 0,
health: { state: 'ok', checks: [] },
...over,
};
}
const ns1 = agent({
remote_address: '192.0.2.53:41234',
cluster: { id: 'c1', name: 'dns-a', role: 'primary', address: '192.0.2.53' },
});
const ns2 = agent({
id: 'a-ns2',
hostname: 'ns2',
cluster: { id: 'c1', name: 'dns-a', role: 'secondary', address: '192.0.2.54' },
});
const ns3 = agent({ id: 'a-ns3', hostname: 'ns3', remote_address: '[2001:db8::53]:5000' });
const ns4 = agent({ id: 'a-ns4', hostname: 'ns4' });
const old = agent({ id: 'a-old', hostname: 'old', revoked_at: stamp });
const dhcp1 = agent({ id: 'a-dhcp1', role: 'dhcp', hostname: 'dhcp1' });
const dhcp2 = agent({ id: 'a-dhcp2', role: 'dhcp', hostname: 'dhcp2' });
const dnsCluster = {
id: 'c1',
name: 'dns-a',
role: 'dns',
description: '',
members: [
{
agent_id: 'a-ns1',
role: 'primary',
address: '192.0.2.53',
hostname: 'ns1',
connected: true,
},
{
agent_id: 'a-ns2',
role: 'secondary',
address: '192.0.2.54',
hostname: 'ns2',
connected: false,
},
],
primary_ns: '',
name_servers: [],
ha_mode: 'hot-standby',
ha_port: 8001,
transfer_key: { id: 'k2', name: 'transfer.dns-a' },
zone_count: 2,
network_count: 0,
version: 1,
created_at: stamp,
updated_at: stamp,
};
const dhcpCluster = {
...dnsCluster,
id: 'c2',
name: 'dhcp-a',
role: 'dhcp',
members: [],
transfer_key: null,
zone_count: 0,
network_count: 1,
};
const key1 = {
id: 'k1',
name: 'dc1.office.test',
algorithm: 'hmac-sha256',
description: 'DC1',
cluster_id: null,
zone_count: 1,
version: 1,
created_at: stamp,
rotated_at: stamp,
};
const key2 = { ...key1, id: 'k2', name: 'transfer.dns-a', cluster_id: 'c1', zone_count: 0 };
const network = {
id: 'n1',
cidr: '192.0.2.0/24',
name: 'Office',
description: '',
vlan: null,
gateway: null,
domain: 'office.test',
search_domains: [],
tags: [],
dhcp_enabled: true,
lease_time: null,
dns_servers: [],
ntp_servers: [],
pools: [],
dhcp_cluster_id: null,
version: 1,
created_at: stamp,
updated_at: stamp,
};
function zone(over: Record<string, unknown>) {
return {
id: 'z1',
name: 'office.test',
kind: 'forward',
mode: 'managed',
cluster_id: null,
default_ttl: null,
allow_update: [],
update_key_ids: [],
description: '',
record_count: 0,
version: 1,
created_at: stamp,
updated_at: stamp,
...over,
};
}
const mx = {
id: 'r1',
zone_id: 'z1',
name: '@',
type: 'MX',
ttl: null,
address: '',
target: 'mail.office.test',
preference: 10,
priority: 0,
weight: 0,
port: 0,
text: [],
description: '',
version: 1,
created_at: stamp,
updated_at: stamp,
};
function requestedUrls(): string[] {
return vi
.mocked(globalThis.fetch)
.mock.calls.map(([input]) => (input instanceof Request ? input.url : String(input)));
}
function optionTexts(select: HTMLElement): string[] {
return within(select)
.getAllByRole('option')
.map((o) => o.textContent);
}
function rowOf(el: HTMLElement): HTMLElement {
const row = el.closest('tr');
if (!row) throw new Error('row not found');
return row;
}
function at(list: HTMLElement[], i: number): HTMLElement {
const el = list[i];
if (!el) throw new Error(`no element ${String(i)}`);
return el;
}
beforeEach(async () => {
window.localStorage.clear();
await changeLanguage('de');
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('clusters', () => {
const handlers = {
...base,
'GET /api/v1/clusters': () => ({ body: { items: [dnsCluster, dhcpCluster] } }),
'GET /api/v1/agents': () => ({ body: { items: [ns1, ns2, ns3, ns4, old, dhcp1, dhcp2] } }),
};
it('lists clusters with members, transfer key and usage', async () => {
mockApi(handlers);
renderApp('/clusters');
const row = rowOf(await screen.findByText('dns-a'));
expect(within(row).getByRole('link', { name: 'ns1' })).toHaveAttribute('href', '/agents/a-ns1');
expect(within(row).getByText('192.0.2.54')).toBeInTheDocument();
expect(within(row).getByText('getrennt')).toBeInTheDocument();
expect(within(row).getByText('transfer.dns-a')).toBeInTheDocument();
expect(within(row).getByText('2 Zonen')).toBeInTheDocument();
const dhcpRow = rowOf(screen.getByText('dhcp-a'));
expect(within(dhcpRow).getByText('1 Netz')).toBeInTheDocument();
expect(within(dhcpRow).getByText('Einzelserver (kein HA)')).toBeInTheDocument();
});
it('validates DNS members and offers only free agents of the role', async () => {
const calls = mockApi({
...handlers,
'POST /api/v1/clusters': () => ({ status: 201, body: { ...dnsCluster, id: 'c9' } }),
});
renderApp('/clusters');
await userEvent.click(await screen.findByRole('button', { name: 'DNS-Cluster anlegen' }));
const dialog = await screen.findByRole('dialog');
await userEvent.type(within(dialog).getByLabelText('Name'), 'dns-b');
const add = within(dialog).getByRole('button', { name: 'Mitglied hinzufügen' });
await userEvent.click(add);
const agents = () => within(dialog).getAllByLabelText('Agent');
const roles = () => within(dialog).getAllByLabelText('Rolle im Cluster');
const addresses = () => within(dialog).getAllByLabelText('Adresse');
// ns1/ns2 belong to dns-a, old is revoked, dhcp agents have the other role.
await waitFor(() => {
expect(optionTexts(at(agents(), 0))).toEqual(['– Agent wählen –', 'ns3', 'ns4']);
});
expect(roles()[0]).toHaveValue('primary');
await userEvent.selectOptions(at(agents(), 0), 'a-ns3');
// The address the agent connects from is suggested.
expect(addresses()[0]).toHaveValue('2001:db8::53');
await userEvent.click(add);
expect(roles()[1]).toHaveValue('secondary');
expect(optionTexts(at(agents(), 1))).toEqual(['– Agent wählen –', 'ns4']);
await userEvent.selectOptions(at(roles(), 1), 'primary');
await userEvent.click(within(dialog).getByRole('button', { name: 'Anlegen' }));
expect(within(dialog).getByText('Ein DNS-Cluster hat genau einen Primary.')).toBeVisible();
expect(within(dialog).getByText('Bitte einen Agent wählen.')).toBeVisible();
expect(within(dialog).getByText('Bitte die Adresse angeben.')).toBeVisible();
expect(calls.some((c) => c.method === 'POST')).toBe(false);
await userEvent.selectOptions(at(roles(), 1), 'secondary');
await userEvent.selectOptions(at(agents(), 1), 'a-ns4');
await userEvent.type(at(addresses(), 1), '192.0.2.55');
await userEvent.type(
within(dialog).getByLabelText('Primärer Nameserver (SOA)'),
'ns.office.test',
);
await userEvent.click(within(dialog).getByRole('button', { name: 'Anlegen' }));
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
expect(calls.find((c) => c.method === 'POST')?.body).toEqual({
name: 'dns-b',
description: '',
role: 'dns',
members: [
{ agent_id: 'a-ns3', role: 'primary', address: '2001:db8::53' },
{ agent_id: 'a-ns4', role: 'secondary', address: '192.0.2.55' },
],
primary_ns: 'ns.office.test',
name_servers: [],
});
});
it('keeps own members selectable when editing and limits DHCP clusters to a pair', async () => {
const calls = mockApi({
...handlers,
'PUT /api/v1/clusters/c2': () => ({ body: dhcpCluster }),
});
renderApp('/clusters');
const dnsRow = rowOf(await screen.findByText('dns-a'));
await userEvent.click(within(dnsRow).getByRole('button', { name: 'Bearbeiten' }));
let dialog = await screen.findByRole('dialog');
await waitFor(() => {
expect(optionTexts(at(within(dialog).getAllByLabelText('Agent'), 0))).toEqual([
'– Agent wählen –',
'ns1',
'ns3',
'ns4',
]);
});
await userEvent.click(within(dialog).getByRole('button', { name: 'Abbrechen' }));
const dhcpRow = rowOf(screen.getByText('dhcp-a'));
await userEvent.click(within(dhcpRow).getByRole('button', { name: 'Bearbeiten' }));
dialog = await screen.findByRole('dialog');
const add = within(dialog).getByRole('button', { name: 'Mitglied hinzufügen' });
await userEvent.click(add);
await userEvent.click(add);
expect(add).toBeDisabled();
const agents = within(dialog).getAllByLabelText('Agent');
await waitFor(() => {
expect(optionTexts(at(agents, 0))).toEqual(['– Agent wählen –', 'dhcp1', 'dhcp2']);
});
await userEvent.selectOptions(at(agents, 0), 'a-dhcp1');
await userEvent.selectOptions(at(agents, 1), 'a-dhcp2');
const addresses = within(dialog).getAllByLabelText('Adresse');
await userEvent.type(at(addresses, 0), '192.0.2.67');
await userEvent.type(at(addresses, 1), '192.0.2.68');
await userEvent.selectOptions(within(dialog).getByLabelText('HA-Modus'), 'load-balancing');
const port = within(dialog).getByLabelText('HA-Port');
expect(port).toHaveValue(8001);
await userEvent.clear(port);
await userEvent.type(port, '8002');
await userEvent.click(within(dialog).getByRole('button', { name: 'Speichern' }));
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
expect(calls.find((c) => c.method === 'PUT')?.body).toEqual({
name: 'dhcp-a',
description: '',
members: [
{ agent_id: 'a-dhcp1', role: 'primary', address: '192.0.2.67' },
{ agent_id: 'a-dhcp2', role: 'secondary', address: '192.0.2.68' },
],
ha_mode: 'load-balancing',
ha_port: 8002,
version: 1,
});
});
it('explains cluster_in_use on delete', async () => {
mockApi({
...handlers,
'DELETE /api/v1/clusters/c1': () => ({ status: 409, body: problem(409, 'cluster_in_use') }),
});
renderApp('/clusters');
const row = rowOf(await screen.findByText('dns-a'));
await userEvent.click(within(row).getByRole('button', { name: 'Löschen' }));
const dialog = await screen.findByRole('dialog');
await userEvent.click(within(dialog).getByRole('button', { name: 'Bestätigen' }));
expect(await within(dialog).findByText(/Der Cluster wird noch verwendet/)).toBeVisible();
});
it('shows the cluster membership of agents', async () => {
mockApi({
...handlers,
'GET /api/v1/enrollment-tokens': () => ({ body: { items: [] } }),
});
renderApp('/agents');
const row = rowOf(await screen.findByRole('link', { name: 'ns2' }));
expect(within(row).getByRole('link', { name: 'dns-a' })).toHaveAttribute('href', '/clusters');
expect(within(row).getByText('Secondary')).toBeInTheDocument();
expect(
within(rowOf(screen.getByRole('link', { name: 'ns3' }))).getByText('kein Cluster'),
).toBeInTheDocument();
});
});
describe('TSIG keys', () => {
const handlers = {
...base,
'GET /api/v1/clusters': () => ({ body: { items: [dnsCluster] } }),
'GET /api/v1/tsig-keys': () => ({ body: { items: [key1, key2] } }),
'GET /api/v1/tsig-keys/k1/secret': () => ({
body: { name: key1.name, algorithm: key1.algorithm, secret: 'c2VjcmV0LWtleQ==' },
}),
};
it('reveals a secret only after confirmation and only once', async () => {
const calls = mockApi(handlers);
renderApp('/settings/tsig-keys');
const row = rowOf(await screen.findByText('dc1.office.test'));
expect(within(row).getByText('Updates in 1 Zone')).toBeInTheDocument();
const transferRow = rowOf(screen.getByText('transfer.dns-a'));
expect(await within(transferRow).findByText('von Cluster dns-a')).toBeInTheDocument();
expect(within(transferRow).queryByRole('button', { name: 'Löschen' })).not.toBeInTheDocument();
const secretCalls = () => calls.filter((c) => c.path.endsWith('/secret')).length;
await userEvent.click(within(row).getByRole('button', { name: 'Geheimnis anzeigen' }));
let dialog = await screen.findByRole('dialog');
expect(within(dialog).getByText(/im Audit-Log protokolliert/)).toBeVisible();
expect(secretCalls()).toBe(0);
await userEvent.click(within(dialog).getByRole('button', { name: 'Geheimnis anzeigen' }));
expect(await within(dialog).findByTestId('tsig-secret')).toHaveTextContent('c2VjcmV0LWtleQ==');
expect(within(dialog).getByText(/secret "c2VjcmV0LWtleQ==";/)).toBeInTheDocument();
expect(secretCalls()).toBe(1);
await userEvent.click(within(dialog).getByRole('button', { name: 'Schließen' }));
expect(screen.queryByText('c2VjcmV0LWtleQ==')).not.toBeInTheDocument();
// Opening again asks again; nothing is cached.
await userEvent.click(within(row).getByRole('button', { name: 'Geheimnis anzeigen' }));
dialog = await screen.findByRole('dialog');
expect(within(dialog).queryByTestId('tsig-secret')).not.toBeInTheDocument();
expect(secretCalls()).toBe(1);
});
it('rotates after confirmation and translates key_in_use', async () => {
const calls = mockApi({
...handlers,
'POST /api/v1/tsig-keys/k1/rotate': () => ({ body: key1 }),
'DELETE /api/v1/tsig-keys/k1': () => ({ status: 409, body: problem(409, 'key_in_use') }),
});
renderApp('/settings/tsig-keys');
const row = rowOf(await screen.findByText('dc1.office.test'));
await userEvent.click(within(row).getByRole('button', { name: 'Erneuern' }));
let dialog = await screen.findByRole('dialog');
expect(within(dialog).getByText(/mit der nächsten Revision/)).toBeVisible();
await userEvent.click(within(dialog).getByRole('button', { name: 'Erneuern' }));
expect(
await screen.findByText(/Für dc1.office.test wurde ein neues Geheimnis erzeugt/),
).toBeVisible();
expect(calls.some((c) => c.method === 'POST' && c.path.endsWith('/rotate'))).toBe(true);
await userEvent.click(within(row).getByRole('button', { name: 'Löschen' }));
dialog = await screen.findByRole('dialog');
await userEvent.click(within(dialog).getByRole('button', { name: 'Löschen' }));
expect(
await within(dialog).findByText(
'Der Schlüssel wird noch von Zonen für dynamische Updates verwendet.',
),
).toBeVisible();
});
it('creates a key with name, algorithm and description', async () => {
const calls = mockApi({
...handlers,
'POST /api/v1/tsig-keys': () => ({ status: 201, body: key1 }),
});
renderApp('/settings/tsig-keys');
await userEvent.click(await screen.findByRole('button', { name: 'Schlüssel anlegen' }));
const dialog = await screen.findByRole('dialog');
await userEvent.type(within(dialog).getByLabelText('Name'), 'dhcp.office.test');
await userEvent.selectOptions(within(dialog).getByLabelText('Algorithmus'), 'hmac-sha512');
await userEvent.type(within(dialog).getByLabelText('Beschreibung'), 'Windows DHCP');
await userEvent.click(within(dialog).getByRole('button', { name: 'Anlegen' }));
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
expect(calls.find((c) => c.method === 'POST')?.body).toEqual({
name: 'dhcp.office.test',
algorithm: 'hmac-sha512',
description: 'Windows DHCP',
});
});
});
describe('zones', () => {
it('takes over a derived zone as a dynamic zone', async () => {
const created = zone({
mode: 'dynamic',
allow_update: ['192.0.2.10/32'],
update_key_ids: ['k1'],
});
const calls = mockApi({
...base,
'GET /api/v1/zones': () => ({ body: { items: [] } }),
'GET /api/v1/zones/derived': () => ({
body: {
items: [
{ name: '2.0.192.in-addr.arpa', kind: 'reverse', network_ids: ['n1'] },
{ name: 'office.test', kind: 'forward', network_ids: ['n1'] },
],
},
}),
'GET /api/v1/networks': () => ({ body: { items: [network] } }),
'GET /api/v1/clusters': () => ({ body: { items: [dnsCluster, dhcpCluster] } }),
'GET /api/v1/tsig-keys': () => ({ body: { items: [key1, key2] } }),
'POST /api/v1/zones': () => ({ status: 201, body: created }),
'GET /api/v1/zones/z1': () => ({ body: created }),
'GET /api/v1/zones/z1/records': () => ({ body: { items: [] } }),
});
const { router } = renderApp('/zones');
expect(await screen.findByText('Noch keine expliziten Zonen.')).toBeInTheDocument();
const derivedRow = rowOf(await screen.findByText('office.test'));
expect(await within(derivedRow).findByText('192.0.2.0/24')).toBeInTheDocument();
await userEvent.click(
within(derivedRow).getByRole('button', { name: 'office.test übernehmen' }),
);
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByLabelText('Zone')).toHaveValue('office.test');
expect(within(dialog).getByLabelText('Art')).toHaveValue('forward');
expect(within(dialog).queryByLabelText('Updates erlaubt von (Netze)')).not.toBeInTheDocument();
// DNS clusters only.
expect(optionTexts(within(dialog).getByLabelText('DNS-Cluster'))).toEqual([
'Standard',
'dns-a',
]);
await userEvent.selectOptions(within(dialog).getByLabelText('Modus'), 'dynamic');
await userEvent.type(
within(dialog).getByLabelText('Updates erlaubt von (Netze)'),
'192.0.2.10/32',
);
// Transfer keys of clusters are not offered for updates.
expect(within(dialog).queryByRole('checkbox', { name: 'transfer.dns-a' })).toBeNull();
await userEvent.click(within(dialog).getByRole('checkbox', { name: 'dc1.office.test' }));
expect(
within(dialog).getByText(/GSS-TSIG \(Kerberos\) werden nicht unterstützt/),
).toBeVisible();
await userEvent.click(within(dialog).getByRole('button', { name: 'Anlegen' }));
await waitFor(() => {
expect(router.state.location.pathname).toBe('/zones/z1');
});
expect(calls.find((c) => c.method === 'POST')?.body).toEqual({
name: 'office.test',
kind: 'forward',
mode: 'dynamic',
cluster_id: null,
default_ttl: null,
allow_update: ['192.0.2.10/32'],
update_key_ids: ['k1'],
description: '',
});
// Dynamic zones: records go into the initial file only; AD help.
expect(await screen.findByText(/nur in die Anfangsdatei der Zone/)).toBeVisible();
expect(screen.getByText('Active Directory')).toBeVisible();
expect(await screen.findByText('dc1.office.test')).toBeInTheDocument();
});
it('deletes a zone with records only with force', async () => {
const calls = mockApi({
...base,
'GET /api/v1/zones': () => ({
body: { items: [zone({ record_count: 1, cluster_id: 'c1' })] },
}),
'GET /api/v1/zones/derived': () => ({ body: { items: [] } }),
'GET /api/v1/networks': () => ({ body: { items: [network] } }),
'GET /api/v1/clusters': () => ({ body: { items: [dnsCluster] } }),
'DELETE /api/v1/zones/z1': () => ({ status: 204 }),
});
renderApp('/zones');
const row = rowOf(await screen.findByRole('link', { name: 'office.test' }));
expect(await within(row).findByText('dns-a')).toBeInTheDocument();
await userEvent.click(within(row).getByRole('button', { name: 'Löschen' }));
const dialog = await screen.findByRole('dialog');
expect(
within(dialog).getByText(
'Die Zone office.test enthält 1 Record. Zone mit allen Records löschen?',
),
).toBeVisible();
await userEvent.click(within(dialog).getByRole('button', { name: 'Bestätigen' }));
await waitFor(() => {
expect(calls.some((c) => c.method === 'DELETE')).toBe(true);
});
expect(requestedUrls().some((u) => u.includes('/zones/z1?force=true'))).toBe(true);
});
});
describe('records', () => {
const handlers = {
...base,
'GET /api/v1/zones/z1': () => ({ body: zone({ record_count: 1 }) }),
'GET /api/v1/zones/z1/records': () => ({ body: { items: [mx] } }),
'GET /api/v1/clusters': () => ({ body: { items: [] } }),
'POST /api/v1/zones/z1/records': () => ({ status: 201, body: mx }),
};
it('lists records with their data and the generated-records hint', async () => {
mockApi(handlers);
renderApp('/zones/z1');
expect(await screen.findByText('10 mail.office.test')).toBeInTheDocument();
expect(screen.getByText(/erzeugt der Generator automatisch/)).toBeVisible();
expect(screen.queryByText(/nur in die Anfangsdatei/)).not.toBeInTheDocument();
});
it('shows the fields of the record type and sends only those', async () => {
const calls = mockApi(handlers);
renderApp('/zones/z1');
await userEvent.click(await screen.findByRole('button', { name: 'Record anlegen' }));
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByLabelText('Typ')).toHaveValue('A');
expect(within(dialog).getByLabelText('Adresse')).toBeInTheDocument();
expect(within(dialog).queryByLabelText('Ziel')).not.toBeInTheDocument();
await userEvent.selectOptions(within(dialog).getByLabelText('Typ'), 'MX');
expect(within(dialog).getByLabelText('Priorität (Preference)')).toHaveValue(10);
expect(within(dialog).getByLabelText('Ziel')).toBeInTheDocument();
expect(within(dialog).queryByLabelText('Adresse')).not.toBeInTheDocument();
await userEvent.selectOptions(within(dialog).getByLabelText('Typ'), 'SRV');
await userEvent.type(within(dialog).getByLabelText('Name'), '_ldap._tcp');
expect(within(dialog).getByText(/Ergibt: _ldap\._tcp\.office\.test/)).toBeVisible();
const weight = within(dialog).getByLabelText('Gewichtung');
await userEvent.clear(weight);
await userEvent.type(weight, '100');
await userEvent.type(within(dialog).getByLabelText('Port'), '389');
await userEvent.type(within(dialog).getByLabelText('Ziel'), 'dc1.office.test.');
await userEvent.click(within(dialog).getByRole('button', { name: 'Anlegen' }));
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
expect(calls.find((c) => c.method === 'POST')?.body).toEqual({
name: '_ldap._tcp',
type: 'SRV',
ttl: null,
description: '',
target: 'dc1.office.test',
priority: 0,
weight: 100,
port: 389,
});
});
it('collects several TXT strings', async () => {
const calls = mockApi(handlers);
renderApp('/zones/z1');
await userEvent.click(await screen.findByRole('button', { name: 'Record anlegen' }));
const dialog = await screen.findByRole('dialog');
await userEvent.selectOptions(within(dialog).getByLabelText('Typ'), 'TXT');
await userEvent.type(within(dialog).getByLabelText('Text 1'), 'v=spf1 mx -all');
await userEvent.click(within(dialog).getByRole('button', { name: 'Zeichenkette hinzufügen' }));
await userEvent.type(within(dialog).getByLabelText('Text 2'), 'second');
await userEvent.type(within(dialog).getByLabelText('TTL (Sekunden)'), '300');
await userEvent.click(within(dialog).getByRole('button', { name: 'Anlegen' }));
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
expect(calls.find((c) => c.method === 'POST')?.body).toEqual({
name: '@',
type: 'TXT',
ttl: 300,
description: '',
text: ['v=spf1 mx -all', 'second'],
});
});
it('shows field problems of the server at the field', async () => {
mockApi({
...handlers,
'POST /api/v1/zones/z1/records': () => ({
status: 422,
body: problem(422, 'validation_failed', {
problems: [{ field: 'address', code: 'invalid_ip' }],
}),
}),
});
renderApp('/zones/z1');
await userEvent.click(await screen.findByRole('button', { name: 'Record anlegen' }));
const dialog = await screen.findByRole('dialog');
await userEvent.type(within(dialog).getByLabelText('Adresse'), '192.0.2.300');
await userEvent.click(within(dialog).getByRole('button', { name: 'Anlegen' }));
expect(await within(dialog).findByText('Keine gültige IP-Adresse.')).toBeVisible();
});
});
describe('revisions per target', () => {
const targets = [
{ role: 'dns', target: '', cluster_id: null, cluster_name: '', member_role: '' },
{
role: 'dns',
target: 'a-ns1',
cluster_id: 'c1',
cluster_name: 'dns-a',
member_role: 'primary',
},
];
const file = (path: string, target: string, secret = false) => ({
path,
content: secret ? '' : `# ${path}\n`,
create_only: false,
target,
secret,
});
const revision = {
number: 5,
created_at: stamp,
created_by_name: 'admin',
comment: '',
dns: [
file('ddi-suite.conf', ''),
file('ddi-suite.conf', 'a-ns1'),
file('ddi-suite-keys.conf', 'a-ns1', true),
],
dhcp: [file('kea-dhcp4.conf', '')],
warnings: [{ code: 'dns.cluster_without_primary', params: { cluster: 'dns-b' } }],
targets,
};
const summary = {
number: 5,
created_at: stamp,
created_by_name: 'admin',
comment: '',
dns_files: 3,
dhcp_files: 1,
warnings: 1,
};
it('groups files and diffs by target and hides secret content', async () => {
mockApi({
...base,
'GET /api/v1/revisions': () => ({ body: { items: [summary], next_before: null } }),
'GET /api/v1/revisions/5': () => ({ body: revision }),
'GET /api/v1/revisions/5/diff': () => ({
body: {
number: 5,
against: 4,
files: [
{
role: 'dns',
target: 'a-ns1',
path: 'ddi-suite-keys.conf',
status: 'added',
diff: '',
inserted: 0,
deleted: 0,
},
{
role: 'dns',
target: 'a-gone',
path: 'ddi-suite.conf',
status: 'removed',
diff: '@@ -1 +0,0 @@\n-old\n',
inserted: 0,
deleted: 1,
},
],
},
}),
'GET /api/v1/deployments': () => ({ body: { items: [], next_before_id: null } }),
'GET /api/v1/agents': () => ({ body: { items: [ns1, ns2] } }),
});
renderApp('/revisions/5');
expect(
await screen.findByText('Cluster dns-b hat keinen Primary und bedient daher keine Zonen.'),
).toBeVisible();
// Files of the revision: default group and the member, with the hostname.
expect(
await screen.findByRole('heading', { name: 'Cluster dns-a · Primary · ns1' }),
).toBeVisible();
expect(screen.getByRole('heading', { name: 'Standardgruppe' })).toBeVisible();
// Diff: per role and target; unknown agents fall back to the ID.
expect(
screen.getByRole('heading', { name: 'DNS (BIND) · Cluster dns-a · Primary · ns1' }),
).toBeVisible();
expect(screen.getByRole('heading', { name: 'DNS (BIND) · a-gone' })).toBeVisible();
// Secret files: no content, neither in the file list nor in the diff.
expect(screen.getAllByText('geheimer Inhalt (nicht angezeigt)')).toHaveLength(2);
expect(screen.queryByRole('button', { name: /ddi-suite-keys\.conf/ })).toBeNull();
// The DHCP card has only the default group: no target headings there.
expect(screen.getByText('kea-dhcp4.conf')).toBeInTheDocument();
});
it('labels pending changes with the current cluster membership', async () => {
mockApi({
...base,
'GET /api/v1/config/pending': () => ({
body: {
latest_revision: 4,
changed: true,
changes: [],
files: [
{
role: 'dns',
target: 'a-ns2',
path: 'ddi-suite.conf',
status: 'modified',
diff: '@@ -1 +1 @@\n-a\n+b\n',
inserted: 1,
deleted: 1,
},
],
},
}),
'GET /api/v1/revisions': () => ({ body: { items: [summary], next_before: null } }),
'GET /api/v1/agents': () => ({ body: { items: [ns1, ns2] } }),
});
renderApp('/revisions');
expect(
await screen.findByRole('heading', { name: 'DNS (BIND) · Cluster dns-a · Secondary · ns2' }),
).toBeVisible();
});
});
describe('settings with clusters', () => {
it('assigns a network to a DHCP cluster', async () => {
const calls = mockApi({
...base,
'GET /api/v1/networks': () => ({ body: { items: [] } }),
'GET /api/v1/clusters': () => ({ body: { items: [dnsCluster, dhcpCluster] } }),
'POST /api/v1/networks': () => ({ status: 201, body: { ...network, dhcp_cluster_id: 'c2' } }),
'GET /api/v1/networks/n1': () => ({ body: { ...network, dhcp_cluster_id: 'c2' } }),
});
const { router } = renderApp('/networks');
await userEvent.click(await screen.findByRole('button', { name: 'Netz anlegen' }));
const dialog = await screen.findByRole('dialog');
await userEvent.type(within(dialog).getByLabelText('Netz (CIDR)'), '192.0.2.0/24');
await userEvent.type(within(dialog).getByLabelText('Name'), 'Office');
const select = within(dialog).getByLabelText('DHCP-Cluster');
await waitFor(() => {
expect(optionTexts(select)).toEqual(['Standard', 'dhcp-a']);
});
await userEvent.selectOptions(select, 'c2');
await userEvent.click(within(dialog).getByRole('button', { name: 'Speichern' }));
await waitFor(() => {
expect(router.state.location.pathname).toBe('/networks/n1');
});
// The detail page names the cluster.
expect(await screen.findByText('dhcp-a')).toBeInTheDocument();
expect(calls.find((c) => c.method === 'POST')?.body).toMatchObject({
dhcp_cluster_id: 'c2',
});
});
it('sets default clusters and the HA password file', async () => {
const settings = {
dns: {
primary_ns: 'ns1.office.test',
hostmaster: 'hostmaster@office.test',
name_servers: [{ name: 'ns1.office.test', addresses: [] }],
default_ttl: 3600,
refresh: 3600,
retry: 600,
expire: 604800,
minimum: 300,
recursion: false,
allow_recursion: [],
forwarders: [],
zone_dir: '/var/lib/bind/ddi-suite',
default_cluster_id: null,
},
dhcp: {
interfaces: ['*'],
direct_clients: true,
valid_lifetime: 3600,
control_socket: '/run/kea/kea4-ctrl-socket',
lease_file: '/var/lib/kea/kea-leases4.csv',
hooks_dir: '',
default_cluster_id: null,
ha_password_file: '',
},
};
const calls = mockApi({
...base,
'GET /api/v1/settings/generator': () => ({ body: settings }),
'PUT /api/v1/settings/generator': (c) => ({ body: c.body }),
'GET /api/v1/clusters': () => ({ body: { items: [dnsCluster, dhcpCluster] } }),
});
renderApp('/settings/generator');
const dnsSelect = await screen.findByLabelText('Standard-DNS-Cluster');
await waitFor(() => {
expect(optionTexts(dnsSelect)).toEqual(['keiner (Standardgruppe)', 'dns-a']);
});
await userEvent.selectOptions(dnsSelect, 'c1');
await userEvent.selectOptions(screen.getByLabelText('Standard-DHCP-Cluster'), 'c2');
await userEvent.type(screen.getByLabelText('HA-Passwortdatei (Kea)'), '/etc/kea/ha-pw');
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
expect(await screen.findByText('Gespeichert.')).toBeVisible();
const body = calls.find((c) => c.method === 'PUT')?.body as typeof settings;
expect(body.dns.default_cluster_id).toBe('c1');
expect(body.dhcp.default_cluster_id).toBe('c2');
expect(body.dhcp.ha_password_file).toBe('/etc/kea/ha-pw');
});
});
+20
View File
@@ -0,0 +1,20 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
import type { Schemas } from './api/client';
export type NameServer = Schemas['NameServer'];
/** One name server per line: name followed by its addresses. */
export function nsToText(list: NameServer[]): string {
return list.map((ns) => [ns.name, ...ns.addresses].join(' ')).join('\n');
}
export function parseNS(text: string): NameServer[] {
return text
.split('\n')
.map((l) => l.trim())
.filter((l) => l !== '')
.map((l) => {
const [name = '', ...addresses] = l.split(/\s+/);
return { name, addresses };
});
}
+18 -6
View File
@@ -412,12 +412,24 @@ export function AddressesPage() {
col.accessor('lease_state', {
id: 'lease',
header: t('addresses.lease'),
cell: (c) =>
c.getValue() ? (
<Badge kind={c.getValue() === 'active' ? 'success' : 'neutral'}>{c.getValue()}</Badge>
) : (
''
),
cell: (c) => {
const state = c.getValue();
if (!state) return '';
const expires = c.row.original.lease_expires_at;
return (
<Badge kind={state === 'active' ? 'success' : 'neutral'}>
<span
title={
expires
? t('addresses.leaseExpires', { time: formatDateTime(expires) })
: undefined
}
>
{t(`leases.states.${state}`, { defaultValue: state })}
</span>
</Badge>
);
},
}),
col.accessor('last_seen', {
header: t('addresses.lastSeen'),
+31 -19
View File
@@ -6,7 +6,16 @@ import { Link, useParams } from 'react-router';
import { api, unwrap, type Schemas } from '../api/client';
import { useSession } from '../auth/context';
import { Badge, ConfirmDialog, ErrorBox, Field, Modal, Notice, Spinner } from '../components/ui';
import {
Badge,
ConfirmDialog,
CopyButton,
ErrorBox,
Field,
Modal,
Notice,
Spinner,
} from '../components/ui';
import { fieldErrors } from '../errors';
import { formatDateTime } from '../format';
import { useRefreshInterval } from '../live';
@@ -45,6 +54,18 @@ export function ConnectionBadge({ agent }: { agent: Agent }) {
);
}
/** Cluster membership: name and member role, or a dash. */
export function ClusterMembership({ agent }: { agent: Agent }) {
const { t } = useTranslation();
if (!agent.cluster) return <span className="muted">{t('clusters.noCluster')}</span>;
return (
<span>
<Link to="/clusters">{agent.cluster.name}</Link>{' '}
<Badge>{t(`clusters.memberRoles.${agent.cluster.role}`)}</Badge>
</span>
);
}
function RevisionCell({ agent }: { agent: Agent }) {
const { t } = useTranslation();
const behind = agent.desired_revision > agent.applied_revision;
@@ -203,24 +224,6 @@ function TokenForm({
);
}
function CopyButton({ text }: { text: string }) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
if (!('clipboard' in navigator)) return null;
return (
<button
type="button"
onClick={() => {
void navigator.clipboard.writeText(text).then(() => {
setCopied(true);
});
}}
>
{copied ? t('common.copied') : t('common.copy')}
</button>
);
}
function TokenResult({ result, onClose }: { result: TokenCreated; onClose: () => void }) {
const { t } = useTranslation();
return (
@@ -384,6 +387,7 @@ export function AgentsPage() {
<tr>
<th>{t('agents.host')}</th>
<th>{t('agents.role')}</th>
<th>{t('agents.cluster')}</th>
<th>{t('agents.connection')}</th>
<th>{t('agents.health')}</th>
<th>{t('agents.mode')}</th>
@@ -398,6 +402,9 @@ export function AgentsPage() {
<Link to={`/agents/${a.id}`}>{a.hostname || a.id}</Link>
</td>
<td>{t(`agents.roles.${a.role}`)}</td>
<td>
<ClusterMembership agent={a} />
</td>
<td>
<ConnectionBadge agent={a} />
</td>
@@ -508,6 +515,11 @@ export function AgentDetailPage() {
<span className="muted"> ({formatDateTime(a.health.checked_at)})</span>
)}
</dd>
<dt>{t('agents.cluster')}</dt>
<dd>
<ClusterMembership agent={a} />
{a.cluster && <span className="mono"> {a.cluster.address}</span>}
</dd>
<dt>{t('agents.revision')}</dt>
<dd>
<RevisionCell agent={a} />
+626
View File
@@ -0,0 +1,626 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useState, type SyntheticEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router';
import { api, unwrap, type Schemas } from '../api/client';
import { useSession } from '../auth/context';
import {
clustersKey,
hostOf,
tsigKeysKey,
useAgents,
useClusters,
type AgentRole,
type Cluster,
type MemberRole,
} from '../clusters';
import { Badge, ConfirmDialog, ErrorBox, Field, Modal, Notice, Spinner } from '../components/ui';
import { fieldErrors } from '../errors';
import { nsToText, parseNS } from '../nameServers';
import { pendingKey } from '../pending';
import { SecretDialog } from './TSIGKeysPage';
type ClusterRequest = Schemas['ClusterRequest'];
type HAMode = Schemas['HAMode'];
type TFn = ReturnType<typeof useTranslation>['t'];
interface MemberRow {
agent_id: string;
role: MemberRole;
address: string;
}
const roles: AgentRole[] = ['dns', 'dhcp'];
const memberRoles: MemberRole[] = ['primary', 'secondary'];
const haModes: HAMode[] = ['hot-standby', 'load-balancing'];
/** Field name of a member property as the server reports it. */
const memberField = (i: number, key: keyof MemberRow) => `members[${String(i)}].${key}`;
/**
* Membership rules of ADR 0023, checked before sending: a DNS cluster has
* at most one primary (and any number of secondaries), a DHCP cluster at
* most one primary and one secondary; every member needs an agent and the
* address the other members reach it at.
*/
function validateMembers(t: TFn, role: AgentRole, rows: MemberRow[]): Record<string, string> {
const out: Record<string, string> = {};
rows.forEach((r, i) => {
if (!r.agent_id) out[memberField(i, 'agent_id')] = t('clusters.agentRequired');
if (!r.address.trim()) out[memberField(i, 'address')] = t('clusters.addressRequired');
});
const primaries = rows.filter((r) => r.role === 'primary').length;
const secondaries = rows.length - primaries;
if (role === 'dns' && primaries > 1) out.members = t('clusters.dnsOnePrimary');
if (role === 'dhcp' && (primaries > 1 || secondaries > 1)) out.members = t('clusters.dhcpPair');
return out;
}
function ClusterForm({
role,
cluster,
onClose,
}: {
role: AgentRole;
cluster?: Cluster;
onClose: () => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const agents = useAgents();
const [name, setName] = useState(cluster?.name ?? '');
const [description, setDescription] = useState(cluster?.description ?? '');
const [rows, setRows] = useState<MemberRow[]>(
cluster?.members.map((m) => ({ agent_id: m.agent_id, role: m.role, address: m.address })) ?? [],
);
const [primaryNs, setPrimaryNs] = useState(cluster?.primary_ns ?? '');
const [ns, setNs] = useState(nsToText(cluster?.name_servers ?? []));
const [haMode, setHaMode] = useState<HAMode>(cluster?.ha_mode ?? 'hot-standby');
const [haPort, setHaPort] = useState(cluster?.ha_port ? String(cluster.ha_port) : '');
const [submitted, setSubmitted] = useState(false);
const save = useMutation({
mutationFn: () => {
const body: ClusterRequest = {
name: name.trim(),
description,
members: rows.map((r) => ({ ...r, address: r.address.trim() })),
...(role === 'dns'
? { primary_ns: primaryNs.trim(), name_servers: parseNS(ns) }
: { ha_mode: haMode, ...(haPort ? { ha_port: Number(haPort) } : {}) }),
};
if (cluster) {
return unwrap(
api.PUT('/clusters/{id}', {
params: { path: { id: cluster.id } },
body: { ...body, version: cluster.version },
}),
);
}
return unwrap(api.POST('/clusters', { body: { ...body, role } }));
},
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: clustersKey });
void qc.invalidateQueries({ queryKey: ['agents'] });
void qc.invalidateQueries({ queryKey: tsigKeysKey });
void qc.invalidateQueries({ queryKey: pendingKey });
onClose();
},
});
const clientErrors = validateMembers(t, role, rows);
const errors: Record<string, string> = {
...fieldErrors(t, save.error),
...(submitted ? clientErrors : {}),
};
const submit = (e: SyntheticEvent) => {
e.preventDefault();
setSubmitted(true);
if (Object.keys(clientErrors).length === 0) save.mutate();
};
const allAgents = agents.data?.items ?? [];
/** Agents of the cluster's role that are free (or members of this cluster). */
const candidates = (row: number) =>
allAgents.filter(
(a) =>
a.role === role &&
!a.revoked_at &&
(!a.cluster || a.cluster.id === cluster?.id) &&
!rows.some((r, j) => j !== row && r.agent_id === a.id),
);
const setRow = (i: number, patch: Partial<MemberRow>) => {
setRows((rs) => rs.map((r, j) => (j === i ? { ...r, ...patch } : r)));
};
const pickAgent = (i: number, agentId: string) => {
const agent = allAgents.find((a) => a.id === agentId);
const current = rows[i];
setRow(i, {
agent_id: agentId,
// Suggest the address the agent connects from.
...(current && !current.address && agent ? { address: hostOf(agent.remote_address) } : {}),
});
};
const full = role === 'dhcp' && rows.length >= 2;
const primaries = rows.filter((r) => r.role === 'primary').length;
const known = [
'name',
'description',
'members',
'primary_ns',
'name_servers',
'ha_mode',
'ha_port',
...rows.flatMap((_, i) => [memberField(i, 'agent_id'), memberField(i, 'address')]),
];
return (
<Modal
title={
cluster
? t('clusters.editTitle', { name: cluster.name })
: t(role === 'dns' ? 'clusters.newDNS' : 'clusters.newDHCP')
}
onClose={onClose}
wide
footer={
<>
<button type="button" onClick={onClose}>
{t('common.cancel')}
</button>
<button type="submit" form="cluster-form" className="primary" disabled={save.isPending}>
{cluster ? t('common.save') : t('common.create')}
</button>
</>
}
>
<form id="cluster-form" className="form" onSubmit={submit} noValidate>
<div className="grid-2">
<Field label={t('clusters.name')} error={errors.name}>
{(id) => (
<input
id={id}
value={name}
required
onChange={(e) => {
setName(e.target.value);
}}
/>
)}
</Field>
<Field label={t('clusters.description')} error={errors.description}>
{(id) => (
<input
id={id}
value={description}
onChange={(e) => {
setDescription(e.target.value);
}}
/>
)}
</Field>
</div>
<fieldset>
<legend>{t('clusters.members')}</legend>
<p className="field-hint">
{t(role === 'dns' ? 'clusters.membersHintDNS' : 'clusters.membersHintDHCP')}
</p>
{agents.isPending && <Spinner />}
<ErrorBox error={agents.error} />
{rows.length === 0 && <p className="muted">{t('clusters.noMembers')}</p>}
{rows.map((r, i) => {
const options = candidates(i);
const missing = r.agent_id && !options.some((a) => a.id === r.agent_id);
const memberHost =
cluster?.members.find((m) => m.agent_id === r.agent_id)?.hostname || r.agent_id;
return (
<div className="member-row" key={i}>
<Field label={t('clusters.agent')} error={errors[memberField(i, 'agent_id')]}>
{(id) => (
<select
id={id}
value={r.agent_id}
onChange={(e) => {
pickAgent(i, e.target.value);
}}
>
<option value="">{t('clusters.pickAgent')}</option>
{options.map((a) => (
<option key={a.id} value={a.id}>
{a.hostname || a.id}
</option>
))}
{missing && <option value={r.agent_id}>{memberHost}</option>}
</select>
)}
</Field>
<Field label={t('clusters.memberRole')} error={errors[memberField(i, 'role')]}>
{(id) => (
<select
id={id}
value={r.role}
onChange={(e) => {
setRow(i, { role: e.target.value as MemberRole });
}}
>
{memberRoles.map((mr) => (
<option key={mr} value={mr}>
{t(`clusters.memberRoles.${mr}`)}
</option>
))}
</select>
)}
</Field>
<Field label={t('clusters.address')} error={errors[memberField(i, 'address')]}>
{(id) => (
<input
id={id}
className="mono"
value={r.address}
placeholder="192.0.2.53"
onChange={(e) => {
setRow(i, { address: e.target.value });
}}
/>
)}
</Field>
<button
type="button"
className="link"
aria-label={t('clusters.removeMemberN', { n: i + 1 })}
onClick={() => {
setRows((rs) => rs.filter((_, j) => j !== i));
}}
>
{t('clusters.removeMember')}
</button>
</div>
);
})}
{errors.members && (
<div className="field-error" role="alert">
{errors.members}
</div>
)}
{role === 'dns' && rows.length > 0 && primaries === 0 && (
<Notice kind="warning">{t('clusters.noPrimaryHint')}</Notice>
)}
<button
type="button"
disabled={full}
onClick={() => {
setRows((rs) => [
...rs,
{
agent_id: '',
role: rs.some((x) => x.role === 'primary') ? 'secondary' : 'primary',
address: '',
},
]);
}}
>
{t('clusters.addMember')}
</button>
<p className="field-hint">{t('clusters.addressHint')}</p>
</fieldset>
{role === 'dns' ? (
<fieldset>
<legend>{t('clusters.dnsOptions')}</legend>
<p className="field-hint">{t('clusters.dnsOptionsHint')}</p>
<Field label={t('generator.primaryNS')} error={errors.primary_ns}>
{(id) => (
<input
id={id}
value={primaryNs}
placeholder="ns1.example.com"
onChange={(e) => {
setPrimaryNs(e.target.value);
}}
/>
)}
</Field>
<Field
label={t('generator.nameServers')}
error={errors.name_servers}
hint={t('generator.nameServersHint')}
>
{(id) => (
<textarea
id={id}
rows={3}
className="mono"
value={ns}
onChange={(e) => {
setNs(e.target.value);
}}
/>
)}
</Field>
</fieldset>
) : (
<fieldset>
<legend>{t('clusters.dhcpOptions')}</legend>
<p className="field-hint">{t('clusters.dhcpOptionsHint')}</p>
<div className="grid-2">
<Field
label={t('clusters.haMode')}
error={errors.ha_mode}
hint={t(`clusters.haModeHelp.${haMode}`)}
>
{(id) => (
<select
id={id}
value={haMode}
onChange={(e) => {
setHaMode(e.target.value as HAMode);
}}
>
{haModes.map((m) => (
<option key={m} value={m}>
{t(`clusters.haModes.${m}`)}
</option>
))}
</select>
)}
</Field>
<Field label={t('clusters.haPort')} error={errors.ha_port}>
{(id) => (
<input
id={id}
type="number"
min={1}
max={65535}
value={haPort}
placeholder="8001"
onChange={(e) => {
setHaPort(e.target.value);
}}
/>
)}
</Field>
</div>
</fieldset>
)}
<ErrorBox error={save.error} fields={known} />
</form>
</Modal>
);
}
function MemberList({ cluster }: { cluster: Cluster }) {
const { t } = useTranslation();
if (cluster.members.length === 0) return <span className="muted">{t('clusters.noMembers')}</span>;
return (
<ul className="member-list">
{cluster.members.map((m) => (
<li key={m.agent_id}>
<Link to={`/agents/${m.agent_id}`}>{m.hostname || m.agent_id}</Link>{' '}
<Badge kind={m.role === 'primary' ? 'success' : 'neutral'}>
{t(`clusters.memberRoles.${m.role}`)}
</Badge>{' '}
<span className="mono">{m.address}</span>{' '}
{m.connected ? (
<Badge kind="success">{t('agents.connected')}</Badge>
) : (
<Badge kind="warning">{t('agents.disconnected')}</Badge>
)}
</li>
))}
</ul>
);
}
function ClusterOptions({
cluster,
onShowSecret,
}: {
cluster: Cluster;
onShowSecret?: (key: { id: string; name: string }) => void;
}) {
const { t } = useTranslation();
if (cluster.role === 'dhcp') {
return (
<div>
<div>
{t('clusters.haMode')}: {t(`clusters.haModes.${cluster.ha_mode}`)}, {t('clusters.haPort')}{' '}
{cluster.ha_port}
</div>
{cluster.members.length < 2 && <div className="muted">{t('clusters.singleServer')}</div>}
</div>
);
}
const key = cluster.transfer_key;
return (
<div>
<div>
{t('clusters.transferKey')}:{' '}
{key ? <span className="mono">{key.name}</span> : t('common.none')}
{key && onShowSecret && (
<>
{' '}
<button
type="button"
className="link"
onClick={() => {
onShowSecret(key);
}}
>
{t('tsig.showSecret')}
</button>
</>
)}
</div>
<div>
{t('generator.primaryNS')}:{' '}
{cluster.primary_ns ? (
<span className="mono">{cluster.primary_ns}</span>
) : (
<span className="muted">{t('clusters.fromSettings')}</span>
)}
</div>
<div>
{t('generator.nameServers')}:{' '}
{cluster.name_servers.length > 0 ? (
<span className="mono">{cluster.name_servers.map((n) => n.name).join(', ')}</span>
) : (
<span className="muted">{t('clusters.fromSettings')}</span>
)}
</div>
</div>
);
}
export function ClustersPage() {
const { t } = useTranslation();
const { can } = useSession();
const qc = useQueryClient();
const admin = can('settings.admin');
const q = useClusters();
const [editing, setEditing] = useState<{ role: AgentRole; cluster?: Cluster } | null>(null);
const [deleting, setDeleting] = useState<Cluster | null>(null);
const [secretFor, setSecretFor] = useState<{ id: string; name: string } | null>(null);
const del = useMutation({
mutationFn: (c: Cluster) =>
unwrap(api.DELETE('/clusters/{id}', { params: { path: { id: c.id } } })),
onSuccess: async () => {
setDeleting(null);
await qc.invalidateQueries({ queryKey: clustersKey });
void qc.invalidateQueries({ queryKey: ['agents'] });
void qc.invalidateQueries({ queryKey: tsigKeysKey });
void qc.invalidateQueries({ queryKey: pendingKey });
},
});
return (
<div className="page">
<h1>{t('clusters.title')}</h1>
<p className="field-hint">{t('clusters.hint')}</p>
{q.isPending && <Spinner />}
<ErrorBox error={q.error} />
{q.data &&
roles.map((role) => {
const list = q.data.items.filter((c) => c.role === role);
return (
<section className="card" key={role}>
<div className="page-head">
<h2>{t(`clusters.section.${role}`)}</h2>
{admin && (
<button
type="button"
className="primary"
onClick={() => {
setEditing({ role });
}}
>
{t(role === 'dns' ? 'clusters.newDNS' : 'clusters.newDHCP')}
</button>
)}
</div>
{list.length === 0 ? (
<p>{t(`clusters.empty.${role}`)}</p>
) : (
<table className="table">
<thead>
<tr>
<th>{t('clusters.name')}</th>
<th>{t('clusters.members')}</th>
<th>{t('clusters.options')}</th>
<th>{t('clusters.usage')}</th>
{admin && <th>{t('common.actions')}</th>}
</tr>
</thead>
<tbody>
{list.map((c) => (
<tr key={c.id}>
<td>
<strong>{c.name}</strong>
{c.description && <div className="muted">{c.description}</div>}
</td>
<td>
<MemberList cluster={c} />
</td>
<td>
<ClusterOptions
cluster={c}
{...(admin ? { onShowSecret: setSecretFor } : {})}
/>
</td>
<td>
{role === 'dns'
? t('clusters.zoneCount', { count: c.zone_count })
: t('clusters.networkCount', { count: c.network_count })}
</td>
{admin && (
<td className="row-actions">
<button
type="button"
className="link"
onClick={() => {
setEditing({ role, cluster: c });
}}
>
{t('common.edit')}
</button>
<button
type="button"
className="link danger"
onClick={() => {
del.reset();
setDeleting(c);
}}
>
{t('common.delete')}
</button>
</td>
)}
</tr>
))}
</tbody>
</table>
)}
</section>
);
})}
{editing && (
<ClusterForm
role={editing.role}
{...(editing.cluster ? { cluster: editing.cluster } : {})}
onClose={() => {
setEditing(null);
}}
/>
)}
{deleting && (
<ConfirmDialog
title={t('common.delete')}
message={t('clusters.deleteConfirm', { name: deleting.name })}
danger
busy={del.isPending}
error={del.error}
onCancel={() => {
setDeleting(null);
del.reset();
}}
onConfirm={() => {
del.mutate(deleting);
}}
/>
)}
{secretFor && (
<SecretDialog
keyId={secretFor.id}
name={secretFor.name}
onClose={() => {
setSecretFor(null);
}}
/>
)}
</div>
);
}
+45 -9
View File
@@ -5,21 +5,52 @@ import { Link } from 'react-router';
import { api, unwrap, type Schemas } from '../api/client';
import { useSession } from '../auth/context';
import { groupByTarget, hasClusterTargets, useTargetLabels } from '../clusters';
import { Badge, ErrorBox, Notice, Spinner } from '../components/ui';
type GeneratedFile = Schemas['GeneratedFile'];
type GeneratorProblem = Schemas['GeneratorProblem'];
type RevisionTarget = Schemas['RevisionTarget'];
type AgentRole = Schemas['AgentRole'];
function FileEntry({ file }: { file: GeneratedFile }) {
const { t } = useTranslation();
if (file.secret) {
// Key material never leaves the server (ADR 0023): no content to show.
return (
<div className="file file-secret">
<span className="mono">{file.path}</span> <Badge kind="warning">{t('config.secret')}</Badge>{' '}
<span className="muted">{t('config.secretContent')}</span>
</div>
);
}
return (
<details className="file">
<summary>
<span className="mono">{file.path}</span>{' '}
{file.create_only && <Badge>{t('config.createOnly')}</Badge>}
</summary>
<pre className="file-content">{file.content}</pre>
</details>
);
}
export function GeneratedFiles({
title,
role,
files,
targets,
error,
}: {
title: string;
role: AgentRole;
files: GeneratedFile[];
targets: RevisionTarget[];
error?: GeneratorProblem;
}) {
const { t } = useTranslation();
const grouped = hasClusterTargets(files);
const label = useTargetLabels(targets, grouped);
return (
<section className="card">
<h2>{title}</h2>
@@ -29,15 +60,16 @@ export function GeneratedFiles({
</div>
)}
{!error && files.length === 0 && <p>{t('config.empty')}</p>}
{files.map((f) => (
<details key={f.path} className="file">
<summary>
<span className="mono">{f.path}</span>{' '}
{f.create_only && <Badge>{t('config.createOnly')}</Badge>}
</summary>
<pre className="file-content">{f.content}</pre>
</details>
))}
{grouped
? groupByTarget(files, () => role, targets).map((g) => (
<div key={g.target} className="target-group">
<h3>{label(role, g.target)}</h3>
{g.files.map((f) => (
<FileEntry key={f.path} file={f} />
))}
</div>
))
: files.map((f) => <FileEntry key={f.path} file={f} />)}
</section>
);
}
@@ -77,12 +109,16 @@ export function ConfigPage() {
)}
<GeneratedFiles
title={t('config.dns')}
role="dns"
files={q.data.dns}
targets={q.data.targets}
{...(q.data.dns_error ? { error: q.data.dns_error } : {})}
/>
<GeneratedFiles
title={t('config.dhcp')}
role="dhcp"
files={q.data.dhcp}
targets={q.data.targets}
{...(q.data.dhcp_error ? { error: q.data.dhcp_error } : {})}
/>
</>
+56 -16
View File
@@ -5,26 +5,12 @@ import { useTranslation } from 'react-i18next';
import { api, unwrap, type Schemas } from '../api/client';
import { ErrorBox, Field, Notice, Spinner } from '../components/ui';
import { ClusterSelect } from '../components/ClusterSelect';
import { fieldErrors } from '../errors';
import { splitList } from '../format';
import { nsToText, parseNS } from '../nameServers';
type Settings = Schemas['GeneratorSettings'];
type NameServer = Settings['dns']['name_servers'][number];
function nsToText(list: NameServer[]): string {
return list.map((ns) => [ns.name, ...ns.addresses].join(' ')).join('\n');
}
function parseNS(text: string): NameServer[] {
return text
.split('\n')
.map((l) => l.trim())
.filter((l) => l !== '')
.map((l) => {
const [name = '', ...addresses] = l.split(/\s+/);
return { name, addresses };
});
}
export function GeneratorSettingsPage() {
const q = useQuery({
@@ -149,6 +135,23 @@ function GeneratorForm({ initial }: { initial: Settings }) {
</Field>
))}
</div>
<Field
label={t('generator.defaultDNSCluster')}
error={fe['dns.default_cluster_id']}
hint={t('generator.defaultDNSClusterHint')}
>
{(id) => (
<ClusterSelect
id={id}
role="dns"
value={dns.default_cluster_id ?? null}
emptyLabel={t('clusters.defaultGroup')}
onChange={(v) => {
setDns({ ...dns, default_cluster_id: v });
}}
/>
)}
</Field>
<label className="checkbox">
<input
type="checkbox"
@@ -237,6 +240,23 @@ function GeneratorForm({ initial }: { initial: Settings }) {
/>
{t('generator.directClients')}
</label>
<Field
label={t('generator.defaultDHCPCluster')}
error={fe['dhcp.default_cluster_id']}
hint={t('generator.defaultDHCPClusterHint')}
>
{(id) => (
<ClusterSelect
id={id}
role="dhcp"
value={dhcp.default_cluster_id ?? null}
emptyLabel={t('clusters.defaultGroup')}
onChange={(v) => {
setDhcp({ ...dhcp, default_cluster_id: v });
}}
/>
)}
</Field>
</section>
<section className="card form">
@@ -295,6 +315,23 @@ function GeneratorForm({ initial }: { initial: Settings }) {
/>
)}
</Field>
<Field
label={t('generator.haPasswordFile')}
error={fe['dhcp.ha_password_file']}
hint={t('generator.haPasswordFileHint')}
>
{(id) => (
<input
id={id}
className="mono"
value={dhcp.ha_password_file ?? ''}
placeholder="/etc/kea/ddi-suite-ha-password"
onChange={(e) => {
setDhcp({ ...dhcp, ha_password_file: e.target.value });
}}
/>
)}
</Field>
</div>
</section>
@@ -308,11 +345,14 @@ function GeneratorForm({ initial }: { initial: Settings }) {
'dns.allow_recursion',
'dns.forwarders',
'dns.zone_dir',
'dns.default_cluster_id',
'dhcp.interfaces',
'dhcp.valid_lifetime',
'dhcp.control_socket',
'dhcp.lease_file',
'dhcp.hooks_dir',
'dhcp.default_cluster_id',
'dhcp.ha_password_file',
]}
/>
{save.isSuccess && <Notice kind="success">{t('common.saved')}</Notice>}
+22
View File
@@ -4,6 +4,7 @@ import { useState, type SyntheticEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { api, unwrap, type Schemas } from '../api/client';
import { ClusterSelect } from '../components/ClusterSelect';
import { ErrorBox, Field, Modal } from '../components/ui';
import { fieldErrors } from '../errors';
import { splitList } from '../format';
@@ -39,6 +40,7 @@ export function NetworkForm({ network, onClose, onSaved }: Props) {
const [dns, setDns] = useState(network?.dns_servers.join(', ') ?? '');
const [ntp, setNtp] = useState(network?.ntp_servers.join(', ') ?? '');
const [pools, setPools] = useState<PoolRow[]>(network?.pools.map((p) => ({ ...p })) ?? []);
const [clusterId, setClusterId] = useState<string | null>(network?.dhcp_cluster_id ?? null);
const save = useMutation({
mutationFn: () => {
@@ -55,6 +57,7 @@ export function NetworkForm({ network, onClose, onSaved }: Props) {
dns_servers: splitList(dns),
ntp_servers: splitList(ntp),
pools: pools.filter((p) => p.start || p.end),
dhcp_cluster_id: clusterId,
};
if (network) {
return unwrap(
@@ -68,6 +71,9 @@ export function NetworkForm({ network, onClose, onSaved }: Props) {
},
onSuccess: async (n) => {
await qc.invalidateQueries({ queryKey: ['networks'] });
// Network counts per cluster and derived zones depend on networks.
void qc.invalidateQueries({ queryKey: ['clusters'] });
void qc.invalidateQueries({ queryKey: ['zones'] });
onSaved(n);
},
});
@@ -86,6 +92,7 @@ export function NetworkForm({ network, onClose, onSaved }: Props) {
'ntp_servers',
'pools',
'type',
'dhcp_cluster_id',
];
const submit = (e: SyntheticEvent) => {
@@ -246,6 +253,21 @@ export function NetworkForm({ network, onClose, onSaved }: Props) {
/>
)}
</Field>
<Field
label={t('networks.dhcpCluster')}
error={fe.dhcp_cluster_id}
hint={t('networks.dhcpClusterHint')}
>
{(id) => (
<ClusterSelect
id={id}
role="dhcp"
value={clusterId}
emptyLabel={t('clusters.defaultCluster')}
onChange={setClusterId}
/>
)}
</Field>
<Field
label={t('networks.dnsServers')}
error={fe.dns_servers}
+6
View File
@@ -7,6 +7,7 @@ import { Link, useNavigate, useParams } from 'react-router';
import { api, ApiError, unwrap, type Schemas } from '../api/client';
import { useSession } from '../auth/context';
import { Badge, ConfirmDialog, ErrorBox, Spinner, UsageBar } from '../components/ui';
import { useClusters } from '../clusters';
import { formatNumber } from '../format';
import { NetworkForm, type Network } from './NetworkForm';
@@ -162,6 +163,9 @@ export function NetworkDetailPage() {
queryKey: ['networks', id],
queryFn: () => unwrap(api.GET('/networks/{id}', { params: { path: { id } } })),
});
const clusterId = q.data?.dhcp_cluster_id ?? null;
const clusters = useClusters(clusterId !== null);
const clusterName = clusters.data?.items.find((c) => c.id === clusterId)?.name ?? clusterId;
const del = useMutation({
mutationFn: (force: boolean) =>
unwrap(
@@ -244,6 +248,8 @@ export function NetworkDetailPage() {
<dd>{n.dhcp_enabled ? t('common.yes') : t('common.no')}</dd>
<dt>{t('networks.leaseTime')}</dt>
<dd>{n.lease_time ?? t('common.none')}</dd>
<dt>{t('networks.dhcpCluster')}</dt>
<dd>{clusterName ?? t('clusters.defaultCluster')}</dd>
<dt>{t('networks.dnsServers')}</dt>
<dd className="mono">{n.dns_servers.join(', ') || t('common.none')}</dd>
<dt>{t('networks.ntpServers')}</dt>
+281
View File
@@ -0,0 +1,281 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useState, type SyntheticEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { api, unwrap, type Schemas } from '../api/client';
import { zonesKey } from '../clusters';
import { ErrorBox, Field, Modal } from '../components/ui';
import { fieldErrors } from '../errors';
import { pendingKey } from '../pending';
import {
fieldsOf,
numberFields,
ownerName,
recordTypes,
type DNSRecord,
type NumberField,
type RecordType,
} from '../records';
import type { Zone } from './ZoneForm';
type RecordRequest = Schemas['RecordRequest'];
/** Create (no record) or edit dialog with the fields of the chosen type. */
export function RecordForm({
zone,
record,
onClose,
}: {
zone: Zone;
record?: DNSRecord;
onClose: () => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const [name, setName] = useState(record?.name ?? '');
const [type, setType] = useState<RecordType>(
record?.type ?? (zone.kind === 'reverse' ? 'PTR' : 'A'),
);
const [ttl, setTtl] = useState(record?.ttl ? String(record.ttl) : '');
const [address, setAddress] = useState(record?.address ?? '');
const [target, setTarget] = useState(record?.target ?? '');
const [numbers, setNumbers] = useState<Record<NumberField, string>>({
preference: record ? String(record.preference) : '10',
priority: record ? String(record.priority) : '0',
weight: record ? String(record.weight) : '0',
port: record?.port ? String(record.port) : '',
});
const [texts, setTexts] = useState<string[]>(record?.text.length ? record.text : ['']);
const [description, setDescription] = useState(record?.description ?? '');
const fields = fieldsOf[type];
const save = useMutation({
mutationFn: () => {
const body: RecordRequest = {
name: name.trim() || '@',
type,
ttl: ttl ? Number(ttl) : null,
description,
};
if (fields.includes('address')) body.address = address.trim();
// Targets are FQDNs without the trailing dot.
if (fields.includes('target')) body.target = target.trim().replace(/\.$/, '');
for (const f of numberFields) {
if (fields.includes(f)) body[f] = numbers[f] === '' ? 0 : Number(numbers[f]);
}
if (fields.includes('text')) body.text = texts.filter((s) => s !== '');
if (record) {
return unwrap(
api.PUT('/zones/{id}/records/{recordId}', {
params: { path: { id: zone.id, recordId: record.id } },
body: { ...body, version: record.version },
}),
);
}
return unwrap(api.POST('/zones/{id}/records', { params: { path: { id: zone.id } }, body }));
},
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: zonesKey });
void qc.invalidateQueries({ queryKey: pendingKey });
onClose();
},
});
const fe = fieldErrors(t, save.error);
const textError = fe.text ?? Object.entries(fe).find(([k]) => k.startsWith('text['))?.[1];
const submit = (e: SyntheticEvent) => {
e.preventDefault();
save.mutate();
};
const numberField = (f: NumberField, min = 0) => (
<Field key={f} label={t(`records.${f}`)} error={fe[f]}>
{(id) => (
<input
id={id}
type="number"
min={min}
max={65535}
value={numbers[f]}
required
onChange={(e) => {
setNumbers({ ...numbers, [f]: e.target.value });
}}
/>
)}
</Field>
);
return (
<Modal
title={record ? t('records.editTitle') : t('records.new')}
onClose={onClose}
wide
footer={
<>
<button type="button" onClick={onClose}>
{t('common.cancel')}
</button>
<button type="submit" form="record-form" className="primary" disabled={save.isPending}>
{record ? t('common.save') : t('common.create')}
</button>
</>
}
>
<form id="record-form" className="form" onSubmit={submit}>
<div className="grid-2">
<Field
label={t('records.name')}
error={fe.name}
hint={t('records.nameHint', { fqdn: ownerName(name, zone.name) })}
>
{(id) => (
<input
id={id}
className="mono"
value={name}
placeholder="@"
onChange={(e) => {
setName(e.target.value);
}}
/>
)}
</Field>
<Field label={t('records.type')} error={fe.type} hint={t(`records.typeHelp.${type}`)}>
{(id) => (
<select
id={id}
value={type}
onChange={(e) => {
setType(e.target.value as RecordType);
}}
>
{recordTypes.map((rt) => (
<option key={rt} value={rt}>
{rt}
</option>
))}
</select>
)}
</Field>
{fields.includes('address') && (
<Field label={t('records.address')} error={fe.address}>
{(id) => (
<input
id={id}
className="mono"
value={address}
required
placeholder={type === 'AAAA' ? '2001:db8::10' : '192.0.2.10'}
onChange={(e) => {
setAddress(e.target.value);
}}
/>
)}
</Field>
)}
{fields.includes('preference') && numberField('preference')}
{fields.includes('priority') && numberField('priority')}
{fields.includes('weight') && numberField('weight')}
{fields.includes('port') && numberField('port', 1)}
{fields.includes('target') && (
<Field label={t('records.target')} error={fe.target} hint={t('records.targetHint')}>
{(id) => (
<input
id={id}
className="mono"
value={target}
required
placeholder="host.example.com"
onChange={(e) => {
setTarget(e.target.value);
}}
/>
)}
</Field>
)}
<Field label={t('records.ttl')} error={fe.ttl} hint={t('records.ttlHint')}>
{(id) => (
<input
id={id}
type="number"
min={1}
value={ttl}
onChange={(e) => {
setTtl(e.target.value);
}}
/>
)}
</Field>
<Field label={t('records.description')} error={fe.description}>
{(id) => (
<input
id={id}
value={description}
onChange={(e) => {
setDescription(e.target.value);
}}
/>
)}
</Field>
</div>
{fields.includes('text') && (
<fieldset>
<legend>{t('records.text')}</legend>
<p className="field-hint">{t('records.textHint')}</p>
{texts.map((s, i) => (
<div className="text-row" key={i}>
<input
aria-label={t('records.textN', { n: i + 1 })}
value={s}
onChange={(e) => {
setTexts(texts.map((x, j) => (j === i ? e.target.value : x)));
}}
/>
{texts.length > 1 && (
<button
type="button"
className="link"
aria-label={t('records.removeTextN', { n: i + 1 })}
onClick={() => {
setTexts(texts.filter((_, j) => j !== i));
}}
>
{t('records.removeText')}
</button>
)}
</div>
))}
{textError && (
<div className="field-error" role="alert">
{textError}
</div>
)}
<button
type="button"
onClick={() => {
setTexts([...texts, '']);
}}
>
{t('records.addText')}
</button>
</fieldset>
)}
<ErrorBox
error={save.error}
fields={[
'name',
'type',
'ttl',
'address',
'target',
'description',
...numberFields,
...Object.keys(fe).filter((k) => k === 'text' || k.startsWith('text[')),
]}
/>
</form>
</Modal>
);
}
+13 -5
View File
@@ -230,7 +230,15 @@ function useRevisionIndex() {
});
}
function RevisionDiff({ number, others }: { number: number; others: number[] }) {
function RevisionDiff({
number,
others,
targets,
}: {
number: number;
others: number[];
targets: Revision['targets'];
}) {
const { t } = useTranslation();
const [against, setAgainst] = useState(number - 1);
const q = useQuery({
@@ -273,7 +281,7 @@ function RevisionDiff({ number, others }: { number: number; others: number[] })
</div>
{q.isPending && <Spinner />}
<ErrorBox error={q.error} />
{q.data && <DiffView files={q.data.files} />}
{q.data && <DiffView files={q.data.files} targets={targets} />}
</section>
);
}
@@ -361,9 +369,9 @@ function RevisionDetail({ number }: { number: number }) {
<h2>{t('deployments.title')}</h2>
<DeploymentsTable revision={r.number} />
</section>
<RevisionDiff number={r.number} others={numbers} />
<GeneratedFiles title={t('config.dns')} files={r.dns} />
<GeneratedFiles title={t('config.dhcp')} files={r.dhcp} />
<RevisionDiff number={r.number} others={numbers} targets={r.targets} />
<GeneratedFiles title={t('config.dns')} role="dns" files={r.dns} targets={r.targets} />
<GeneratedFiles title={t('config.dhcp')} role="dhcp" files={r.dhcp} targets={r.targets} />
{dialog === 'deploy' && <DeployDialog revision={r.number} latest={latest} onClose={close} />}
{dialog === 'rollback' && <RollbackDialog revision={r.number} onClose={close} />}
</div>
+376
View File
@@ -0,0 +1,376 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useState, type SyntheticEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { api, unwrap, type Schemas } from '../api/client';
import { tsigKeysKey, useClusters, useTSIGKeys } from '../clusters';
import {
Badge,
ConfirmDialog,
CopyButton,
ErrorBox,
Field,
Modal,
Notice,
Spinner,
} from '../components/ui';
import { fieldErrors } from '../errors';
import { formatDateTime } from '../format';
import { pendingKey } from '../pending';
type TSIGKey = Schemas['TSIGKey'];
type TSIGAlgorithm = Schemas['TSIGAlgorithm'];
const algorithms: TSIGAlgorithm[] = ['hmac-sha256', 'hmac-sha384', 'hmac-sha512'];
/** Create (no key) or edit dialog; name and algorithm are fixed after creation. */
function KeyForm({ tsigKey, onClose }: { tsigKey?: TSIGKey; onClose: () => void }) {
const { t } = useTranslation();
const qc = useQueryClient();
const [name, setName] = useState(tsigKey?.name ?? '');
const [algorithm, setAlgorithm] = useState<TSIGAlgorithm>(tsigKey?.algorithm ?? 'hmac-sha256');
const [description, setDescription] = useState(tsigKey?.description ?? '');
const save = useMutation({
mutationFn: () =>
tsigKey
? unwrap(
api.PUT('/tsig-keys/{id}', {
params: { path: { id: tsigKey.id } },
body: { description, version: tsigKey.version },
}),
)
: unwrap(api.POST('/tsig-keys', { body: { name: name.trim(), algorithm, description } })),
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: tsigKeysKey });
void qc.invalidateQueries({ queryKey: pendingKey });
onClose();
},
});
const fe = fieldErrors(t, save.error);
const submit = (e: SyntheticEvent) => {
e.preventDefault();
save.mutate();
};
return (
<Modal
title={tsigKey ? t('tsig.editTitle', { name: tsigKey.name }) : t('tsig.new')}
onClose={onClose}
footer={
<>
<button type="button" onClick={onClose}>
{t('common.cancel')}
</button>
<button type="submit" form="tsig-form" className="primary" disabled={save.isPending}>
{tsigKey ? t('common.save') : t('common.create')}
</button>
</>
}
>
<form id="tsig-form" className="form" onSubmit={submit}>
<Field
label={t('tsig.name')}
error={fe.name}
hint={tsigKey ? t('tsig.nameImmutable') : t('tsig.nameHint')}
>
{(id) => (
<input
id={id}
className="mono"
value={name}
placeholder="dc1.example.com"
disabled={!!tsigKey}
required
onChange={(e) => {
setName(e.target.value);
}}
/>
)}
</Field>
<Field label={t('tsig.algorithm')} error={fe.algorithm}>
{(id) => (
<select
id={id}
value={algorithm}
disabled={!!tsigKey}
onChange={(e) => {
setAlgorithm(e.target.value as TSIGAlgorithm);
}}
>
{algorithms.map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
)}
</Field>
<Field label={t('tsig.description')} error={fe.description}>
{(id) => (
<input
id={id}
value={description}
onChange={(e) => {
setDescription(e.target.value);
}}
/>
)}
</Field>
{!tsigKey && <p className="field-hint">{t('tsig.createHint')}</p>}
<ErrorBox error={save.error} fields={['name', 'algorithm', 'description']} />
</form>
</Modal>
);
}
/**
* Shows a TSIG secret after an explicit confirmation. The server audits
* every retrieval; the secret is not cached and is gone once the dialog
* closes.
*/
export function SecretDialog({
keyId,
name,
onClose,
}: {
keyId: string;
name: string;
onClose: () => void;
}) {
const { t } = useTranslation();
const reveal = useMutation({
mutationFn: () =>
unwrap(api.GET('/tsig-keys/{id}/secret', { params: { path: { id: keyId } } })),
});
const s = reveal.data;
const statement = s
? `key "${s.name}" {\n algorithm ${s.algorithm};\n secret "${s.secret}";\n};`
: '';
return (
<Modal
title={t('tsig.secretTitle', { name })}
onClose={onClose}
wide
footer={
s ? (
<button type="button" className="primary" onClick={onClose}>
{t('common.close')}
</button>
) : (
<>
<button type="button" onClick={onClose}>
{t('common.cancel')}
</button>
<button
type="button"
className="danger"
disabled={reveal.isPending}
onClick={() => {
reveal.mutate();
}}
>
{t('tsig.reveal')}
</button>
</>
)
}
>
{s ? (
<>
<dl className="kv">
<dt>{t('tsig.name')}</dt>
<dd className="mono">{s.name}</dd>
<dt>{t('tsig.algorithm')}</dt>
<dd className="mono">{s.algorithm}</dd>
<dt>{t('tsig.secret')}</dt>
<dd>
<code className="mono secret-value" data-testid="tsig-secret">
{s.secret}
</code>{' '}
<CopyButton text={s.secret} />
</dd>
</dl>
<p className="field-hint">{t('tsig.statementHint')}</p>
<pre className="file-content">{statement}</pre>
<CopyButton text={statement} />
<Notice kind="warning">{t('tsig.secretOnce')}</Notice>
</>
) : (
<>
<p>{t('tsig.secretConfirm', { name })}</p>
<Notice kind="warning">{t('tsig.secretAudited')}</Notice>
<ErrorBox error={reveal.error} />
</>
)}
</Modal>
);
}
export function TSIGKeysPage() {
const { t } = useTranslation();
const qc = useQueryClient();
const q = useTSIGKeys();
const clusters = useClusters();
const [editing, setEditing] = useState<TSIGKey | 'new' | null>(null);
const [secretFor, setSecretFor] = useState<TSIGKey | null>(null);
const [confirm, setConfirm] = useState<{ kind: 'rotate' | 'delete'; key: TSIGKey } | null>(null);
const [rotated, setRotated] = useState('');
const act = useMutation({
mutationFn: async ({ kind, key }: { kind: 'rotate' | 'delete'; key: TSIGKey }) => {
if (kind === 'rotate') {
await unwrap(api.POST('/tsig-keys/{id}/rotate', { params: { path: { id: key.id } } }));
} else {
await unwrap(api.DELETE('/tsig-keys/{id}', { params: { path: { id: key.id } } }));
}
},
onSuccess: async (_, { kind, key }) => {
setConfirm(null);
setRotated(kind === 'rotate' ? key.name : '');
await qc.invalidateQueries({ queryKey: tsigKeysKey });
void qc.invalidateQueries({ queryKey: pendingKey });
},
});
const clusterName = (id: string) => clusters.data?.items.find((c) => c.id === id)?.name ?? id;
return (
<div className="page">
<div className="page-head">
<h1>{t('tsig.title')}</h1>
<button
type="button"
className="primary"
onClick={() => {
setEditing('new');
}}
>
{t('tsig.new')}
</button>
</div>
<p className="field-hint">{t('tsig.hint')}</p>
{rotated && <Notice kind="success">{t('tsig.rotated', { name: rotated })}</Notice>}
{q.isPending && <Spinner />}
<ErrorBox error={q.error} />
{q.data?.items.length === 0 && <p>{t('tsig.empty')}</p>}
{q.data && q.data.items.length > 0 && (
<table className="table">
<thead>
<tr>
<th>{t('tsig.name')}</th>
<th>{t('tsig.algorithm')}</th>
<th>{t('tsig.description')}</th>
<th>{t('tsig.usage')}</th>
<th>{t('tsig.rotatedAt')}</th>
<th>{t('common.actions')}</th>
</tr>
</thead>
<tbody>
{q.data.items.map((k) => (
<tr key={k.id}>
<td className="mono">{k.name}</td>
<td className="mono">{k.algorithm}</td>
<td>{k.description}</td>
<td>
{k.cluster_id && (
<div>
<Badge>{t('tsig.transferKey')}</Badge>{' '}
{t('tsig.ofCluster', { name: clusterName(k.cluster_id) })}
</div>
)}
{k.zone_count > 0 && <div>{t('tsig.zones', { count: k.zone_count })}</div>}
{!k.cluster_id && k.zone_count === 0 && (
<span className="muted">{t('tsig.unused')}</span>
)}
</td>
<td>{formatDateTime(k.rotated_at)}</td>
<td className="row-actions">
<button
type="button"
className="link"
onClick={() => {
setSecretFor(k);
}}
>
{t('tsig.showSecret')}
</button>
<button
type="button"
className="link"
onClick={() => {
act.reset();
setConfirm({ kind: 'rotate', key: k });
}}
>
{t('tsig.rotate')}
</button>
<button
type="button"
className="link"
onClick={() => {
setEditing(k);
}}
>
{t('common.edit')}
</button>
{!k.cluster_id && (
<button
type="button"
className="link danger"
onClick={() => {
act.reset();
setConfirm({ kind: 'delete', key: k });
}}
>
{t('common.delete')}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
{editing && (
<KeyForm
{...(editing === 'new' ? {} : { tsigKey: editing })}
onClose={() => {
setEditing(null);
}}
/>
)}
{secretFor && (
<SecretDialog
keyId={secretFor.id}
name={secretFor.name}
onClose={() => {
setSecretFor(null);
}}
/>
)}
{confirm && (
<ConfirmDialog
title={confirm.kind === 'rotate' ? t('tsig.rotate') : t('common.delete')}
message={t(confirm.kind === 'rotate' ? 'tsig.rotateConfirm' : 'tsig.deleteConfirm', {
name: confirm.key.name,
})}
confirmLabel={confirm.kind === 'rotate' ? t('tsig.rotate') : t('common.delete')}
danger
busy={act.isPending}
error={act.error}
onCancel={() => {
setConfirm(null);
act.reset();
}}
onConfirm={() => {
act.mutate(confirm);
}}
/>
)}
</div>
);
}
+339
View File
@@ -0,0 +1,339 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useState, type SyntheticEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { api, ApiError, unwrap, type Schemas } from '../api/client';
import { clustersKey, tsigKeysKey, useTSIGKeys, zonesKey } from '../clusters';
import { ClusterSelect } from '../components/ClusterSelect';
import { ConfirmDialog, ErrorBox, Field, Modal, Notice } from '../components/ui';
import { fieldErrors } from '../errors';
import { splitList } from '../format';
import { pendingKey } from '../pending';
export type Zone = Schemas['Zone'];
type ZoneRequest = Schemas['ZoneRequest'];
type ZoneKind = Schemas['ZoneKind'];
type ZoneMode = Schemas['ZoneMode'];
const modes: ZoneMode[] = ['managed', 'dynamic'];
/** Help for Active Directory on dynamic zones (ADR 0023, docs/ACTIVE-DIRECTORY.md). */
export function ActiveDirectoryHelp() {
const { t } = useTranslation();
return (
<Notice>
<strong>{t('zones.adTitle')}</strong>
<p>{t('zones.adHelp')}</p>
</Notice>
);
}
/** Values to prefill a new zone with (taking over a derived zone). */
export interface ZoneDraft {
name: string;
kind: ZoneKind;
}
/** Create (no zone) or edit dialog for an explicit zone. */
export function ZoneForm({
zone,
draft,
onClose,
onSaved,
}: {
zone?: Zone;
draft?: ZoneDraft;
onClose: () => void;
onSaved?: (z: Zone) => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const keys = useTSIGKeys();
const [name, setName] = useState(zone?.name ?? draft?.name ?? '');
const [kind, setKind] = useState<ZoneKind | ''>(zone?.kind ?? draft?.kind ?? '');
const [mode, setMode] = useState<ZoneMode>(zone?.mode ?? 'managed');
const [clusterId, setClusterId] = useState<string | null>(zone?.cluster_id ?? null);
const [ttl, setTtl] = useState(zone?.default_ttl ? String(zone.default_ttl) : '');
const [allowUpdate, setAllowUpdate] = useState(zone?.allow_update.join(', ') ?? '');
const [keyIds, setKeyIds] = useState<string[]>(zone?.update_key_ids ?? []);
const [description, setDescription] = useState(zone?.description ?? '');
const save = useMutation({
mutationFn: () => {
const dynamic = mode === 'dynamic';
const body: ZoneRequest = {
mode,
cluster_id: clusterId,
default_ttl: ttl ? Number(ttl) : null,
allow_update: dynamic ? splitList(allowUpdate) : [],
update_key_ids: dynamic ? keyIds : [],
description,
};
if (zone) {
return unwrap(
api.PUT('/zones/{id}', {
params: { path: { id: zone.id } },
body: { ...body, version: zone.version },
}),
);
}
return unwrap(
api.POST('/zones', {
body: { ...body, name: name.trim(), ...(kind ? { kind } : {}) },
}),
);
},
onSuccess: async (z) => {
await qc.invalidateQueries({ queryKey: zonesKey });
void qc.invalidateQueries({ queryKey: clustersKey });
void qc.invalidateQueries({ queryKey: tsigKeysKey });
void qc.invalidateQueries({ queryKey: pendingKey });
if (onSaved) onSaved(z);
else onClose();
},
});
const fe = fieldErrors(t, save.error);
const submit = (e: SyntheticEvent) => {
e.preventDefault();
save.mutate();
};
// Transfer keys belong to their cluster; offer them only if already chosen.
const keyOptions = (keys.data?.items ?? []).filter((k) => !k.cluster_id || keyIds.includes(k.id));
return (
<Modal
title={zone ? t('zones.editTitle', { name: zone.name }) : t('zones.new')}
onClose={onClose}
wide
footer={
<>
<button type="button" onClick={onClose}>
{t('common.cancel')}
</button>
<button type="submit" form="zone-form" className="primary" disabled={save.isPending}>
{zone ? t('common.save') : t('common.create')}
</button>
</>
}
>
<form id="zone-form" className="form" onSubmit={submit}>
{draft && !zone && <Notice>{t('zones.takeOverHint')}</Notice>}
<div className="grid-2">
<Field
label={t('zones.name')}
error={fe.name}
hint={zone ? t('zones.nameImmutable') : t('zones.nameHint')}
>
{(id) => (
<input
id={id}
className="mono"
value={name}
placeholder="example.com"
disabled={!!zone}
required
onChange={(e) => {
setName(e.target.value);
}}
/>
)}
</Field>
<Field label={t('zones.kind')} error={fe.kind}>
{(id) => (
<select
id={id}
value={kind}
disabled={!!zone}
onChange={(e) => {
setKind(e.target.value as ZoneKind | '');
}}
>
{!zone && <option value="">{t('zones.kindAuto')}</option>}
<option value="forward">{t('zones.kinds.forward')}</option>
<option value="reverse">{t('zones.kinds.reverse')}</option>
</select>
)}
</Field>
<Field label={t('zones.mode')} error={fe.mode} hint={t(`zones.modeHelp.${mode}`)}>
{(id) => (
<select
id={id}
value={mode}
onChange={(e) => {
setMode(e.target.value as ZoneMode);
}}
>
{modes.map((m) => (
<option key={m} value={m}>
{t(`zones.modes.${m}`)}
</option>
))}
</select>
)}
</Field>
<Field label={t('zones.cluster')} error={fe.cluster_id} hint={t('zones.clusterHint')}>
{(id) => (
<ClusterSelect
id={id}
role="dns"
value={clusterId}
emptyLabel={t('clusters.defaultCluster')}
onChange={setClusterId}
/>
)}
</Field>
<Field label={t('zones.defaultTTL')} error={fe.default_ttl} hint={t('zones.ttlHint')}>
{(id) => (
<input
id={id}
type="number"
min={1}
value={ttl}
onChange={(e) => {
setTtl(e.target.value);
}}
/>
)}
</Field>
<Field label={t('zones.description')} error={fe.description}>
{(id) => (
<input
id={id}
value={description}
onChange={(e) => {
setDescription(e.target.value);
}}
/>
)}
</Field>
</div>
{mode === 'dynamic' && (
<fieldset>
<legend>{t('zones.updates')}</legend>
<Field
label={t('zones.allowUpdate')}
error={fe.allow_update}
hint={t('zones.allowUpdateHint')}
>
{(id) => (
<input
id={id}
className="mono"
value={allowUpdate}
placeholder="192.0.2.10/32, 192.0.2.11/32"
onChange={(e) => {
setAllowUpdate(e.target.value);
}}
/>
)}
</Field>
<div className={`field${fe.update_key_ids ? ' field-invalid' : ''}`}>
<span>{t('zones.updateKeys')}</span>
{keyOptions.length === 0 ? (
<span className="field-hint">{t('zones.noKeys')}</span>
) : (
<div className="check-list">
{keyOptions.map((k) => (
<label key={k.id} className="checkbox">
<input
type="checkbox"
checked={keyIds.includes(k.id)}
onChange={(e) => {
setKeyIds(
e.target.checked ? [...keyIds, k.id] : keyIds.filter((x) => x !== k.id),
);
}}
/>
<span className="mono">{k.name}</span>
</label>
))}
</div>
)}
{fe.update_key_ids && (
<div className="field-error" role="alert">
{fe.update_key_ids}
</div>
)}
</div>
<ActiveDirectoryHelp />
</fieldset>
)}
<ErrorBox
error={save.error}
fields={[
'name',
'kind',
'mode',
'cluster_id',
'default_ttl',
'allow_update',
'update_key_ids',
'description',
]}
/>
</form>
</Modal>
);
}
/**
* Deletes a zone; with records it asks again and deletes them too
* (force). zone_not_empty from the server switches to that question.
*/
export function ZoneDeleteDialog({
zone,
onCancel,
onDeleted,
}: {
zone: Zone;
onCancel: () => void;
onDeleted: () => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const [force, setForce] = useState(zone.record_count > 0);
const del = useMutation({
mutationFn: (f: boolean) =>
unwrap(
api.DELETE('/zones/{id}', {
params: { path: { id: zone.id }, query: f ? { force: true } : {} },
}),
),
onSuccess: async () => {
// The deleted zone and its records are gone: do not refetch them.
await qc.invalidateQueries({
queryKey: zonesKey,
predicate: (query) => query.queryKey[1] !== zone.id,
});
void qc.invalidateQueries({ queryKey: clustersKey });
void qc.invalidateQueries({ queryKey: tsigKeysKey });
void qc.invalidateQueries({ queryKey: pendingKey });
onDeleted();
},
onError: (err) => {
if (err instanceof ApiError && err.code === 'zone_not_empty') setForce(true);
},
});
const notEmptyError =
del.error instanceof ApiError && del.error.code === 'zone_not_empty' ? del.error : null;
const notEmpty = notEmptyError !== null;
const count = Number(notEmptyError?.problem.params?.count ?? zone.record_count);
return (
<ConfirmDialog
title={t('common.delete')}
message={
force
? t('zones.deleteForce', { name: zone.name, count })
: t('zones.deleteConfirm', { name: zone.name })
}
danger
busy={del.isPending}
error={notEmpty ? undefined : del.error}
onCancel={onCancel}
onConfirm={() => {
del.mutate(force);
}}
/>
);
}
+453
View File
@@ -0,0 +1,453 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate, useParams } from 'react-router';
import { api, unwrap } from '../api/client';
import { useSession } from '../auth/context';
import { useClusters, useTSIGKeys, zonesKey } from '../clusters';
import { Badge, ConfirmDialog, ErrorBox, Notice, Spinner } from '../components/ui';
import { pendingKey } from '../pending';
import { ownerName, recordData, type DNSRecord } from '../records';
import { RecordForm } from './RecordForm';
import {
ActiveDirectoryHelp,
ZoneDeleteDialog,
ZoneForm,
type Zone,
type ZoneDraft,
} from './ZoneForm';
function useClusterName() {
const { t } = useTranslation();
const clusters = useClusters();
return (id: string | null) =>
id === null
? t('clusters.defaultCluster')
: (clusters.data?.items.find((c) => c.id === id)?.name ?? id);
}
function ZoneBadges({ zone }: { zone: Pick<Zone, 'kind'> & Partial<Pick<Zone, 'mode'>> }) {
const { t } = useTranslation();
return (
<>
<Badge>{t(`zones.kinds.${zone.kind}`)}</Badge>
{zone.mode && (
<>
{' '}
<Badge kind={zone.mode === 'dynamic' ? 'warning' : 'neutral'}>
{t(`zones.modes.${zone.mode}`)}
</Badge>
</>
)}
</>
);
}
/** Zones derived from networks that no explicit zone covers. */
function DerivedZones({ onTakeOver }: { onTakeOver?: (d: ZoneDraft) => void }) {
const { t } = useTranslation();
const q = useQuery({
queryKey: [...zonesKey, 'derived'],
queryFn: () => unwrap(api.GET('/zones/derived')),
});
const networks = useQuery({
queryKey: ['networks'],
queryFn: () => unwrap(api.GET('/networks')),
});
const cidr = (id: string) => networks.data?.items.find((n) => n.id === id)?.cidr ?? id;
return (
<section className="card">
<h2>{t('zones.derivedTitle')}</h2>
<p className="field-hint">{t('zones.derivedHint')}</p>
{q.isPending && <Spinner />}
<ErrorBox error={q.error} />
{q.data?.items.length === 0 && <p>{t('zones.derivedEmpty')}</p>}
{q.data && q.data.items.length > 0 && (
<table className="table">
<thead>
<tr>
<th>{t('zones.name')}</th>
<th>{t('zones.kind')}</th>
<th>{t('zones.networks')}</th>
{onTakeOver && <th>{t('common.actions')}</th>}
</tr>
</thead>
<tbody>
{q.data.items.map((d) => (
<tr key={d.name}>
<td className="mono">{d.name}</td>
<td>
<ZoneBadges zone={d} />
</td>
<td className="mono">{d.network_ids.map(cidr).join(', ')}</td>
{onTakeOver && (
<td className="row-actions">
<button
type="button"
className="link"
aria-label={t('zones.takeOverName', { name: d.name })}
onClick={() => {
onTakeOver({ name: d.name, kind: d.kind });
}}
>
{t('zones.takeOver')}
</button>
</td>
)}
</tr>
))}
</tbody>
</table>
)}
</section>
);
}
export function ZonesPage() {
const { t } = useTranslation();
const { can } = useSession();
const navigate = useNavigate();
const writable = can('ipam.write');
const clusterName = useClusterName();
const [form, setForm] = useState<{ zone?: Zone; draft?: ZoneDraft } | null>(null);
const [deleting, setDeleting] = useState<Zone | null>(null);
const q = useQuery({ queryKey: zonesKey, queryFn: () => unwrap(api.GET('/zones')) });
return (
<div className="page">
<div className="page-head">
<h1>{t('zones.title')}</h1>
{writable && (
<button
type="button"
className="primary"
onClick={() => {
setForm({});
}}
>
{t('zones.new')}
</button>
)}
</div>
<p className="field-hint">{t('zones.hint')}</p>
{q.isPending && <Spinner />}
<ErrorBox error={q.error} />
{q.data?.items.length === 0 && <p>{t('zones.empty')}</p>}
{q.data && q.data.items.length > 0 && (
<table className="table">
<thead>
<tr>
<th>{t('zones.name')}</th>
<th>{t('zones.kind')}</th>
<th>{t('zones.cluster')}</th>
<th>{t('zones.defaultTTL')}</th>
<th>{t('zones.records')}</th>
{writable && <th>{t('common.actions')}</th>}
</tr>
</thead>
<tbody>
{q.data.items.map((z) => (
<tr key={z.id}>
<td>
<Link to={`/zones/${z.id}`} className="mono">
{z.name}
</Link>
{z.description && <div className="muted">{z.description}</div>}
</td>
<td>
<ZoneBadges zone={z} />
</td>
<td>{clusterName(z.cluster_id)}</td>
<td>{z.default_ttl ?? <span className="muted">{t('zones.ttlDefault')}</span>}</td>
<td>{z.record_count}</td>
{writable && (
<td className="row-actions">
<button
type="button"
className="link"
onClick={() => {
setForm({ zone: z });
}}
>
{t('common.edit')}
</button>
<button
type="button"
className="link danger"
onClick={() => {
setDeleting(z);
}}
>
{t('common.delete')}
</button>
</td>
)}
</tr>
))}
</tbody>
</table>
)}
<DerivedZones
{...(writable
? {
onTakeOver: (d: ZoneDraft) => {
setForm({ draft: d });
},
}
: {})}
/>
{form && (
<ZoneForm
{...(form.zone ? { zone: form.zone } : {})}
{...(form.draft ? { draft: form.draft } : {})}
onClose={() => {
setForm(null);
}}
onSaved={(z) => {
setForm(null);
if (!form.zone) void navigate(`/zones/${z.id}`);
}}
/>
)}
{deleting && (
<ZoneDeleteDialog
zone={deleting}
onCancel={() => {
setDeleting(null);
}}
onDeleted={() => {
setDeleting(null);
}}
/>
)}
</div>
);
}
export function ZoneDetailPage() {
const { t } = useTranslation();
const { id = '' } = useParams();
const { can } = useSession();
const navigate = useNavigate();
const qc = useQueryClient();
const writable = can('ipam.write');
const clusterName = useClusterName();
const [editing, setEditing] = useState(false);
const [deleting, setDeleting] = useState(false);
const [recordForm, setRecordForm] = useState<DNSRecord | 'new' | null>(null);
const [deletingRecord, setDeletingRecord] = useState<DNSRecord | null>(null);
const q = useQuery({
queryKey: [...zonesKey, id],
queryFn: () => unwrap(api.GET('/zones/{id}', { params: { path: { id } } })),
});
const records = useQuery({
queryKey: [...zonesKey, id, 'records'],
queryFn: () => unwrap(api.GET('/zones/{id}/records', { params: { path: { id } } })),
});
const dynamic = q.data?.mode === 'dynamic';
const keys = useTSIGKeys(dynamic && (q.data?.update_key_ids.length ?? 0) > 0);
const delRecord = useMutation({
mutationFn: (r: DNSRecord) =>
unwrap(
api.DELETE('/zones/{id}/records/{recordId}', {
params: { path: { id, recordId: r.id } },
}),
),
onSuccess: async () => {
setDeletingRecord(null);
await qc.invalidateQueries({ queryKey: zonesKey });
void qc.invalidateQueries({ queryKey: pendingKey });
},
});
if (q.isPending) return <Spinner />;
if (q.error) return <ErrorBox error={q.error} />;
const z = q.data;
const keyName = (kid: string) => keys.data?.items.find((k) => k.id === kid)?.name ?? kid;
return (
<div className="page">
<div className="page-head">
<h1>
<span className="mono">{z.name}</span> <ZoneBadges zone={z} />
</h1>
{writable && (
<div className="actions">
<button
type="button"
onClick={() => {
setEditing(true);
}}
>
{t('common.edit')}
</button>
<button
type="button"
className="danger"
onClick={() => {
setDeleting(true);
}}
>
{t('common.delete')}
</button>
</div>
)}
</div>
<Link to="/zones">{t('zones.back')}</Link>
<section className="card">
<dl className="kv">
<dt>{t('zones.cluster')}</dt>
<dd>{clusterName(z.cluster_id)}</dd>
<dt>{t('zones.defaultTTL')}</dt>
<dd>{z.default_ttl ?? t('zones.ttlDefault')}</dd>
{dynamic && (
<>
<dt>{t('zones.allowUpdate')}</dt>
<dd className="mono">{z.allow_update.join(', ') || t('common.none')}</dd>
<dt>{t('zones.updateKeys')}</dt>
<dd className="mono">
{z.update_key_ids.map(keyName).join(', ') || t('common.none')}
</dd>
</>
)}
{z.description && (
<>
<dt>{t('zones.description')}</dt>
<dd>{z.description}</dd>
</>
)}
</dl>
</section>
{dynamic && <ActiveDirectoryHelp />}
<section className="card">
<div className="page-head">
<h2>{t('records.title')}</h2>
{writable && (
<button
type="button"
className="primary"
onClick={() => {
setRecordForm('new');
}}
>
{t('records.new')}
</button>
)}
</div>
<Notice>{t('records.autoHint')}</Notice>
{dynamic && <Notice kind="warning">{t('records.dynamicHint')}</Notice>}
{records.isPending && <Spinner />}
<ErrorBox error={records.error} />
{records.data?.items.length === 0 && <p>{t('records.empty')}</p>}
{records.data && records.data.items.length > 0 && (
<table className="table">
<thead>
<tr>
<th>{t('records.name')}</th>
<th>{t('records.type')}</th>
<th>{t('records.ttl')}</th>
<th>{t('records.data')}</th>
<th>{t('records.description')}</th>
{writable && <th>{t('common.actions')}</th>}
</tr>
</thead>
<tbody>
{records.data.items.map((r) => (
<tr key={r.id}>
<td className="mono" title={ownerName(r.name, z.name)}>
{r.name}
</td>
<td>
<Badge>{r.type}</Badge>
</td>
<td>{r.ttl ?? <span className="muted">{t('records.ttlZone')}</span>}</td>
<td className="mono">{recordData(r)}</td>
<td>{r.description}</td>
{writable && (
<td className="row-actions">
<button
type="button"
className="link"
onClick={() => {
setRecordForm(r);
}}
>
{t('common.edit')}
</button>
<button
type="button"
className="link danger"
onClick={() => {
delRecord.reset();
setDeletingRecord(r);
}}
>
{t('common.delete')}
</button>
</td>
)}
</tr>
))}
</tbody>
</table>
)}
</section>
{editing && (
<ZoneForm
zone={z}
onClose={() => {
setEditing(false);
}}
/>
)}
{deleting && (
<ZoneDeleteDialog
zone={z}
onCancel={() => {
setDeleting(false);
}}
onDeleted={() => {
setDeleting(false);
void navigate('/zones');
}}
/>
)}
{recordForm && (
<RecordForm
zone={z}
{...(recordForm === 'new' ? {} : { record: recordForm })}
onClose={() => {
setRecordForm(null);
}}
/>
)}
{deletingRecord && (
<ConfirmDialog
title={t('common.delete')}
message={t('records.deleteConfirm', {
name: ownerName(deletingRecord.name, z.name),
type: deletingRecord.type,
})}
danger
busy={delRecord.isPending}
error={delRecord.error}
onCancel={() => {
setDeletingRecord(null);
delRecord.reset();
}}
onConfirm={() => {
delRecord.mutate(deletingRecord);
}}
/>
)}
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
import type { Schemas } from './api/client';
export type DNSRecord = Schemas['Record'];
export type RecordType = Schemas['RecordType'];
export type DataField =
'address' | 'target' | 'preference' | 'priority' | 'weight' | 'port' | 'text';
export type NumberField = 'preference' | 'priority' | 'weight' | 'port';
export const recordTypes: RecordType[] = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'SRV', 'NS', 'PTR'];
/** The data fields of each record type; all others stay empty. */
export const fieldsOf: Record<RecordType, DataField[]> = {
A: ['address'],
AAAA: ['address'],
CNAME: ['target'],
NS: ['target'],
PTR: ['target'],
MX: ['preference', 'target'],
SRV: ['priority', 'weight', 'port', 'target'],
TXT: ['text'],
};
export const numberFields: NumberField[] = ['preference', 'priority', 'weight', 'port'];
/** Owner as FQDN: empty or "@" is the zone itself. */
export function ownerName(name: string, zone: string): string {
const n = name.trim();
return n === '' || n === '@' ? zone : `${n}.${zone}`;
}
/** Record data in zone file order, e.g. "10 mail.example.com" for MX. */
export function recordData(r: DNSRecord): string {
switch (r.type) {
case 'A':
case 'AAAA':
return r.address;
case 'MX':
return `${String(r.preference)} ${r.target}`;
case 'SRV':
return `${String(r.priority)} ${String(r.weight)} ${String(r.port)} ${r.target}`;
case 'TXT':
return r.text.map((s) => `"${s}"`).join(' ');
default:
return r.target;
}
}
+6
View File
@@ -8,6 +8,12 @@ import { routes } from './App';
import { createQueryClient } from './queryClient';
import { SessionProvider } from './auth/session';
/**
* Pages are lazy-loaded (App.tsx); importing them up front keeps the first
* render of a page in a test from waiting for module transformation.
*/
export const preloadedPages = import.meta.glob('./pages/*.tsx', { eager: true });
export interface Call {
method: string;
path: string;