backup and restore

This commit is contained in:
2026-03-15 15:21:27 -05:00
parent e7cfff3eca
commit f858fe4785
16 changed files with 472 additions and 69 deletions

View File

@@ -4,6 +4,7 @@ import type {
AdminPermissionOptionDto,
AdminRoleDto,
AdminRoleInput,
SupportLogEntryDto,
SupportSnapshotDto,
AdminUserDto,
AdminUserInput,
@@ -144,6 +145,9 @@ export const api = {
getSupportSnapshot(token: string) {
return request<SupportSnapshotDto>("/api/v1/admin/support-snapshot", undefined, token);
},
getSupportLogs(token: string) {
return request<SupportLogEntryDto[]>("/api/v1/admin/support-logs", undefined, token);
},
getAdminPermissions(token: string) {
return request<AdminPermissionOptionDto[]>("/api/v1/admin/permissions", undefined, token);
},

View File

@@ -1,4 +1,4 @@
import type { AdminDiagnosticsDto, BackupGuidanceDto } from "@mrp/shared";
import type { AdminDiagnosticsDto, BackupGuidanceDto, SupportLogEntryDto } from "@mrp/shared";
import { Link } from "react-router-dom";
import { useEffect, useState } from "react";
@@ -21,6 +21,7 @@ export function AdminDiagnosticsPage() {
const { token } = useAuth();
const [diagnostics, setDiagnostics] = useState<AdminDiagnosticsDto | null>(null);
const [backupGuidance, setBackupGuidance] = useState<BackupGuidanceDto | null>(null);
const [supportLogs, setSupportLogs] = useState<SupportLogEntryDto[]>([]);
const [status, setStatus] = useState("Loading diagnostics...");
useEffect(() => {
@@ -30,13 +31,14 @@ export function AdminDiagnosticsPage() {
let active = true;
Promise.all([api.getAdminDiagnostics(token), api.getBackupGuidance(token)])
.then(([nextDiagnostics, nextBackupGuidance]) => {
Promise.all([api.getAdminDiagnostics(token), api.getBackupGuidance(token), api.getSupportLogs(token)])
.then(([nextDiagnostics, nextBackupGuidance, nextSupportLogs]) => {
if (!active) {
return;
}
setDiagnostics(nextDiagnostics);
setBackupGuidance(nextBackupGuidance);
setSupportLogs(nextSupportLogs);
setStatus("Diagnostics loaded.");
})
.catch((error: Error) => {
@@ -71,10 +73,28 @@ export function AdminDiagnosticsPage() {
setStatus("Support snapshot exported.");
}
async function handleExportSupportLogs() {
if (!token) {
return;
}
const logs = await api.getSupportLogs(token);
const blob = new Blob([JSON.stringify(logs, null, 2)], { type: "application/json" });
const objectUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = objectUrl;
link.download = `mrp-codex-support-logs-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
link.click();
window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 60_000);
setSupportLogs(logs);
setStatus("Support logs exported.");
}
const summaryCards = [
["Server time", formatDateTime(diagnostics.serverTime)],
["Node runtime", diagnostics.nodeVersion],
["Audit events", diagnostics.auditEventCount.toString()],
["Support logs", diagnostics.supportLogCount.toString()],
["Active users", `${diagnostics.activeUserCount} / ${diagnostics.userCount}`],
["Sales docs", diagnostics.salesDocumentCount.toString()],
["Work orders", diagnostics.workOrderCount.toString()],
@@ -96,12 +116,18 @@ export function AdminDiagnosticsPage() {
];
const startupStatusTone =
diagnostics.startupStatus === "PASS"
diagnostics.startup.status === "PASS"
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-200"
: diagnostics.startupStatus === "WARN"
: diagnostics.startup.status === "WARN"
? "bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-200"
: "bg-rose-100 text-rose-800 dark:bg-rose-500/15 dark:text-rose-200";
const startupSummaryCards = [
["Generated", formatDateTime(diagnostics.startup.generatedAt)],
["Duration", `${diagnostics.startup.durationMs} ms`],
["Pass / Warn / Fail", `${diagnostics.startup.passCount} / ${diagnostics.startup.warnCount} / ${diagnostics.startup.failCount}`],
];
return (
<div className="space-y-6">
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
@@ -119,7 +145,14 @@ export function AdminDiagnosticsPage() {
onClick={handleExportSupportSnapshot}
className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text"
>
Export support snapshot
Export support bundle
</button>
<button
type="button"
onClick={handleExportSupportLogs}
className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text"
>
Export support logs
</button>
<Link to="/settings/users" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
User management
@@ -178,6 +211,32 @@ export function AdminDiagnosticsPage() {
</div>
</div>
</div>
<div className="mt-5 grid gap-4 xl:grid-cols-2">
<div className="rounded-2xl border border-line/70 bg-page/70 p-4">
<p className="text-sm font-semibold text-text">Backup verification checklist</p>
<div className="mt-3 space-y-3">
{backupGuidance.verificationChecklist.map((item) => (
<div key={item.id}>
<p className="text-sm font-semibold text-text">{item.label}</p>
<p className="mt-1 text-sm text-muted">{item.detail}</p>
<p className="mt-1 text-xs text-muted">Evidence: {item.evidence}</p>
</div>
))}
</div>
</div>
<div className="rounded-2xl border border-line/70 bg-page/70 p-4">
<p className="text-sm font-semibold text-text">Restore drill runbook</p>
<div className="mt-3 space-y-3">
{backupGuidance.restoreDrillSteps.map((step) => (
<div key={step.id}>
<p className="text-sm font-semibold text-text">{step.label}</p>
<p className="mt-1 text-sm text-muted">{step.detail}</p>
<p className="mt-1 text-xs text-muted">Expected outcome: {step.expectedOutcome}</p>
</div>
))}
</div>
</div>
</div>
</section>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
@@ -187,11 +246,11 @@ export function AdminDiagnosticsPage() {
<h3 className="mt-2 text-lg font-bold text-text">Boot-time readiness checks</h3>
</div>
<span className={`inline-flex rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] ${startupStatusTone}`}>
{diagnostics.startupStatus}
{diagnostics.startup.status}
</span>
</div>
<div className="mt-5 grid gap-3 xl:grid-cols-2">
{diagnostics.startupChecks.map((check) => (
{diagnostics.startup.checks.map((check) => (
<div key={check.id} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-semibold text-text">{check.label}</p>
@@ -201,6 +260,14 @@ export function AdminDiagnosticsPage() {
</div>
))}
</div>
<div className="mt-5 grid gap-3 lg:grid-cols-3">
{startupSummaryCards.map(([label, value]) => (
<div key={label} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{label}</p>
<p className="mt-2 text-sm text-text">{value}</p>
</div>
))}
</div>
</section>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
@@ -215,6 +282,49 @@ export function AdminDiagnosticsPage() {
</div>
</section>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Support Logs</p>
<h3 className="mt-2 text-lg font-bold text-text">Recent runtime warnings and failures</h3>
</div>
<p className="text-sm text-muted">{supportLogs.length} entries loaded</p>
</div>
<div className="mt-5 overflow-x-auto">
<table className="min-w-full divide-y divide-line/70 text-sm">
<thead>
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
<th className="px-3 py-3">When</th>
<th className="px-3 py-3">Level</th>
<th className="px-3 py-3">Source</th>
<th className="px-3 py-3">Message</th>
<th className="px-3 py-3">Context</th>
</tr>
</thead>
<tbody className="divide-y divide-line/70">
{supportLogs.map((entry) => {
const context = parseMetadata(entry.contextJson);
return (
<tr key={entry.id} className="align-top">
<td className="px-3 py-3 text-muted">{formatDateTime(entry.createdAt)}</td>
<td className="px-3 py-3">
<span className="rounded-full bg-page px-2 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-text">
{entry.level}
</span>
</td>
<td className="px-3 py-3 text-text">{entry.source}</td>
<td className="px-3 py-3 text-text">{entry.message}</td>
<td className="px-3 py-3 text-xs text-muted">
{Object.keys(context).length > 0 ? JSON.stringify(context) : "No context"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
<div className="flex items-center justify-between gap-3">
<div>