dorfteich/apps/web/src/pages/AdminSystemPage.tsx
Claude Fable 5 418aafd5ec
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 4m43s
CI / Build container images (pull_request) Successful in 1m11s
CI / Auth e2e pack (pull_request) Successful in 7m14s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
#163: Dokumentsprache und Seitentitel der SPA
i18n spiegelt die aktive Sprache auf <html lang> (Init + languageChanged;
der User-Locale-Wechsel in auth-context läuft über dasselbe Event). Neuer
useDocumentTitle-Hook setzt je Route einen sprechenden Titel
(Seite — Teich — Dorfteich), verdrahtet in allen Routen-Komponenten;
dynamische Titel folgen den geladenen Daten.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGM8jo3hwoV9wsCVGfy8iq
2026-07-21 13:52:59 +02:00

372 lines
12 KiB
TypeScript

import type {
AuditEntryView,
AuditListView,
JobTriggerOutcome,
JobTriggerResult,
StorageOverviewView,
SystemJobView,
} from '@dorfteich/shared';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { SettingsLayout } from '../components/SettingsLayout';
import { formatBytes } from '../files/file-format';
import { apiGet, apiPost } from '../lib/api';
import { BackupSection } from './AdminBackupSection';
import { useDocumentTitle } from '../lib/use-document-title';
/**
* Site-Admin "System" panel (issue #86): maintenance jobs with a manual
* trigger, the backup card fed by the sidecar's status.json, the audit-log
* viewer, and the per-pond storage top list — the operator's single glance
* for instance health (operations.md).
*/
export function AdminSystemPage(): React.JSX.Element {
const { t } = useTranslation('system');
useDocumentTitle(t('title'));
return (
<>
<h1>{t('title')}</h1>
<p>
<Link to="/admin"> {t('backLink')}</Link>
</p>
<SettingsLayout>
<JobsSection />
<BackupSection />
<AuditViewer />
<StorageSection />
</SettingsLayout>
</>
);
}
function cadenceParts(seconds: number): {
unit: 'days' | 'hours' | 'minutes' | 'seconds';
count: number;
} {
if (seconds % 86_400 === 0) return { unit: 'days', count: seconds / 86_400 };
if (seconds % 3_600 === 0) return { unit: 'hours', count: seconds / 3_600 };
if (seconds % 60 === 0) return { unit: 'minutes', count: seconds / 60 };
return { unit: 'seconds', count: seconds };
}
function durationLabel(ms: number): string {
if (ms < 1000) return `${ms} ms`;
return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)} s`;
}
function JobsSection(): React.JSX.Element {
const { t } = useTranslation('system');
const [notice, setNotice] = useState<{ job: string; outcome: JobTriggerOutcome } | null>(null);
const [pending, setPending] = useState<string | null>(null);
const query = useQuery({
queryKey: ['admin', 'system', 'jobs'],
queryFn: () => apiGet<SystemJobView[]>('/admin/system/jobs'),
});
const trigger = async (name: string): Promise<void> => {
setPending(name);
setNotice(null);
try {
const result = await apiPost<JobTriggerResult>(`/admin/system/jobs/${name}/run`, {});
setNotice({ job: name, outcome: result.outcome });
} finally {
setPending(null);
await query.refetch();
}
};
return (
<section className="settings-section">
<h2>{t('jobs.title')}</h2>
{notice && (
<p role="status" className="system-jobs__notice">
{t(`jobs.triggered.${notice.outcome}`)}
</p>
)}
<table className="table system-jobs__table">
<thead>
<tr>
<th>{t('jobs.columns.job')}</th>
<th>{t('jobs.columns.cadence')}</th>
<th>{t('jobs.columns.lastRun')}</th>
<th>{t('jobs.columns.duration')}</th>
<th>{t('jobs.columns.outcome')}</th>
<th>{t('jobs.columns.actions')}</th>
</tr>
</thead>
<tbody>
{(query.data ?? []).map((job) => (
<tr key={job.name}>
<td>
{t(`jobs.names.${job.name}`, { defaultValue: job.name })}
{!job.registered && (
<span className="system-badge system-badge--warn">{t('jobs.unregistered')}</span>
)}
</td>
<td>
{(() => {
const { unit, count } = cadenceParts(job.cadenceSeconds);
return t(`jobs.cadence.${unit}`, { count });
})()}
</td>
<td>{job.lastRunAt ? new Date(job.lastRunAt).toLocaleString() : '—'}</td>
<td>{job.lastDurationMs !== null ? durationLabel(job.lastDurationMs) : '—'}</td>
<td>
<JobOutcome job={job} />
</td>
<td>
{job.registered && (
<button
type="button"
className="button"
disabled={pending !== null || job.status === 'RUNNING'}
onClick={() => void trigger(job.name)}
>
{pending === job.name ? t('jobs.runPending') : t('jobs.run')}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</section>
);
}
function JobOutcome({ job }: { job: SystemJobView }): React.JSX.Element {
const { t } = useTranslation('system');
if (job.status === 'RUNNING')
return <span className="system-badge">{t('jobs.outcome.running')}</span>;
if (job.status === 'FAILED')
return (
<span className="system-badge system-badge--error" title={job.lastError ?? undefined}>
{t('jobs.outcome.failed')}
</span>
);
if (!job.lastRunAt) return <span className="system-badge">{t('jobs.outcome.never')}</span>;
return <span className="system-badge system-badge--ok">{t('jobs.outcome.ok')}</span>;
}
interface AuditFilter {
actor: string;
action: string;
from: string;
to: string;
}
const EMPTY_FILTER: AuditFilter = { actor: '', action: '', from: '', to: '' };
function AuditViewer(): React.JSX.Element {
const { t } = useTranslation('system');
const [draft, setDraft] = useState<AuditFilter>(EMPTY_FILTER);
const [filter, setFilter] = useState<AuditFilter>(EMPTY_FILTER);
const [page, setPage] = useState(1);
const query = useQuery({
queryKey: ['admin', 'system', 'audit', filter, page],
queryFn: () => {
const params = new URLSearchParams({ page: String(page) });
if (filter.actor) params.set('actor', filter.actor);
if (filter.action) params.set('action', filter.action);
if (filter.from) params.set('from', new Date(filter.from).toISOString());
if (filter.to) params.set('to', new Date(filter.to).toISOString());
return apiGet<AuditListView>(`/admin/system/audit?${params.toString()}`);
},
placeholderData: keepPreviousData,
});
const data = query.data;
return (
<section className="settings-section system-audit">
<h2>{t('audit.title')}</h2>
<form
className="system-audit__filters"
onSubmit={(e) => {
e.preventDefault();
setPage(1);
setFilter(draft);
}}
>
<input
type="text"
value={draft.actor}
placeholder={t('audit.filters.actor')}
aria-label={t('audit.filters.actor')}
onChange={(e) => setDraft({ ...draft, actor: e.target.value })}
/>
<select
value={draft.action}
aria-label={t('audit.filters.action')}
onChange={(e) => setDraft({ ...draft, action: e.target.value })}
>
<option value="">{t('audit.filters.actionAny')}</option>
{KNOWN_ACTIONS.map((action) => (
<option key={action} value={action}>
{t(`audit.actions.${action}`, { defaultValue: action })}
</option>
))}
</select>
<input
type="datetime-local"
value={draft.from}
aria-label={t('audit.filters.from')}
onChange={(e) => setDraft({ ...draft, from: e.target.value })}
/>
<input
type="datetime-local"
value={draft.to}
aria-label={t('audit.filters.to')}
onChange={(e) => setDraft({ ...draft, to: e.target.value })}
/>
<button type="submit" className="button">
{t('audit.filters.apply')}
</button>
</form>
{data && data.entries.length === 0 && <p>{t('audit.empty')}</p>}
{data && data.entries.length > 0 && (
<table className="table system-audit__table">
<thead>
<tr>
<th>{t('audit.columns.time')}</th>
<th>{t('audit.columns.actor')}</th>
<th>{t('audit.columns.action')}</th>
<th>{t('audit.columns.target')}</th>
<th>{t('audit.columns.details')}</th>
</tr>
</thead>
<tbody>
{data.entries.map((entry) => (
<AuditRow key={entry.id} entry={entry} />
))}
</tbody>
</table>
)}
{data && (
<div className="system-audit__pager">
<button
type="button"
className="button"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
{t('audit.previous')}
</button>
<span>
{t('audit.pager', { page: data.page, pageCount: data.pageCount, total: data.total })}
</span>
<button
type="button"
className="button"
disabled={page >= data.pageCount}
onClick={() => setPage((p) => p + 1)}
>
{t('audit.next')}
</button>
</div>
)}
</section>
);
}
const KNOWN_ACTIONS = [
'grant.created',
'grant.deleted',
'member.added',
'member.role_changed',
'member.removed',
'user.disabled_set',
'user.verification_resent',
'user.deleted',
'user.site_admin_set',
'user.pseudonymized',
'quota.override_set',
'quota.override_cleared',
'plugin.installed',
'plugin.mode_set',
'plugin.uninstalled',
'plugin.pond_toggled',
'settings.changed',
'setup.preseeded',
'setup.admin_created',
'setup.smtp_stored',
'setup.completed',
'auth.signup',
'auth.email_verified',
'auth.login_failed',
'auth.login_succeeded',
'auth.password_reset',
'job.triggered',
'backup.settings_changed',
'backup.run_triggered',
'backup.restore_requested',
'api.token_created',
'api.token_revoked',
'api.write',
];
function AuditRow({ entry }: { entry: AuditEntryView }): React.JSX.Element {
const { t } = useTranslation('system');
return (
<tr>
<td>{new Date(entry.at).toLocaleString()}</td>
<td>{entry.actor ? entry.actor.displayName : t('audit.systemActor')}</td>
<td>{t(`audit.actions.${entry.action}`, { defaultValue: entry.action })}</td>
<td>
{entry.targetType && (
<code>
{entry.targetType}:{entry.targetId}
</code>
)}
</td>
<td>{entry.details && <code>{JSON.stringify(entry.details)}</code>}</td>
</tr>
);
}
function StorageSection(): React.JSX.Element {
const { t } = useTranslation('system');
const query = useQuery({
queryKey: ['admin', 'system', 'storage'],
queryFn: () => apiGet<StorageOverviewView>('/admin/system/storage'),
});
const view = query.data;
if (!view) return <></>;
return (
<section className="settings-section system-storage">
<h2>{t('storage.title')}</h2>
<p>{t('storage.total', { total: formatBytes(view.totalBytes) })}</p>
{view.ponds.length === 0 && <p>{t('storage.empty')}</p>}
{view.ponds.length > 0 && (
<table className="table system-storage__table">
<thead>
<tr>
<th>{t('storage.columns.pond')}</th>
<th>{t('storage.columns.type')}</th>
<th>{t('storage.columns.used')}</th>
</tr>
</thead>
<tbody>
{view.ponds.map((pond) => (
<tr key={pond.pondId}>
<td>
<Link to={`/p/${pond.slug}`}>{pond.name}</Link>
</td>
<td>{t(`storage.types.${pond.type}`)}</td>
<td>{formatBytes(pond.storageBytesUsed)}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
);
}