Merge branch 'worktree-agent-ace13af6aa36eb177'

This commit is contained in:
beug.k@beugsiud.de
2026-10-13 20:13:18 +02:00
14 changed files with 1696 additions and 69 deletions
+118
View File
@@ -679,3 +679,121 @@ pre.message {
word-break: break-word;
font-size: 0.8rem;
}
/* ---- Diffs, pending changes, live status ----------------------------- */
.file-diff {
border-top: 1px solid var(--border);
padding: 6px 0;
}
.file-diff-head {
display: flex;
flex-wrap: wrap;
gap: 4px 8px;
align-items: center;
}
button.file-diff-toggle {
color: var(--text);
padding: 0;
text-align: left;
}
.file-diff-counts {
font-size: 0.85rem;
}
.diff-count-add {
color: var(--ok);
}
.diff-count-del {
color: var(--fail);
}
.file-diff-body {
max-height: 520px;
overflow: auto;
margin-top: 6px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
}
.diff-table {
border-collapse: collapse;
width: 100%;
font-size: 0.8rem;
line-height: 1.4;
}
.diff-table td {
padding: 0 6px;
vertical-align: top;
}
.diff-table .diff-no {
width: 1%;
text-align: right;
color: var(--text-muted);
user-select: none;
white-space: nowrap;
}
.diff-table .diff-sign {
width: 1%;
user-select: none;
font-weight: 700;
}
.diff-table .diff-text {
white-space: pre-wrap;
word-break: break-all;
}
.diff-add td {
background: var(--diff-add-bg);
}
.diff-del td {
background: var(--diff-del-bg);
}
.diff-add .diff-sign {
color: var(--ok);
}
.diff-del .diff-sign {
color: var(--fail);
}
.diff-hunk td,
.diff-note td {
color: var(--text-muted);
background: var(--bg);
}
.banner-pending {
background: var(--bg);
color: var(--text);
}
.live-on {
color: var(--ok);
}
.live-off {
color: var(--text-muted);
}
.pick-table td:first-child {
width: 1%;
}
.mode-switch {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
+98
View File
@@ -0,0 +1,98 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { Schemas } from '../api/client';
import { parseUnifiedDiff, type DiffLineKind } from '../diff';
import { Badge } from './ui';
type FileDiff = Schemas['FileDiff'];
type DiffStatus = FileDiff['status'];
const statusKind: Record<DiffStatus, string> = {
added: 'success',
removed: 'danger',
modified: 'warning',
};
const sign: Record<DiffLineKind, string> = { add: '+', del: '−', ctx: '', hunk: '', note: '' };
/** Up to this many changed lines in total, all files start expanded. */
const autoOpenLines = 400;
function FileDiffView({ file, defaultOpen }: { file: FileDiff; defaultOpen: boolean }) {
const { t } = useTranslation();
const [open, setOpen] = useState(defaultOpen);
return (
<div className="file-diff">
<div className="file-diff-head">
<button
type="button"
className="link file-diff-toggle"
aria-expanded={open}
onClick={() => {
setOpen(!open);
}}
>
<span aria-hidden="true">{open ? '▾' : '▸'}</span>{' '}
<span className="mono">{file.path}</span>
</button>
<Badge>{t(`agents.roles.${file.role}`)}</Badge>
<Badge kind={statusKind[file.status]}>{t(`diff.status.${file.status}`)}</Badge>
<span className="file-diff-counts">
<span className="diff-count-add" aria-hidden="true">
+{file.inserted}
</span>{' '}
<span className="diff-count-del" aria-hidden="true">
−{file.deleted}
</span>
<span className="sr-only">
{t('diff.counts', { inserted: file.inserted, deleted: file.deleted })}
</span>
</span>
</div>
{open && (
<div className="file-diff-body">
<table className="diff-table mono">
<tbody>
{parseUnifiedDiff(file.diff).map((l, i) => (
<tr key={i} className={`diff-${l.kind}`}>
<td className="diff-no" aria-hidden="true">
{l.oldNo ?? ''}
</td>
<td className="diff-no" aria-hidden="true">
{l.newNo ?? ''}
</td>
<td className="diff-sign">
<span aria-hidden="true">{sign[l.kind]}</span>
{l.kind === 'add' && <span className="sr-only">{t('diff.added')}</span>}
{l.kind === 'del' && <span className="sr-only">{t('diff.deleted')}</span>}
</td>
<td className="diff-text">{l.text}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
/** Unified diffs per file (revision comparison or pending changes). */
export function DiffView({ files, emptyText }: { files: FileDiff[]; emptyText?: string }) {
const { t } = useTranslation();
if (files.length === 0) return <p>{emptyText ?? t('diff.empty')}</p>;
const total = files.reduce((n, f) => n + f.inserted + f.deleted, 0);
return (
<div className="diff-files">
{files.map((f) => (
<FileDiffView
key={`${f.role} ${f.path}`}
file={f}
defaultOpen={total <= autoOpenLines || f.inserted + f.deleted <= 40}
/>
))}
</div>
);
}
+79 -42
View File
@@ -8,6 +8,8 @@ import { formatDate } from '../format';
import { useServerHealth } from '../health';
import { changeLanguage, languages, type Language } from '../i18n';
import { reasonText, useLicense } from '../license';
import { LiveContext, useLiveEvents } from '../live';
import { usePendingChanges } from '../pending';
import { themes, useTheme, type Theme } from '../theme';
const themeLabelKey = {
@@ -36,6 +38,24 @@ function LicenseBanner() {
);
}
/** "N changes waiting to be deployed" while the data differs from the latest revision. */
function PendingBanner() {
const { t } = useTranslation();
const { data } = usePendingChanges();
if (!data?.changed) return null;
const count = data.changes.length;
return (
<div className="banner banner-pending" role="status">
<span>
{count > 0
? t('pending.banner', { count })
: t('pending.bannerNoEntries', { revision: data.latest_revision })}
</span>
<Link to="/revisions">{t('pending.link')}</Link>
</div>
);
}
export function Preferences() {
const { t, i18n } = useTranslation();
const [theme, setTheme] = useTheme();
@@ -77,6 +97,7 @@ export function Layout() {
const { t } = useTranslation();
const { session, can, logout } = useSession();
const health = useServerHealth();
const live = useLiveEvents(can('ipam.read'));
const navigate = useNavigate();
const items: { to: string; label: string; show: boolean }[] = [
@@ -94,47 +115,63 @@ export function Layout() {
];
return (
<div className="app">
<header className="app-header">
<Link to="/" className="brand">
<span className="brand-name">{productName}</span>
<span className="brand-tagline">{t('app.tagline')}</span>
</Link>
<nav className="main-nav" aria-label="main">
{items
.filter((i) => i.show)
.map((i) => (
<NavLink key={i.to} to={i.to}>
{i.label}
</NavLink>
))}
</nav>
<div className="user-menu">
<NavLink to="/account">{session?.user.display_name || session?.user.username}</NavLink>
<button
type="button"
className="link"
onClick={() => {
void logout().then(() => navigate('/login'));
}}
>
{t('nav.logout')}
</button>
</div>
</header>
<LicenseBanner />
<main className="app-main">
<Outlet />
</main>
<footer className="app-footer">
<span>
{t('health.label')}:{' '}
<output className={`health health-${health}`} aria-live="polite">
{t(`health.${health}`)}
</output>
</span>
<Preferences />
</footer>
</div>
<LiveContext value={live}>
<div className="app">
<header className="app-header">
<Link to="/" className="brand">
<span className="brand-name">{productName}</span>
<span className="brand-tagline">{t('app.tagline')}</span>
</Link>
<nav className="main-nav" aria-label="main">
{items
.filter((i) => i.show)
.map((i) => (
<NavLink key={i.to} to={i.to}>
{i.label}
</NavLink>
))}
</nav>
<div className="user-menu">
<NavLink to="/account">{session?.user.display_name || session?.user.username}</NavLink>
<button
type="button"
className="link"
onClick={() => {
void logout().then(() => navigate('/login'));
}}
>
{t('nav.logout')}
</button>
</div>
</header>
<LicenseBanner />
{can('ipam.read') && <PendingBanner />}
<main className="app-main">
<Outlet />
</main>
<footer className="app-footer">
<span>
{t('health.label')}:{' '}
<output className={`health health-${health}`} aria-live="polite">
{t(`health.${health}`)}
</output>
{can('ipam.read') && (
<>
{' · '}
{t('live.label')}:{' '}
<output
className={live ? 'live-on' : 'live-off'}
aria-live="polite"
title={t(live ? 'live.onHint' : 'live.offHint')}
>
{t(live ? 'live.on' : 'live.off')}
</output>
</>
)}
</span>
<Preferences />
</footer>
</div>
</LiveContext>
);
}
+485
View File
@@ -0,0 +1,485 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
import { act, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { parseUnifiedDiff } from './diff';
import { changeLanguage } from './i18n';
import { adminSession, mockApi, problem, renderApp, validLicense } from './test-utils';
/** Minimal EventSource stand-in; tests open it and emit events. */
class MockEventSource extends EventTarget {
static instances: MockEventSource[] = [];
readonly url: string;
closed = false;
constructor(url: string) {
super();
this.url = url;
MockEventSource.instances.push(this);
}
close() {
this.closed = true;
}
open() {
this.dispatchEvent(new Event('open'));
}
emit(type: string, data: unknown) {
this.dispatchEvent(new MessageEvent(type, { data: JSON.stringify(data) }));
}
}
const base = {
'GET /api/v1/auth/session': () => ({ body: adminSession }),
'GET /api/v1/license': () => ({ body: validLicense }),
};
const noPending = {
latest_revision: 4,
changed: false,
files: [],
changes: [],
};
const fileDiff = {
role: 'dns',
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',
inserted: 1,
deleted: 1,
};
const auditEntry = {
id: 41,
at: '2026-10-13T12:00:00Z',
actor_name: 'alice',
action: 'address.update',
object_type: 'address',
object_name: '192.0.2.11',
request_id: 'r1',
};
function agent(over: Record<string, unknown>) {
return {
id: 'a-dns',
role: 'dns',
hostname: 'dns1',
agent_version: '0.5.0',
service_version: 'BIND 9.20.3',
connected: true,
mode: 'auto_pull',
enrolled_at: '2026-10-13T10:00:00Z',
last_seen_at: '2026-10-13T11:00:00Z',
cert_expires_at: '2026-10-27T10:00:00Z',
applied_revision: 3,
last_good_revision: 3,
desired_revision: 4,
target_revision: 0,
health: { state: 'ok', checks: [] },
...over,
};
}
const dns1 = agent({});
const dhcp1 = agent({
id: 'a-dhcp',
role: 'dhcp',
hostname: 'dhcp1',
mode: 'manual',
connected: false,
target_revision: 3,
});
const old1 = agent({ id: 'a-old', hostname: 'old1', revoked_at: '2026-10-13T09:00:00Z' });
function revision(number: number) {
return {
number,
created_at: '2026-10-13T11:00:00Z',
created_by_name: 'admin',
comment: `rev ${String(number)}`,
dns: [],
dhcp: [],
warnings: [],
};
}
function summary(number: number) {
return {
number,
created_at: '2026-10-13T11:00:00Z',
created_by_name: 'admin',
comment: `rev ${String(number)}`,
dns_files: 2,
dhcp_files: 1,
warnings: 0,
};
}
/** Handlers for the revision detail page of revision n (latest = 4). */
function revisionPage(n: number) {
return {
...base,
'GET /api/v1/config/pending': () => ({ body: noPending }),
'GET /api/v1/revisions': () => ({
body: { items: [4, 3, 2, 1].map(summary), next_before: null },
}),
[`GET /api/v1/revisions/${String(n)}`]: () => ({ body: revision(n) }),
[`GET /api/v1/revisions/${String(n)}/diff`]: () => ({
body: { number: n, against: n - 1, files: [fileDiff] },
}),
'GET /api/v1/deployments': () => ({ body: { items: [], next_before_id: null } }),
'GET /api/v1/agents': () => ({ body: { items: [dns1, dhcp1, old1] } }),
};
}
function requestedUrls(): string[] {
return vi
.mocked(globalThis.fetch)
.mock.calls.map(([input]) => (input instanceof Request ? input.url : String(input)));
}
beforeEach(async () => {
window.localStorage.clear();
MockEventSource.instances = [];
await changeLanguage('de');
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('diff parsing', () => {
it('numbers old and new lines from the hunk header', () => {
const lines = parseUnifiedDiff(fileDiff.diff);
expect(lines.map((l) => [l.kind, l.oldNo, l.newNo])).toEqual([
['hunk', undefined, undefined],
['ctx', 2, 2],
['del', 3, undefined],
['add', undefined, 3],
['ctx', 4, 4],
]);
expect(lines[2]?.text).toBe('printer A 192.0.2.10');
});
});
describe('pending changes', () => {
it('shows the header notice with a link to the deploy page', async () => {
mockApi({
...base,
'GET /api/v1/networks': () => ({ body: { items: [] } }),
'GET /api/v1/config/pending': () => ({
body: {
...noPending,
changed: true,
files: [fileDiff],
changes: [auditEntry, { ...auditEntry, id: 42 }],
},
}),
});
renderApp('/networks');
const text = await screen.findByText('2 Änderungen warten auf Ausrollen.');
const banner = text.closest('.banner');
if (!(banner instanceof HTMLElement)) throw new Error('banner not found');
expect(within(banner).getByRole('link', { name: 'Zum Ausrollen' })).toHaveAttribute(
'href',
'/revisions',
);
});
it('lists pending audit entries and renders the file diff', async () => {
mockApi({
...base,
'GET /api/v1/revisions': () => ({ body: { items: [summary(4)], next_before: null } }),
'GET /api/v1/config/pending': () => ({
body: { ...noPending, changed: true, files: [fileDiff], changes: [auditEntry] },
}),
});
renderApp('/revisions');
expect(await screen.findByText('Offene Änderungen')).toBeInTheDocument();
expect(await screen.findByText('address.update')).toBeInTheDocument();
expect(screen.getByText('alice')).toBeInTheDocument();
expect(screen.getByText('zones/db.office.test')).toBeInTheDocument();
expect(screen.getByText('geändert')).toBeInTheDocument();
expect(screen.getByText('1 Zeilen hinzugefügt, 1 Zeilen entfernt')).toBeInTheDocument();
const removed = screen.getByText('printer A 192.0.2.10').closest('tr');
const added = screen.getByText('printer A 192.0.2.11').closest('tr');
expect(removed).toHaveClass('diff-del');
expect(added).toHaveClass('diff-add');
// Not by color only: the sign column names the kind of change.
if (!added || !removed) throw new Error('rows not found');
expect(within(added).getByText('hinzugefügt:')).toBeInTheDocument();
expect(within(removed).getByText('entfernt:')).toBeInTheDocument();
// Collapsible per file.
const toggle = screen.getByRole('button', { name: /zones\/db\.office\.test/ });
expect(toggle).toHaveAttribute('aria-expanded', 'true');
await userEvent.click(toggle);
expect(screen.queryByText('printer A 192.0.2.11')).not.toBeInTheDocument();
});
it('says when nothing is pending', async () => {
mockApi({
...base,
'GET /api/v1/revisions': () => ({ body: { items: [summary(4)], next_before: null } }),
'GET /api/v1/config/pending': () => ({ body: noPending }),
});
renderApp('/revisions');
expect(
await screen.findByText('Keine offenen Änderungen – der Datenstand entspricht Revision 4.'),
).toBeInTheDocument();
expect(screen.queryByText(/warten auf Ausrollen/)).not.toBeInTheDocument();
});
});
describe('revision detail', () => {
it('compares with the previous revision and with an empty one', async () => {
mockApi(revisionPage(4));
renderApp('/revisions/4');
expect(await screen.findByText('printer A 192.0.2.11')).toBeInTheDocument();
expect(requestedUrls().some((u) => u.includes('/revisions/4/diff?against=3'))).toBe(true);
const select = screen.getByLabelText('Vergleichen mit');
await waitFor(() => {
expect(within(select).getAllByRole('option')).toHaveLength(4);
});
await userEvent.selectOptions(select, '0');
await waitFor(() => {
expect(requestedUrls().some((u) => u.includes('/revisions/4/diff?against=0'))).toBe(true);
});
});
it('pushes the revision to the selected agents and shows the delivery', async () => {
const calls = mockApi({
...revisionPage(4),
'POST /api/v1/revisions/4/deploy': () => ({
body: {
items: [{ agent_id: 'a-dns', agent_hostname: 'dns1', deployment_id: 9, sent: true }],
},
}),
});
renderApp('/revisions/4');
await userEvent.click(await screen.findByRole('button', { name: 'Jetzt ausrollen' }));
const dialog = await screen.findByRole('dialog');
const dnsBox = await within(dialog).findByRole('checkbox', { name: 'dns1 auswählen' });
const dhcpBox = within(dialog).getByRole('checkbox', { name: 'dhcp1 auswählen' });
const oldBox = within(dialog).getByRole('checkbox', { name: 'old1 auswählen' });
expect(dnsBox).toBeChecked();
expect(dhcpBox).toBeChecked();
expect(oldBox).not.toBeChecked();
expect(oldBox).toBeDisabled();
expect(within(dialog).getByText('getrennt')).toBeInTheDocument();
await userEvent.click(dhcpBox);
await userEvent.click(within(dialog).getByRole('button', { name: 'An 1 Agent senden' }));
expect(await within(dialog).findByText('sofort gesendet')).toBeInTheDocument();
expect(within(dialog).getByText('Auftrag für 1 Agent angelegt.')).toBeInTheDocument();
const post = calls.find((c) => c.method === 'POST');
expect(post?.body).toEqual({ agent_ids: ['a-dns'] });
expect(post?.headers.get('X-CSRF-Token')).toBe('csrf-1');
});
it('explains rollback_required for older revisions on auto_pull agents', async () => {
mockApi({
...revisionPage(3),
'POST /api/v1/revisions/3/deploy': () => ({
status: 422,
body: problem(422, 'rollback_required'),
}),
});
renderApp('/revisions/3');
await userEvent.click(await screen.findByRole('button', { name: 'Jetzt ausrollen' }));
const dialog = await screen.findByRole('dialog');
const dnsBox = await within(dialog).findByRole('checkbox', { name: 'dns1 auswählen' });
// auto_pull agents take only the latest revision: not preselected.
expect(dnsBox).not.toBeChecked();
expect(
within(dialog).getByText(/Das ist nicht die neueste Revision \(4\)/),
).toBeInTheDocument();
// dhcp1 (manual) already runs revision 3: not preselected either.
expect(within(dialog).getByRole('checkbox', { name: 'dhcp1 auswählen' })).not.toBeChecked();
expect(within(dialog).getAllByText('(bereits aktiv)')).toHaveLength(3);
await userEvent.click(dnsBox);
await userEvent.click(within(dialog).getByRole('button', { name: 'An 1 Agent senden' }));
expect(
await within(dialog).findByText(/Ältere Revisionen lassen sich nicht auf Agents/),
).toBeInTheDocument();
});
it('rolls back to an older revision and opens the new one', async () => {
const calls = mockApi({
...revisionPage(3),
'POST /api/v1/revisions/3/rollback': () => ({ status: 201, body: revision(5) }),
'GET /api/v1/revisions/5': () => ({ body: revision(5) }),
'GET /api/v1/revisions/5/diff': () => ({ body: { number: 5, against: 4, files: [] } }),
});
const { router } = renderApp('/revisions/3');
await userEvent.click(
await screen.findByRole('button', { name: 'Auf diese Revision zurücksetzen' }),
);
const dialog = await screen.findByRole('dialog');
await userEvent.type(within(dialog).getByLabelText('Kommentar'), 'printer broke');
await userEvent.click(within(dialog).getByRole('button', { name: 'Zurücksetzen' }));
await waitFor(() => {
expect(router.state.location.pathname).toBe('/revisions/5');
});
expect(await screen.findByRole('heading', { name: 'Revision 5' })).toBeInTheDocument();
expect(calls.find((c) => c.path.endsWith('/rollback'))?.body).toEqual({
comment: 'printer broke',
});
});
it('translates rollback_unavailable', async () => {
mockApi({
...revisionPage(3),
'POST /api/v1/revisions/3/rollback': () => ({
status: 409,
body: problem(409, 'rollback_unavailable'),
}),
});
renderApp('/revisions/3');
await userEvent.click(
await screen.findByRole('button', { name: 'Auf diese Revision zurücksetzen' }),
);
const dialog = await screen.findByRole('dialog');
await userEvent.click(within(dialog).getByRole('button', { name: 'Zurücksetzen' }));
expect(
await within(dialog).findByText(/Diese Revision kann nicht wiederhergestellt werden/),
).toBeInTheDocument();
});
it('hides rollback on the latest revision', async () => {
mockApi(revisionPage(4));
renderApp('/revisions/4');
expect(await screen.findByText('neueste Revision')).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Auf diese Revision zurücksetzen' }),
).not.toBeInTheDocument();
});
});
describe('agent mode', () => {
it('switches an agent to manual mode and shows its target revision', async () => {
let current = dns1;
const calls = mockApi({
...base,
'GET /api/v1/config/pending': () => ({ body: noPending }),
'GET /api/v1/agents': () => ({ body: { items: [current] } }),
'GET /api/v1/agents/a-dns': () => ({ body: current }),
'PATCH /api/v1/agents/a-dns': () => {
current = { ...dns1, mode: 'manual' };
return { body: current };
},
'GET /api/v1/deployments': () => ({
body: {
items: [
{
id: 9,
agent_id: 'a-dns',
agent_hostname: 'dns1',
revision: 4,
trigger: 'push',
outcome: 'pending',
message: '',
active_revision: 3,
started_at: '2026-10-13T11:00:00Z',
requested_by_name: 'admin',
},
{
id: 8,
agent_id: 'a-dns',
agent_hostname: 'dns1',
revision: 3,
trigger: 'push',
outcome: 'superseded',
message: '',
active_revision: 3,
started_at: '2026-10-13T10:00:00Z',
finished_at: '2026-10-13T11:00:00Z',
},
],
next_before_id: null,
},
}),
});
renderApp('/agents/a-dns');
expect(await screen.findByText('Der Agent übernimmt jede neue Revision sofort.')).toBeVisible();
expect(await screen.findByText('wartet')).toBeInTheDocument();
expect(screen.getByText('ersetzt')).toBeInTheDocument();
expect(screen.getByText('von admin')).toBeInTheDocument();
expect(screen.queryByText('(aktiv: 3)')).not.toBeInTheDocument();
await userEvent.selectOptions(screen.getByLabelText('Betriebsmodus'), 'manual');
expect(await screen.findByText(/Der Agent bleibt auf seiner Soll-Revision/)).toBeVisible();
expect(screen.getByText('Soll-Revision (manuell)')).toBeInTheDocument();
expect(screen.getByText('noch keine – erst nach „Jetzt ausrollen“')).toBeInTheDocument();
expect(calls.find((c) => c.method === 'PATCH')?.body).toEqual({ mode: 'manual' });
});
it('shows the mode in the agent list', async () => {
mockApi({
...base,
'GET /api/v1/config/pending': () => ({ body: noPending }),
'GET /api/v1/agents': () => ({ body: { items: [dns1, dhcp1] } }),
'GET /api/v1/enrollment-tokens': () => ({ body: { items: [] } }),
});
renderApp('/agents');
const row = (await screen.findByRole('link', { name: 'dhcp1' })).closest('tr');
if (!row) throw new Error('row not found');
expect(within(row).getByText('Manuell')).toBeInTheDocument();
});
});
describe('live updates', () => {
it('is offline without EventSource', async () => {
mockApi({
...base,
'GET /api/v1/config/pending': () => ({ body: noPending }),
'GET /api/v1/networks': () => ({ body: { items: [] } }),
});
renderApp('/networks');
expect(await screen.findByText('getrennt')).toBeInTheDocument();
});
it('reloads agents on an agent event and closes the stream on logout', async () => {
vi.stubGlobal('EventSource', MockEventSource);
let hostname = 'dns1';
mockApi({
...base,
'GET /api/v1/config/pending': () => ({ body: noPending }),
'GET /api/v1/agents': () => ({ body: { items: [{ ...dns1, hostname }] } }),
'GET /api/v1/enrollment-tokens': () => ({ body: { items: [] } }),
'POST /api/v1/auth/logout': () => ({ status: 204 }),
'GET /api/v1/system/info': () => ({
body: { product: 'DDI-Suite', version: 'test', auth: { local: true } },
}),
});
renderApp('/agents');
expect(await screen.findByRole('link', { name: 'dns1' })).toBeInTheDocument();
await waitFor(() => {
expect(MockEventSource.instances).toHaveLength(1);
});
const es = MockEventSource.instances[0];
if (!es) throw new Error('no event source');
expect(es.url).toBe('/api/v1/events');
act(() => {
es.open();
});
const footer = screen.getByRole('contentinfo');
expect(await within(footer).findByText('verbunden')).toBeInTheDocument();
hostname = 'dns1-renamed';
act(() => {
es.emit('agent', { id: 'a-dns' });
});
expect(await screen.findByRole('link', { name: 'dns1-renamed' })).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Abmelden' }));
await waitFor(() => {
expect(es.closed).toBe(true);
});
});
});
+40
View File
@@ -0,0 +1,40 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
export type DiffLineKind = 'add' | 'del' | 'ctx' | 'hunk' | 'note';
export interface DiffLine {
kind: DiffLineKind;
text: string;
/** Line number in the old file (deleted and context lines). */
oldNo?: number;
/** Line number in the new file (added and context lines). */
newNo?: number;
}
const hunkHeader = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/;
/** Splits unified diff hunks (no file header) into lines with old/new line numbers. */
export function parseUnifiedDiff(diff: string): DiffLine[] {
const out: DiffLine[] = [];
let oldNo = 0;
let newNo = 0;
const lines = diff.split('\n');
if (lines.at(-1) === '') lines.pop();
for (const line of lines) {
const m = hunkHeader.exec(line);
if (m) {
oldNo = Number(m[1]);
newNo = Number(m[2]);
out.push({ kind: 'hunk', text: line });
} else if (line.startsWith('+')) {
out.push({ kind: 'add', text: line.slice(1), newNo: newNo++ });
} else if (line.startsWith('-')) {
out.push({ kind: 'del', text: line.slice(1), oldNo: oldNo++ });
} else if (line.startsWith('\\')) {
out.push({ kind: 'note', text: line });
} else {
out.push({ kind: 'ctx', text: line.slice(1), oldNo: oldNo++, newNo: newNo++ });
}
}
return out;
}
+119
View File
@@ -0,0 +1,119 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
import { useQueryClient, type QueryClient } from '@tanstack/react-query';
import { createContext, useContext, useEffect, useState } from 'react';
/** Server-Sent Events endpoint (ADR 0022); same origin, session cookie. */
export const eventsUrl = '/api/v1/events';
/** Polling interval while the live stream is connected (fallback only). */
export const liveFallbackMs = 60_000;
const minBackoffMs = 1_000;
const maxBackoffMs = 30_000;
/** True while the live event stream is connected. */
export const LiveContext = createContext(false);
export function useLive(): boolean {
return useContext(LiveContext);
}
/** Polls every offlineMs without a live stream, rarely while it is connected. */
export function useRefreshInterval(offlineMs: number): number {
return useLive() ? Math.max(offlineMs, liveFallbackMs) : offlineMs;
}
function parse(data: unknown): Record<string, unknown> {
if (typeof data !== 'string' || data === '') return {};
try {
const v: unknown = JSON.parse(data);
return typeof v === 'object' && v !== null ? (v as Record<string, unknown>) : {};
} catch {
return {};
}
}
function invalidateAgent(qc: QueryClient, id: unknown) {
void qc.invalidateQueries({ queryKey: ['agents'], exact: true });
if (typeof id === 'string' && id !== '') void qc.invalidateQueries({ queryKey: ['agents', id] });
}
/** Query invalidations per event type. Events carry IDs only. */
const handlers: Record<string, (qc: QueryClient, data: Record<string, unknown>) => void> = {
agent: (qc, d) => {
invalidateAgent(qc, d.id);
},
deployment: (qc, d) => {
void qc.invalidateQueries({ queryKey: ['deployments'] });
invalidateAgent(qc, d.agent_id);
},
revision: (qc) => {
void qc.invalidateQueries({ queryKey: ['revisions'] });
void qc.invalidateQueries({ queryKey: ['config-pending'] });
void qc.invalidateQueries({ queryKey: ['agents'] });
},
pending: (qc) => {
void qc.invalidateQueries({ queryKey: ['config-pending'] });
},
};
/** Everything an event could have changed, after missing events while offline. */
function invalidateAll(qc: QueryClient) {
for (const key of ['agents', 'deployments', 'revisions', 'config-pending']) {
void qc.invalidateQueries({ queryKey: [key] });
}
}
/**
* Keeps one EventSource open while enabled, invalidates the affected queries
* on events and reconnects with exponential backoff. Returns whether the
* stream is connected. Without EventSource (e.g. jsdom) it stays offline.
*/
export function useLiveEvents(enabled: boolean): boolean {
const qc = useQueryClient();
const [live, setLive] = useState(false);
useEffect(() => {
if (!enabled || typeof EventSource === 'undefined') return;
let source: EventSource | null = null;
let timer: ReturnType<typeof setTimeout> | undefined;
let attempt = 0;
let missed = false;
let stopped = false;
const connect = () => {
const es = new EventSource(eventsUrl);
source = es;
es.addEventListener('open', () => {
attempt = 0;
setLive(true);
if (missed) invalidateAll(qc);
missed = false;
});
es.addEventListener('error', () => {
// Reconnect ourselves: the browser gives up on HTTP errors anyway.
es.close();
setLive(false);
missed = true;
if (stopped) return;
const delay = Math.min(maxBackoffMs, minBackoffMs * 2 ** attempt);
attempt++;
timer = setTimeout(connect, delay);
});
for (const [type, handle] of Object.entries(handlers)) {
es.addEventListener(type, (e) => {
handle(qc, parse(e.data));
});
}
};
connect();
return () => {
stopped = true;
clearTimeout(timer);
source?.close();
};
}, [enabled, qc]);
return enabled && live;
}
+84 -6
View File
@@ -397,7 +397,9 @@
"config_invalid": "Die Konfiguration ist ungültig ({{path}}: {{message}}).",
"config_failed": "Die Konfiguration konnte nicht erzeugt werden: {{message}}",
"agent_api_unavailable": "Die Agent-Schnittstelle des Servers ist nicht eingerichtet.",
"revision_limit": "Die höchste mögliche Revisionsnummer ist erreicht."
"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."
},
"problems": {
"required": "Pflichtfeld.",
@@ -473,7 +475,7 @@
},
"config": {
"title": "Erzeugte Konfiguration",
"hint": "Vorschau der BIND- und Kea-Konfiguration für den aktuellen Datenstand. Ausgerollt wird ab Meilenstein 3 über die Agents.",
"hint": "Vorschau der BIND- und Kea-Konfiguration für den aktuellen Datenstand. Ausgerollt wird sie als Revision unter „Ausrollen“.",
"dns": "DNS (BIND)",
"dhcp": "DHCP (Kea)",
"createOnly": "nur beim ersten Mal angelegt",
@@ -549,6 +551,13 @@
"auto_pull": "Automatisch holen",
"manual": "Manuell"
},
"modeHelp": {
"auto_pull": "Der Agent übernimmt jede neue Revision sofort.",
"manual": "Der Agent bleibt auf seiner Soll-Revision, bis jemand über „Jetzt ausrollen“ eine andere Revision an ihn sendet."
},
"modeChanged": "Modus geändert. Der Agent verbindet sich dafür kurz neu.",
"targetRevision": "Soll-Revision (manuell)",
"noTarget": "noch keine – erst nach „Jetzt ausrollen“",
"agentVersion": "Agent-Version",
"serviceVersion": "Dienst-Version",
"enrolled": "Registriert",
@@ -585,7 +594,7 @@
"revisions": {
"title": "Ausrollen",
"create": "Neue Revision",
"createHint": "Erzeugt die Konfiguration aus dem aktuellen Datenstand als neue, unveränderliche Revision. Agents im Modus „Automatisch holen“ wenden sie sofort an.",
"createHint": "Erzeugt die Konfiguration aus dem aktuellen Datenstand als neue, unveränderliche Revision. Agents im Modus „Automatisch holen“ wenden sie sofort an, Agents im Modus „Manuell“ erst nach „Jetzt ausrollen“ auf der Seite der Revision.",
"comment": "Kommentar",
"commentPlaceholder": "Was hat sich geändert?",
"createButton": "Revision erzeugen und ausrollen",
@@ -600,7 +609,72 @@
"fileCounts": "DNS {{dns}}, DHCP {{dhcp}}",
"warnings": "Warnungen",
"detailTitle": "Revision {{number}}",
"back": "Zurück zu allen Revisionen"
"back": "Zurück zu allen Revisionen",
"status": "Status",
"latest": "neueste Revision"
},
"pending": {
"title": "Offene Änderungen",
"banner_one": "{{count}} Änderung wartet auf Ausrollen.",
"banner_other": "{{count}} Änderungen warten auf Ausrollen.",
"bannerNoEntries": "Die Konfiguration weicht von der zuletzt ausgerollten Revision ab.",
"link": "Zum Ausrollen",
"none": "Keine offenen Änderungen – der Datenstand entspricht Revision {{revision}}.",
"noneNoRevision": "Noch keine Revision und keine Konfiguration zum Ausrollen.",
"hint": "Diese Änderungen sind gespeichert, aber noch nicht ausgerollt. Sie gelangen mit der nächsten Revision auf die Server (Vergleich mit Revision {{revision}}).",
"hintNoRevision": "Es gibt noch keine Revision. Die erste Revision rollt den gesamten Datenstand aus.",
"files": "Auswirkung auf die Konfiguration",
"noFiles": "Die Änderungen wirken sich nicht auf die erzeugten Dateien aus."
},
"diff": {
"title": "Änderungen",
"against": "Vergleichen mit",
"revision": "Revision {{number}}",
"emptyRevision": "leer (vollständiger Inhalt)",
"empty": "Keine Unterschiede.",
"status": {
"added": "neu",
"removed": "entfernt",
"modified": "geändert"
},
"counts": "{{inserted}} Zeilen hinzugefügt, {{deleted}} Zeilen entfernt",
"added": "hinzugefügt:",
"deleted": "entfernt:"
},
"deploy": {
"button": "Jetzt ausrollen",
"title": "Revision {{number}} ausrollen",
"hint": "Die Revision wird sofort an die ausgewählten Agents gesendet. Nicht verbundene Agents erhalten sie, sobald sie sich wieder melden.",
"olderHint": "Das ist nicht die neueste Revision ({{latest}}). Agents im Modus „Automatisch holen“ würden sofort wieder auf die neueste wechseln; für sie gibt es „Auf diese Revision zurücksetzen“.",
"autoPullOlder": "nimmt nur die neueste Revision an",
"selection": "Auswahl",
"select": "{{host}} auswählen",
"alreadyActive": "(bereits aktiv)",
"noAgents": "Es ist noch kein Agent registriert.",
"submit_one": "An {{count}} Agent senden",
"submit_other": "An {{count}} Agents senden",
"done_one": "Auftrag für {{count}} Agent angelegt.",
"done_other": "Aufträge für {{count}} Agents angelegt.",
"noTargets": "Kein Agent betroffen.",
"delivery": "Zustellung",
"sent": "sofort gesendet",
"queued": "wird bei der nächsten Verbindung zugestellt",
"resultHint": "Das Ergebnis erscheint unter „Deployments“, sobald die Agents es melden."
},
"rollback": {
"button": "Auf diese Revision zurücksetzen",
"title": "Auf Revision {{number}} zurücksetzen",
"confirm": "Es wird eine neue Revision mit dem Inhalt von Revision {{number}} erzeugt. Agents im Modus „Automatisch holen“ übernehmen sie sofort, Agents im Modus „Manuell“ nach „Jetzt ausrollen“.",
"hint": "Die Daten im IPAM bleiben unverändert. Die nächste Revision aus dem aktuellen Datenstand enthält daher wieder alle späteren Änderungen.",
"submit": "Zurücksetzen",
"commentPlaceholder": "Zurückgesetzt auf Revision {{number}}"
},
"live": {
"label": "Live-Status",
"on": "verbunden",
"off": "getrennt",
"onHint": "Änderungen erscheinen sofort.",
"offHint": "Keine Live-Verbindung; die Anzeige wird regelmäßig neu geladen."
},
"deployments": {
"title": "Deployments",
@@ -616,12 +690,16 @@
"result": "Ergebnis",
"message": "Details",
"outcome": {
"pending": "wartet",
"succeeded": "erfolgreich",
"rejected": "abgelehnt",
"rolled_back": "zurückgerollt",
"failed": "fehlgeschlagen"
"failed": "fehlgeschlagen",
"superseded": "ersetzt"
},
"activeNow": "(aktiv: {{revision}})"
"activeNow": "(aktiv: {{revision}})",
"startedAt": "seit {{time}}",
"requestedBy": "von {{name}}"
},
"leases": {
"title": "Leases",
+84 -6
View File
@@ -397,7 +397,9 @@
"config_invalid": "The configuration is invalid ({{path}}: {{message}}).",
"config_failed": "The configuration could not be generated: {{message}}",
"agent_api_unavailable": "The server's agent interface is not configured.",
"revision_limit": "The highest possible revision number has been reached."
"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."
},
"problems": {
"required": "Required.",
@@ -473,7 +475,7 @@
},
"config": {
"title": "Generated configuration",
"hint": "Preview of the BIND and Kea configuration for the current data. Deployment via the agents follows in milestone 3.",
"hint": "Preview of the BIND and Kea configuration for the current data. It is deployed as a revision under \"Deploy\".",
"dns": "DNS (BIND)",
"dhcp": "DHCP (Kea)",
"createOnly": "created only once",
@@ -549,6 +551,13 @@
"auto_pull": "Pull automatically",
"manual": "Manual"
},
"modeHelp": {
"auto_pull": "The agent applies every new revision right away.",
"manual": "The agent stays on its target revision until someone sends it another revision with \"Deploy now\"."
},
"modeChanged": "Mode changed. The agent reconnects briefly to pick it up.",
"targetRevision": "Target revision (manual)",
"noTarget": "none yet – only after \"Deploy now\"",
"agentVersion": "Agent version",
"serviceVersion": "Service version",
"enrolled": "Enrolled",
@@ -585,7 +594,7 @@
"revisions": {
"title": "Deploy",
"create": "New revision",
"createHint": "Generates the configuration from the current data as a new, immutable revision. Agents in \"pull automatically\" mode apply it right away.",
"createHint": "Generates the configuration from the current data as a new, immutable revision. Agents in \"pull automatically\" mode apply it right away, agents in \"manual\" mode only after \"Deploy now\" on the revision's page.",
"comment": "Comment",
"commentPlaceholder": "What has changed?",
"createButton": "Create and deploy revision",
@@ -600,7 +609,72 @@
"fileCounts": "DNS {{dns}}, DHCP {{dhcp}}",
"warnings": "Warnings",
"detailTitle": "Revision {{number}}",
"back": "Back to all revisions"
"back": "Back to all revisions",
"status": "Status",
"latest": "latest revision"
},
"pending": {
"title": "Pending changes",
"banner_one": "{{count}} change is waiting to be deployed.",
"banner_other": "{{count}} changes are waiting to be deployed.",
"bannerNoEntries": "The configuration differs from the latest revision.",
"link": "Go to deployment",
"none": "No pending changes – the data matches revision {{revision}}.",
"noneNoRevision": "No revision yet and no configuration to deploy.",
"hint": "These changes are saved but not deployed yet. They reach the servers with the next revision (compared with revision {{revision}}).",
"hintNoRevision": "There is no revision yet. The first revision deploys all current data.",
"files": "Effect on the configuration",
"noFiles": "The changes do not affect the generated files."
},
"diff": {
"title": "Changes",
"against": "Compare with",
"revision": "Revision {{number}}",
"emptyRevision": "empty (full content)",
"empty": "No differences.",
"status": {
"added": "new",
"removed": "removed",
"modified": "modified"
},
"counts": "{{inserted}} lines added, {{deleted}} lines removed",
"added": "added:",
"deleted": "removed:"
},
"deploy": {
"button": "Deploy now",
"title": "Deploy revision {{number}}",
"hint": "The revision is sent to the selected agents right away. Agents that are not connected receive it as soon as they report back.",
"olderHint": "This is not the latest revision ({{latest}}). Agents in \"pull automatically\" mode would switch back to the latest one right away; use \"Roll back to this revision\" for them.",
"autoPullOlder": "accepts only the latest revision",
"selection": "Selection",
"select": "Select {{host}}",
"alreadyActive": "(already active)",
"noAgents": "No agent is enrolled yet.",
"submit_one": "Send to {{count}} agent",
"submit_other": "Send to {{count}} agents",
"done_one": "Created a deployment for {{count}} agent.",
"done_other": "Created deployments for {{count}} agents.",
"noTargets": "No agent affected.",
"delivery": "Delivery",
"sent": "sent now",
"queued": "delivered on next connection",
"resultHint": "The result appears under \"Deployments\" as soon as the agents report it."
},
"rollback": {
"button": "Roll back to this revision",
"title": "Roll back to revision {{number}}",
"confirm": "A new revision with the content of revision {{number}} is created. Agents in \"pull automatically\" mode apply it right away, agents in \"manual\" mode after \"Deploy now\".",
"hint": "The IPAM data stays unchanged. The next revision created from the current data therefore contains all later changes again.",
"submit": "Roll back",
"commentPlaceholder": "Rolled back to revision {{number}}"
},
"live": {
"label": "Live status",
"on": "connected",
"off": "disconnected",
"onHint": "Changes appear immediately.",
"offHint": "No live connection; the display is refreshed periodically."
},
"deployments": {
"title": "Deployments",
@@ -616,12 +690,16 @@
"result": "Result",
"message": "Details",
"outcome": {
"pending": "waiting",
"succeeded": "succeeded",
"rejected": "rejected",
"rolled_back": "rolled back",
"failed": "failed"
"failed": "failed",
"superseded": "superseded"
},
"activeNow": "(active: {{revision}})"
"activeNow": "(active: {{revision}})",
"startedAt": "since {{time}}",
"requestedBy": "by {{name}}"
},
"leases": {
"title": "Leases",
+76 -6
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState, type SyntheticEvent } from 'react';
import { useId, useState, type SyntheticEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useParams } from 'react-router';
@@ -9,16 +9,20 @@ import { useSession } from '../auth/context';
import { Badge, ConfirmDialog, ErrorBox, Field, Modal, Notice, Spinner } from '../components/ui';
import { fieldErrors } from '../errors';
import { formatDateTime } from '../format';
import { useRefreshInterval } from '../live';
import { DeploymentsTable } from './DeploymentsTable';
type Agent = Schemas['Agent'];
type AgentMode = Schemas['AgentMode'];
type AgentRole = Schemas['AgentRole'];
type HealthState = Schemas['HealthState'];
type TokenCreated = Schemas['EnrollmentTokenCreated'];
/** Agents report their state every few seconds; M4 replaces polling with a live stream. */
/** Polling without the live stream: agents report their state every few seconds. */
const refreshMs = 5000;
const modes: AgentMode[] = ['auto_pull', 'manual'];
const healthKind: Record<HealthState, string> = {
unknown: 'neutral',
ok: 'success',
@@ -31,7 +35,7 @@ export function HealthBadge({ state }: { state: HealthState }) {
return <Badge kind={healthKind[state]}>{t(`health.state.${state}`)}</Badge>;
}
function ConnectionBadge({ agent }: { agent: Agent }) {
export function ConnectionBadge({ agent }: { agent: Agent }) {
const { t } = useTranslation();
if (agent.revoked_at) return <Badge kind="danger">{t('agents.revoked')}</Badge>;
return agent.connected ? (
@@ -57,6 +61,54 @@ function RevisionCell({ agent }: { agent: Agent }) {
);
}
/** Operating mode with an explanation; admins can switch it. */
function ModeSwitch({ agent }: { agent: Agent }) {
const { t } = useTranslation();
const { can } = useSession();
const qc = useQueryClient();
const change = useMutation({
mutationFn: (mode: AgentMode) =>
unwrap(api.PATCH('/agents/{id}', { params: { path: { id: agent.id } }, body: { mode } })),
onSuccess: async (a) => {
qc.setQueryData(['agents', a.id], a);
await qc.invalidateQueries({ queryKey: ['agents'], exact: true });
},
});
const editable = can('settings.admin') && !agent.revoked_at;
const selectId = useId();
return (
<div className="form">
{editable ? (
<div className="mode-switch">
<label htmlFor={selectId} className="sr-only">
{t('agents.mode')}
</label>
<select
id={selectId}
value={agent.mode}
disabled={change.isPending}
onChange={(e) => {
change.mutate(e.target.value as AgentMode);
}}
>
{modes.map((m) => (
<option key={m} value={m}>
{t(`agents.modes.${m}`)}
</option>
))}
</select>
{change.isSuccess && <span className="muted">{t('agents.modeChanged')}</span>}
</div>
) : (
<span>{t(`agents.modes.${agent.mode}`)}</span>
)}
<span className="field-hint">{t(`agents.modeHelp.${agent.mode}`)}</span>
<ErrorBox error={change.error} />
</div>
);
}
function TokenForm({
onClose,
onCreated,
@@ -310,10 +362,11 @@ function TokensSection() {
export function AgentsPage() {
const { t } = useTranslation();
const { can } = useSession();
const refetchInterval = useRefreshInterval(refreshMs);
const q = useQuery({
queryKey: ['agents'],
queryFn: () => unwrap(api.GET('/agents')),
refetchInterval: refreshMs,
refetchInterval,
});
return (
@@ -333,6 +386,7 @@ export function AgentsPage() {
<th>{t('agents.role')}</th>
<th>{t('agents.connection')}</th>
<th>{t('agents.health')}</th>
<th>{t('agents.mode')}</th>
<th>{t('agents.revision')}</th>
<th>{t('agents.lastSeen')}</th>
</tr>
@@ -351,6 +405,7 @@ export function AgentsPage() {
<HealthBadge state={a.health.state} />{' '}
{a.health.message && <span className="muted">{a.health.message}</span>}
</td>
<td>{t(`agents.modes.${a.mode}`)}</td>
<td>
<RevisionCell agent={a} />
</td>
@@ -372,10 +427,11 @@ export function AgentDetailPage() {
const qc = useQueryClient();
const [confirm, setConfirm] = useState<'revoke' | 'delete' | null>(null);
const [deleted, setDeleted] = useState(false);
const refetchInterval = useRefreshInterval(refreshMs);
const q = useQuery({
queryKey: ['agents', id],
queryFn: () => unwrap(api.GET('/agents/{id}', { params: { path: { id } } })),
refetchInterval: refreshMs,
refetchInterval,
enabled: !deleted,
});
const act = useMutation({
@@ -459,7 +515,21 @@ export function AgentDetailPage() {
<dt>{t('agents.lastGood')}</dt>
<dd>{a.last_good_revision > 0 ? a.last_good_revision : t('common.none')}</dd>
<dt>{t('agents.mode')}</dt>
<dd>{t(`agents.modes.${a.mode}`)}</dd>
<dd>
<ModeSwitch agent={a} />
</dd>
{a.mode === 'manual' && (
<>
<dt>{t('agents.targetRevision')}</dt>
<dd>
{a.target_revision > 0 ? (
<Link to={`/revisions/${String(a.target_revision)}`}>{a.target_revision}</Link>
) : (
t('agents.noTarget')
)}
</dd>
</>
)}
<dt>{t('agents.agentVersion')}</dt>
<dd>{a.agent_version}</dd>
<dt>{t('agents.serviceVersion')}</dt>
+27 -4
View File
@@ -6,16 +6,22 @@ import { Link } from 'react-router';
import { api, unwrap, type Schemas } from '../api/client';
import { Badge, ErrorBox, Spinner } from '../components/ui';
import { formatDateTime } from '../format';
import { useRefreshInterval } from '../live';
type Outcome = Schemas['Deployment']['outcome'];
const outcomeKind: Record<Outcome, string> = {
pending: 'neutral',
succeeded: 'success',
rejected: 'warning',
rolled_back: 'warning',
failed: 'danger',
superseded: 'neutral',
};
/** Outcomes after which the agent kept (or went back to) another revision. */
const showsActive = new Set<Outcome>(['rejected', 'rolled_back', 'failed']);
export function OutcomeBadge({ outcome }: { outcome: Outcome }) {
const { t } = useTranslation();
return <Badge kind={outcomeKind[outcome]}>{t(`deployments.outcome.${outcome}`)}</Badge>;
@@ -24,6 +30,7 @@ export function OutcomeBadge({ outcome }: { outcome: Outcome }) {
/** Deployment results of one agent or one revision, newest first. */
export function DeploymentsTable({ agentId, revision }: { agentId?: string; revision?: number }) {
const { t } = useTranslation();
const refetchInterval = useRefreshInterval(5000);
const q = useInfiniteQuery({
queryKey: ['deployments', agentId ?? '', revision ?? 0],
queryFn: ({ pageParam }) =>
@@ -41,7 +48,7 @@ export function DeploymentsTable({ agentId, revision }: { agentId?: string; revi
),
initialPageParam: 0,
getNextPageParam: (last) => last.next_before_id ?? undefined,
refetchInterval: 5000,
refetchInterval,
});
if (q.error) return <ErrorBox error={q.error} />;
@@ -65,7 +72,15 @@ export function DeploymentsTable({ agentId, revision }: { agentId?: string; revi
<tbody>
{items.map((d) => (
<tr key={d.id}>
<td>{formatDateTime(d.finished_at)}</td>
<td>
{d.finished_at ? (
formatDateTime(d.finished_at)
) : (
<span className="muted">
{t('deployments.startedAt', { time: formatDateTime(d.started_at) })}
</span>
)}
</td>
{!agentId && (
<td>
<Link to={`/agents/${d.agent_id}`}>{d.agent_hostname || d.agent_id}</Link>
@@ -76,10 +91,18 @@ export function DeploymentsTable({ agentId, revision }: { agentId?: string; revi
<Link to={`/revisions/${String(d.revision)}`}>{d.revision}</Link>
</td>
)}
<td>{t(`deployments.triggers.${d.trigger}`)}</td>
<td>
{t(`deployments.triggers.${d.trigger}`)}
{d.requested_by_name && (
<span className="muted">
{' '}
{t('deployments.requestedBy', { name: d.requested_by_name })}
</span>
)}
</td>
<td>
<OutcomeBadge outcome={d.outcome} />
{d.outcome !== 'succeeded' && d.active_revision > 0 && (
{showsActive.has(d.outcome) && d.active_revision > 0 && (
<span className="muted">
{' '}
{t('deployments.activeNow', { revision: d.active_revision })}
+272
View File
@@ -0,0 +1,272 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import { api, unwrap, type Schemas } from '../api/client';
import { Badge, ErrorBox, Field, Modal, Notice, Spinner } from '../components/ui';
import { pendingKey } from '../pending';
import { ConnectionBadge } from './AgentsPage';
type Agent = Schemas['Agent'];
type DeployTarget = Schemas['DeployTarget'];
/**
* Whether pushing the revision to the agent is accepted: revoked agents are
* skipped, auto_pull agents only take the latest revision (ADR 0022).
*/
function deployable(a: Agent, isOlder: boolean): boolean {
return !a.revoked_at && !(a.mode === 'auto_pull' && isOlder);
}
function DeployResult({ items }: { items: DeployTarget[] }) {
const { t } = useTranslation();
if (items.length === 0) return <Notice>{t('deploy.noTargets')}</Notice>;
return (
<>
<Notice kind="success">{t('deploy.done', { count: items.length })}</Notice>
<table className="table">
<thead>
<tr>
<th>{t('agents.host')}</th>
<th>{t('deploy.delivery')}</th>
</tr>
</thead>
<tbody>
{items.map((d) => (
<tr key={d.agent_id}>
<td>{d.agent_hostname || d.agent_id}</td>
<td>
{d.sent ? (
<Badge kind="success">{t('deploy.sent')}</Badge>
) : (
<Badge kind="warning">{t('deploy.queued')}</Badge>
)}
</td>
</tr>
))}
</tbody>
</table>
<p className="field-hint">{t('deploy.resultHint')}</p>
</>
);
}
/** "Deploy now": push a revision to selected agents. */
export function DeployDialog({
revision,
latest,
onClose,
}: {
revision: number;
/** Newest revision number, if known. */
latest: number | undefined;
onClose: () => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const agents = useQuery({
queryKey: ['agents'],
queryFn: () => unwrap(api.GET('/agents')),
});
// null: the default selection, derived from the agent list.
const [picked, setPicked] = useState<Set<string> | null>(null);
const isOlder = latest !== undefined && revision < latest;
const list = agents.data?.items ?? [];
const selected =
picked ??
new Set(
list
.filter((a) => deployable(a, isOlder) && a.applied_revision !== revision)
.map((a) => a.id),
);
const deploy = useMutation({
mutationFn: () =>
unwrap(
api.POST('/revisions/{number}/deploy', {
params: { path: { number: revision } },
body: { agent_ids: [...selected] },
}),
),
onSuccess: async () => {
await Promise.all([
qc.invalidateQueries({ queryKey: ['deployments'] }),
qc.invalidateQueries({ queryKey: ['agents'] }),
]);
},
});
const toggle = (id: string) => {
const next = new Set(selected);
if (next.has(id)) next.delete(id);
else next.add(id);
setPicked(next);
};
const done = deploy.data !== undefined;
return (
<Modal
title={t('deploy.title', { number: revision })}
onClose={onClose}
wide
footer={
done ? (
<button type="button" className="primary" onClick={onClose}>
{t('common.close')}
</button>
) : (
<>
<button type="button" onClick={onClose}>
{t('common.cancel')}
</button>
<button
type="button"
className="primary"
disabled={deploy.isPending || selected.size === 0}
onClick={() => {
deploy.mutate();
}}
>
{t('deploy.submit', { count: selected.size })}
</button>
</>
)
}
>
{done ? (
<DeployResult items={deploy.data.items} />
) : (
<>
<p className="field-hint">{t('deploy.hint')}</p>
{isOlder && <Notice kind="warning">{t('deploy.olderHint', { latest })}</Notice>}
{agents.isPending && <Spinner />}
<ErrorBox error={agents.error} />
{agents.data && list.length === 0 && <p>{t('deploy.noAgents')}</p>}
{list.length > 0 && (
<table className="table pick-table">
<thead>
<tr>
<th>
<span className="sr-only">{t('deploy.selection')}</span>
</th>
<th>{t('agents.host')}</th>
<th>{t('agents.role')}</th>
<th>{t('agents.mode')}</th>
<th>{t('agents.connection')}</th>
<th>{t('agents.revision')}</th>
</tr>
</thead>
<tbody>
{list.map((a) => {
const host = a.hostname || a.id;
return (
<tr key={a.id}>
<td>
<input
type="checkbox"
aria-label={t('deploy.select', { host })}
checked={selected.has(a.id)}
disabled={Boolean(a.revoked_at)}
onChange={() => {
toggle(a.id);
}}
/>
</td>
<td>{host}</td>
<td>{t(`agents.roles.${a.role}`)}</td>
<td>
{t(`agents.modes.${a.mode}`)}
{!a.revoked_at && a.mode === 'auto_pull' && isOlder && (
<div className="field-hint">{t('deploy.autoPullOlder')}</div>
)}
</td>
<td>
<ConnectionBadge agent={a} />
</td>
<td>
{a.applied_revision > 0 ? a.applied_revision : t('common.none')}
{a.applied_revision === revision && (
<span className="muted"> {t('deploy.alreadyActive')}</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
<ErrorBox error={deploy.error} />
</>
)}
</Modal>
);
}
/** Creates a new revision with the content of an older one and opens it. */
export function RollbackDialog({ revision, onClose }: { revision: number; onClose: () => void }) {
const { t } = useTranslation();
const qc = useQueryClient();
const navigate = useNavigate();
const [comment, setComment] = useState('');
const rollback = useMutation({
mutationFn: () =>
unwrap(
api.POST('/revisions/{number}/rollback', {
params: { path: { number: revision } },
body: comment.trim() ? { comment: comment.trim() } : {},
}),
),
onSuccess: async (r) => {
await Promise.all([
qc.invalidateQueries({ queryKey: ['revisions'] }),
qc.invalidateQueries({ queryKey: ['agents'] }),
qc.invalidateQueries({ queryKey: pendingKey }),
]);
onClose();
await navigate(`/revisions/${String(r.number)}`);
},
});
return (
<Modal
title={t('rollback.title', { number: revision })}
onClose={onClose}
footer={
<>
<button type="button" onClick={onClose}>
{t('common.cancel')}
</button>
<button
type="button"
className="danger"
disabled={rollback.isPending}
onClick={() => {
rollback.mutate();
}}
>
{t('rollback.submit')}
</button>
</>
}
>
<p>{t('rollback.confirm', { number: revision })}</p>
<p className="field-hint">{t('rollback.hint')}</p>
<Field label={t('revisions.comment')} hint={t('common.optional')}>
{(id) => (
<input
id={id}
value={comment}
maxLength={500}
placeholder={t('rollback.commentPlaceholder', { number: revision })}
onChange={(e) => {
setComment(e.target.value);
}}
/>
)}
</Field>
<ErrorBox error={rollback.error} />
</Modal>
);
}
+187 -5
View File
@@ -1,17 +1,92 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState, type SyntheticEvent } from 'react';
import { useCallback, useState, type SyntheticEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useParams } from 'react-router';
import { api, unwrap, type Schemas } from '../api/client';
import { useSession } from '../auth/context';
import { DiffView } from '../components/DiffView';
import { ErrorBox, Field, Notice, Spinner } from '../components/ui';
import { formatDateTime } from '../format';
import { pendingKey, usePendingChanges } from '../pending';
import { GeneratedFiles } from './ConfigPage';
import { DeploymentsTable } from './DeploymentsTable';
import { DeployDialog, RollbackDialog } from './RevisionActions';
type Revision = Schemas['Revision'];
type GeneratorProblem = Schemas['GeneratorProblem'];
function GeneratorError({ error }: { error: GeneratorProblem | undefined }) {
const { t } = useTranslation();
if (!error) return null;
return (
<div className="alert alert-error" role="alert">
{t(`generatorErrors.${error.code}`, { defaultValue: error.code, ...error.params })}
</div>
);
}
/** Changes since the latest revision: audit entries and the resulting file diffs. */
function PendingSection() {
const { t } = useTranslation();
const q = usePendingChanges();
return (
<section className="card">
<h2>{t('pending.title')}</h2>
{q.isPending && <Spinner />}
<ErrorBox error={q.error} />
{q.data && (
<>
<GeneratorError error={q.data.dns_error} />
<GeneratorError error={q.data.dhcp_error} />
{!q.data.changed ? (
<p>
{q.data.latest_revision > 0
? t('pending.none', { revision: q.data.latest_revision })
: t('pending.noneNoRevision')}
</p>
) : (
<>
<p className="field-hint">
{q.data.latest_revision > 0
? t('pending.hint', { revision: q.data.latest_revision })
: t('pending.hintNoRevision')}
</p>
{q.data.changes.length > 0 && (
<table className="table">
<thead>
<tr>
<th>{t('audit.at')}</th>
<th>{t('audit.actor')}</th>
<th>{t('audit.action')}</th>
<th>{t('audit.object')}</th>
</tr>
</thead>
<tbody>
{q.data.changes.map((e) => (
<tr key={e.id}>
<td>{formatDateTime(e.at)}</td>
<td>{e.actor_name}</td>
<td className="mono">{e.action}</td>
<td>
{e.object_type} {e.object_name}
</td>
</tr>
))}
</tbody>
</table>
)}
<h3>{t('pending.files')}</h3>
<DiffView files={q.data.files} emptyText={t('pending.noFiles')} />
</>
)}
</>
)}
</section>
);
}
function CreateRevision() {
const { t } = useTranslation();
@@ -33,6 +108,7 @@ function CreateRevision() {
await Promise.all([
qc.invalidateQueries({ queryKey: ['revisions'] }),
qc.invalidateQueries({ queryKey: ['agents'] }),
qc.invalidateQueries({ queryKey: pendingKey }),
]);
},
});
@@ -98,6 +174,7 @@ export function RevisionsPage() {
return (
<div className="page">
<h1>{t('revisions.title')}</h1>
<PendingSection />
{can('deploy') && <CreateRevision />}
{q.isPending && <Spinner />}
<ErrorBox error={q.error} />
@@ -145,14 +222,76 @@ export function RevisionsPage() {
);
}
export function RevisionDetailPage() {
/** Revision numbers for the comparison select and the newest one. */
function useRevisionIndex() {
return useQuery({
queryKey: ['revisions', 'index'],
queryFn: () => unwrap(api.GET('/revisions', { params: { query: { limit: 200 } } })),
});
}
function RevisionDiff({ number, others }: { number: number; others: number[] }) {
const { t } = useTranslation();
const number = Number(useParams().number);
const [against, setAgainst] = useState(number - 1);
const q = useQuery({
queryKey: ['revision-diff', number, against],
queryFn: () =>
unwrap(
api.GET('/revisions/{number}/diff', {
params: { path: { number }, query: { against } },
}),
),
// Revisions never change.
staleTime: Infinity,
});
const options = [...new Set([...others, ...(against > 0 ? [against] : [])])]
.filter((n) => n !== number)
.sort((a, b) => b - a);
return (
<section className="card">
<h2>{t('diff.title')}</h2>
<div className="filters">
<Field label={t('diff.against')}>
{(id) => (
<select
id={id}
value={against}
onChange={(e) => {
setAgainst(Number(e.target.value));
}}
>
{options.map((n) => (
<option key={n} value={n}>
{t('diff.revision', { number: n })}
</option>
))}
<option value={0}>{t('diff.emptyRevision')}</option>
</select>
)}
</Field>
</div>
{q.isPending && <Spinner />}
<ErrorBox error={q.error} />
{q.data && <DiffView files={q.data.files} />}
</section>
);
}
function RevisionDetail({ number }: { number: number }) {
const { t } = useTranslation();
const { can } = useSession();
const [dialog, setDialog] = useState<'deploy' | 'rollback' | null>(null);
const close = useCallback(() => {
setDialog(null);
}, []);
const q = useQuery({
queryKey: ['revisions', number],
queryFn: () => unwrap(api.GET('/revisions/{number}', { params: { path: { number } } })),
enabled: Number.isInteger(number) && number > 0,
});
const index = useRevisionIndex();
const numbers = (index.data?.items ?? []).map((r) => r.number);
const latest = numbers.length > 0 ? Math.max(...numbers) : undefined;
if (q.error) return <ErrorBox error={q.error} />;
if (q.isPending) return <Spinner />;
@@ -160,7 +299,33 @@ export function RevisionDetailPage() {
return (
<div className="page">
<h1>{t('revisions.detailTitle', { number: r.number })}</h1>
<div className="page-head">
<h1>{t('revisions.detailTitle', { number: r.number })}</h1>
{can('deploy') && (
<div className="actions">
<button
type="button"
className="primary"
onClick={() => {
setDialog('deploy');
}}
>
{t('deploy.button')}
</button>
{latest !== undefined && r.number < latest && (
<button
type="button"
className="danger"
onClick={() => {
setDialog('rollback');
}}
>
{t('rollback.button')}
</button>
)}
</div>
)}
</div>
<Link to="/revisions">{t('revisions.back')}</Link>
<section className="card">
<dl className="kv">
@@ -174,6 +339,12 @@ export function RevisionDetailPage() {
<dd>{r.comment}</dd>
</>
)}
{latest !== undefined && r.number === latest && (
<>
<dt>{t('revisions.status')}</dt>
<dd>{t('revisions.latest')}</dd>
</>
)}
</dl>
</section>
{r.warnings.length > 0 && (
@@ -190,8 +361,19 @@ export function RevisionDetailPage() {
<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} />
{dialog === 'deploy' && <DeployDialog revision={r.number} latest={latest} onClose={close} />}
{dialog === 'rollback' && <RollbackDialog revision={r.number} onClose={close} />}
</div>
);
}
export function RevisionDetailPage() {
const { t } = useTranslation();
const number = Number(useParams().number);
if (!Number.isInteger(number) || number < 1) return <p>{t('app.notFound')}</p>;
// Keyed: state (comparison, dialogs) starts fresh for another revision.
return <RevisionDetail key={number} number={number} />;
}
+21
View File
@@ -0,0 +1,21 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
import { useQuery } from '@tanstack/react-query';
import { api, unwrap } from './api/client';
import { useRefreshInterval } from './live';
export const pendingKey = ['config-pending'] as const;
/** Polling interval without live updates; generating the comparison costs server time. */
const offlinePollMs = 30_000;
/** Changes since the latest revision (audit entries and file diffs). */
export function usePendingChanges(enabled = true) {
const refetchInterval = useRefreshInterval(offlinePollMs);
return useQuery({
queryKey: pendingKey,
queryFn: () => unwrap(api.GET('/config/pending')),
refetchInterval,
enabled,
});
}
+6
View File
@@ -14,6 +14,8 @@
--banner-text: #5c3b00;
--banner-ro-bg: #fde2e2;
--banner-ro-text: #7a1010;
--diff-add-bg: #e6f6ea;
--diff-del-bg: #fdeaea;
color-scheme: light;
}
@@ -32,6 +34,8 @@
--banner-text: #ffd98a;
--banner-ro-bg: #3d1111;
--banner-ro-text: #ffb4b4;
--diff-add-bg: #12301c;
--diff-del-bg: #3a1515;
color-scheme: dark;
}
}
@@ -50,6 +54,8 @@
--banner-text: #ffd98a;
--banner-ro-bg: #3d1111;
--banner-ro-text: #ffb4b4;
--diff-add-bg: #12301c;
--diff-del-bg: #3a1515;
color-scheme: dark;
}