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
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
372 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|