- Clusters page: DNS/DHCP clusters with members, transfer key, DNS
options and Kea HA settings; member picking limited to free agents of
the role, client-side checks of the membership rules.
- TSIG keys page: create, edit, rotate, delete; secrets only after an
explicit, audited retrieval and shown once.
- Zones page with derived zones ("take over") and zone detail with
type-specific record forms and hints for dynamic zones and AD.
- Networks, agents and generator settings show/assign clusters; files,
diffs and pending changes are grouped by target, secret files are
never shown.
- Pages are lazy-loaded per route (main bundle 600 KB -> 480 KB).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
217 lines
5.4 KiB
TypeScript
217 lines
5.4 KiB
TypeScript
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
|
|
import { useEffect, useId, useRef, useState, type ReactNode } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import { ApiError } from '../api/client';
|
|
import { errorText, problemText } from '../errors';
|
|
|
|
/** Shows an error and any problems not bound to a known field. */
|
|
export function ErrorBox({ error, fields = [] }: { error: unknown; fields?: string[] }) {
|
|
const { t } = useTranslation();
|
|
if (!error) return null;
|
|
const extra =
|
|
error instanceof ApiError
|
|
? (error.problem.problems ?? []).filter((p) => !p.field || !fields.includes(p.field))
|
|
: [];
|
|
return (
|
|
<div className="alert alert-error" role="alert">
|
|
<div>{errorText(t, error)}</div>
|
|
{extra.length > 0 && (
|
|
<ul>
|
|
{extra.map((p, i) => (
|
|
<li key={i}>
|
|
{p.field ? `${p.field}: ` : ''}
|
|
{problemText(t, p)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function Notice({
|
|
children,
|
|
kind = 'info',
|
|
}: {
|
|
children: ReactNode;
|
|
kind?: 'info' | 'success' | 'warning';
|
|
}) {
|
|
return (
|
|
<div className={`alert alert-${kind}`} role="status">
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface FieldProps {
|
|
label: string;
|
|
error?: string | undefined;
|
|
hint?: string | undefined;
|
|
children: (id: string) => ReactNode;
|
|
}
|
|
|
|
/** A labelled form field; children receive the input id. */
|
|
export function Field({ label, error, hint, children }: FieldProps) {
|
|
const id = useId();
|
|
return (
|
|
<div className={`field${error ? ' field-invalid' : ''}`}>
|
|
<label htmlFor={id}>{label}</label>
|
|
{children(id)}
|
|
{hint && !error && <div className="field-hint">{hint}</div>}
|
|
{error && (
|
|
<div className="field-error" role="alert">
|
|
{error}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface ModalProps {
|
|
title: string;
|
|
onClose: () => void;
|
|
children: ReactNode;
|
|
footer?: ReactNode;
|
|
wide?: boolean;
|
|
}
|
|
|
|
/** A modal dialog; Escape and the backdrop close it. */
|
|
export function Modal({ title, onClose, children, footer, wide }: ModalProps) {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const titleId = useId();
|
|
useEffect(() => {
|
|
const prev = document.activeElement as HTMLElement | null;
|
|
ref.current?.querySelector<HTMLElement>('input, select, textarea, button')?.focus();
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') onClose();
|
|
};
|
|
document.addEventListener('keydown', onKey);
|
|
return () => {
|
|
document.removeEventListener('keydown', onKey);
|
|
prev?.focus();
|
|
};
|
|
}, [onClose]);
|
|
return (
|
|
<div
|
|
className="modal-backdrop"
|
|
onMouseDown={(e) => {
|
|
if (e.target === e.currentTarget) onClose();
|
|
}}
|
|
>
|
|
<div
|
|
ref={ref}
|
|
className={`modal${wide ? ' modal-wide' : ''}`}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby={titleId}
|
|
>
|
|
<h2 id={titleId}>{title}</h2>
|
|
<div className="modal-body">{children}</div>
|
|
{footer && <div className="modal-footer">{footer}</div>}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface ConfirmProps {
|
|
title: string;
|
|
message: string;
|
|
confirmLabel?: string;
|
|
danger?: boolean;
|
|
busy?: boolean;
|
|
error?: unknown;
|
|
onConfirm: () => void;
|
|
onCancel: () => void;
|
|
}
|
|
|
|
export function ConfirmDialog({
|
|
title,
|
|
message,
|
|
confirmLabel,
|
|
danger,
|
|
busy,
|
|
error,
|
|
onConfirm,
|
|
onCancel,
|
|
}: ConfirmProps) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<Modal
|
|
title={title}
|
|
onClose={onCancel}
|
|
footer={
|
|
<>
|
|
<button type="button" onClick={onCancel}>
|
|
{t('common.cancel')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={danger ? 'danger' : 'primary'}
|
|
disabled={busy}
|
|
onClick={onConfirm}
|
|
>
|
|
{confirmLabel ?? t('common.confirm')}
|
|
</button>
|
|
</>
|
|
}
|
|
>
|
|
<p>{message}</p>
|
|
<ErrorBox error={error} />
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
export function Badge({ children, kind = 'neutral' }: { children: ReactNode; kind?: string }) {
|
|
return <span className={`badge badge-${kind}`}>{children}</span>;
|
|
}
|
|
|
|
/** A horizontal bar showing used/total. */
|
|
export function UsageBar({ used, total, label }: { used: number; total: number; label: string }) {
|
|
const pct = total > 0 ? Math.min(100, Math.round((used / total) * 100)) : 0;
|
|
const level = pct >= 90 ? 'high' : pct >= 70 ? 'mid' : 'low';
|
|
return (
|
|
<div className="usage" title={label}>
|
|
<div
|
|
className={`usage-bar usage-${level}`}
|
|
role="progressbar"
|
|
aria-valuenow={pct}
|
|
aria-valuemin={0}
|
|
aria-valuemax={100}
|
|
aria-label={label}
|
|
>
|
|
<div style={{ width: `${String(pct)}%` }} />
|
|
</div>
|
|
<span className="usage-text">{label}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** 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 (
|
|
<div className="spinner" role="status">
|
|
{t('app.loading')}
|
|
</div>
|
|
);
|
|
}
|