Files
DDI/web/src/components/ui.tsx
T
sir_melonchenandClaude Opus 5.5 15bdb03c14 feat(web): clusters, TSIG keys, zones and records (M5)
- 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>
2026-10-14 14:51:26 +02:00

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>
);
}