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 (
<>
{t('title')}
← {t('backLink')}
>
);
}
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(null);
const query = useQuery({
queryKey: ['admin', 'system', 'jobs'],
queryFn: () => apiGet('/admin/system/jobs'),
});
const trigger = async (name: string): Promise => {
setPending(name);
setNotice(null);
try {
const result = await apiPost(`/admin/system/jobs/${name}/run`, {});
setNotice({ job: name, outcome: result.outcome });
} finally {
setPending(null);
await query.refetch();
}
};
return (
{t('jobs.title')}
{notice && (
{t(`jobs.triggered.${notice.outcome}`)}
)}
{t('jobs.columns.job')}
{t('jobs.columns.cadence')}
{t('jobs.columns.lastRun')}
{t('jobs.columns.duration')}
{t('jobs.columns.outcome')}
{t('jobs.columns.actions')}
{(query.data ?? []).map((job) => (
{t(`jobs.names.${job.name}`, { defaultValue: job.name })}
{!job.registered && (
{t('jobs.unregistered')}
)}
{(() => {
const { unit, count } = cadenceParts(job.cadenceSeconds);
return t(`jobs.cadence.${unit}`, { count });
})()}
{job.lastRunAt ? new Date(job.lastRunAt).toLocaleString() : '—'}
{job.lastDurationMs !== null ? durationLabel(job.lastDurationMs) : '—'}
{job.registered && (
void trigger(job.name)}
>
{pending === job.name ? t('jobs.runPending') : t('jobs.run')}
)}
))}
);
}
function JobOutcome({ job }: { job: SystemJobView }): React.JSX.Element {
const { t } = useTranslation('system');
if (job.status === 'RUNNING')
return {t('jobs.outcome.running')} ;
if (job.status === 'FAILED')
return (
{t('jobs.outcome.failed')}
);
if (!job.lastRunAt) return {t('jobs.outcome.never')} ;
return {t('jobs.outcome.ok')} ;
}
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(EMPTY_FILTER);
const [filter, setFilter] = useState(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(`/admin/system/audit?${params.toString()}`);
},
placeholderData: keepPreviousData,
});
const data = query.data;
return (
{t('audit.title')}
{data && data.entries.length === 0 && {t('audit.empty')}
}
{data && data.entries.length > 0 && (
{t('audit.columns.time')}
{t('audit.columns.actor')}
{t('audit.columns.action')}
{t('audit.columns.target')}
{t('audit.columns.details')}
{data.entries.map((entry) => (
))}
)}
{data && (
setPage((p) => p - 1)}
>
{t('audit.previous')}
{t('audit.pager', { page: data.page, pageCount: data.pageCount, total: data.total })}
= data.pageCount}
onClick={() => setPage((p) => p + 1)}
>
{t('audit.next')}
)}
);
}
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 (
{new Date(entry.at).toLocaleString()}
{entry.actor ? entry.actor.displayName : t('audit.systemActor')}
{t(`audit.actions.${entry.action}`, { defaultValue: entry.action })}
{entry.targetType && (
{entry.targetType}:{entry.targetId}
)}
{entry.details && {JSON.stringify(entry.details)}}
);
}
function StorageSection(): React.JSX.Element {
const { t } = useTranslation('system');
const query = useQuery({
queryKey: ['admin', 'system', 'storage'],
queryFn: () => apiGet('/admin/system/storage'),
});
const view = query.data;
if (!view) return <>>;
return (
{t('storage.title')}
{t('storage.total', { total: formatBytes(view.totalBytes) })}
{view.ponds.length === 0 && {t('storage.empty')}
}
{view.ponds.length > 0 && (
{t('storage.columns.pond')}
{t('storage.columns.type')}
{t('storage.columns.used')}
{view.ponds.map((pond) => (
{pond.name}
{t(`storage.types.${pond.type}`)}
{formatBytes(pond.storageBytesUsed)}
))}
)}
);
}