Merge branch 'worktree-agent-adf19957537754d9f'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+12
-2
@@ -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
@@ -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 /> },
|
||||
],
|
||||
|
||||
+954
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 } : {})}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user