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

)} {(query.data ?? []).map((job) => ( ))}
{t('jobs.columns.job')} {t('jobs.columns.cadence')} {t('jobs.columns.lastRun')} {t('jobs.columns.duration')} {t('jobs.columns.outcome')} {t('jobs.columns.actions')}
{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 && ( )}
); } 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')}

{ e.preventDefault(); setPage(1); setFilter(draft); }} > setDraft({ ...draft, actor: e.target.value })} /> setDraft({ ...draft, from: e.target.value })} /> setDraft({ ...draft, to: e.target.value })} />
{data && data.entries.length === 0 &&

{t('audit.empty')}

} {data && data.entries.length > 0 && ( {data.entries.map((entry) => ( ))}
{t('audit.columns.time')} {t('audit.columns.actor')} {t('audit.columns.action')} {t('audit.columns.target')} {t('audit.columns.details')}
)} {data && (
{t('audit.pager', { page: data.page, pageCount: data.pageCount, total: data.total })}
)}
); } 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 && ( {view.ponds.map((pond) => ( ))}
{t('storage.columns.pond')} {t('storage.columns.type')} {t('storage.columns.used')}
{pond.name} {t(`storage.types.${pond.type}`)} {formatBytes(pond.storageBytesUsed)}
)}
); }