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

@@ -26,7 +26,9 @@ MRP Codex is a modular Manufacturing Resource Planning platform intended to be a
- admin diagnostics with runtime footprint, record counts, and persisted audit-trail visibility - admin diagnostics with runtime footprint, record counts, and persisted audit-trail visibility
- admin user management with account creation, activation, role assignment, and role-permission editing - admin user management with account creation, activation, role assignment, and role-permission editing
- CRM/shipping audit coverage and startup validation surfaced through the admin diagnostics workflow - CRM/shipping audit coverage and startup validation surfaced through the admin diagnostics workflow
- backup/restore guidance and exportable support snapshots in the admin diagnostics workflow - backup/restore guidance, richer startup diagnostics, and exportable support bundles in the admin diagnostics workflow
- backup verification checklist and restore-drill runbook in the admin diagnostics workflow
- support-log viewing and support debugging helpers in the admin diagnostics workflow
- Puppeteer PDF foundation - Puppeteer PDF foundation
- single-container Docker deployment - single-container Docker deployment
@@ -123,8 +125,8 @@ If implementation changes invalidate those docs, update them in the same change
Near-term priorities are: Near-term priorities are:
1. Backup verification checklist and restore drill guidance 1. Better user and session visibility for operational admins
2. Deeper startup diagnostics and support export helpers 2. Safer destructive-action confirmations and recovery messaging
When adding new modules, preserve the ability to extend the system without refactoring the existing app shell. When adding new modules, preserve the ability to extend the system without refactoring the existing app shell.

View File

@@ -6,6 +6,10 @@ This file is the running release and change log for MRP Codex. Keep it updated w
### Added ### Added
- Support-log capture for startup warnings, HTTP failures, and server errors, surfaced through admin diagnostics
- Exportable support bundles that now include backup guidance and recent support logs for support handoff
- Deeper startup diagnostics with writable-path checks, database-file validation, startup timing, and pass/warn/fail rollups
- Backup verification checklist and restore-drill runbook surfaced in admin diagnostics
- Backup/restore guidance surfaced in admin diagnostics with exportable support snapshot JSON for support handoff - Backup/restore guidance surfaced in admin diagnostics with exportable support snapshot JSON for support handoff
- CRM customer/vendor changes and shipping mutations now feed the shared audit trail - CRM customer/vendor changes and shipping mutations now feed the shared audit trail
- Startup validation now runs during server boot and surfaces readiness checks in admin diagnostics - Startup validation now runs during server boot and surfaces readiness checks in admin diagnostics
@@ -47,7 +51,9 @@ This file is the running release and change log for MRP Codex. Keep it updated w
- The client entry bundle now stays lighter by loading major modules on demand instead of importing all route pages eagerly in `main.tsx` - The client entry bundle now stays lighter by loading major modules on demand instead of importing all route pages eagerly in `main.tsx`
- Company settings now acts as the staging area for admin surfaces while user administration lives on its own dedicated page instead of inside the company-profile form - Company settings now acts as the staging area for admin surfaces while user administration lives on its own dedicated page instead of inside the company-profile form
- Admin diagnostics now includes startup-readiness status alongside runtime footprint and recent audit activity - Admin diagnostics now includes startup-readiness status alongside runtime footprint and recent audit activity
- Admin diagnostics now includes structured startup summaries and a dedicated support-log view for faster debugging
- Roadmap and project docs now treat backup verification checklist and restore drill guidance as the next active priority after the backup/support-tooling slice - Roadmap and project docs now treat backup verification checklist and restore drill guidance as the next active priority after the backup/support-tooling slice
- Roadmap and project docs now treat user/session visibility and destructive-action safety as the next active priorities after the diagnostics/support-debugging slices
## 2026-03-15 ## 2026-03-15

View File

@@ -30,7 +30,9 @@ This repository implements the platform foundation milestone:
- admin diagnostics with runtime footprint, storage visibility, record counts, and recent audit activity - admin diagnostics with runtime footprint, storage visibility, record counts, and recent audit activity
- admin user management with account creation, activation, role assignment, and role-permission editing - admin user management with account creation, activation, role assignment, and role-permission editing
- CRM/shipping audit coverage and startup validation surfaced through diagnostics - CRM/shipping audit coverage and startup validation surfaced through diagnostics
- backup/restore guidance and exportable support snapshots in diagnostics - backup/restore guidance, richer startup diagnostics, and exportable support bundles in diagnostics
- backup verification checklist and restore-drill runbook in diagnostics
- support-log viewing and support debugging helpers in diagnostics
- Dockerized single-container deployment - Dockerized single-container deployment
- Puppeteer PDF pipeline foundation - Puppeteer PDF pipeline foundation
@@ -66,5 +68,5 @@ This repository implements the platform foundation milestone:
## Next roadmap candidates ## Next roadmap candidates
- backup verification checklist and restore drill guidance - better user and session visibility for operational admins
- deeper startup diagnostics and support export helpers - safer destructive-action confirmations and recovery messaging

View File

@@ -29,7 +29,9 @@ Current foundation scope includes:
- admin diagnostics with runtime footprint, record counts, and recent audit-trail visibility - admin diagnostics with runtime footprint, record counts, and recent audit-trail visibility
- admin user management with account creation, activation, role assignment, and role-permission editing - admin user management with account creation, activation, role assignment, and role-permission editing
- CRM and shipping audit coverage plus startup validation surfaced through the admin diagnostics page - CRM and shipping audit coverage plus startup validation surfaced through the admin diagnostics page
- backup/restore guidance and exportable support snapshots in the admin diagnostics workflow - backup/restore guidance, richer startup diagnostics, and exportable support bundles in the admin diagnostics workflow
- backup verification checklist and restore-drill runbook surfaced in admin diagnostics
- support-log viewing and support debugging helpers in admin diagnostics
- route-level code-splitting and vendor chunking for lighter initial client loads - route-level code-splitting and vendor chunking for lighter initial client loads
- file storage and PDF rendering - file storage and PDF rendering
@@ -51,14 +53,14 @@ Current completed foundation areas:
Near-term priorities: Near-term priorities:
1. Backup verification checklist and restore drill guidance 1. Better user and session visibility for operational admins
2. Deeper startup diagnostics and support export helpers 2. Safer destructive-action confirmations and recovery messaging
Revisit / deferred items: Revisit / deferred items:
- local Windows Prisma migration reliability - local Windows Prisma migration reliability
- backup verification checklist and restore drill depth - better user and session visibility for operational admins
- deeper startup diagnostics and support export helpers - safer destructive-action confirmations and recovery messaging
Dashboard direction: Dashboard direction:
@@ -351,14 +353,16 @@ The current admin operations slice supports:
- an admin diagnostics page for runtime footprint, data/storage path visibility, key record counts, and recent audit activity - an admin diagnostics page for runtime footprint, data/storage path visibility, key record counts, and recent audit activity
- a dedicated user-management page for account creation, activation, role assignment, password reset-style updates, and role-permission administration - a dedicated user-management page for account creation, activation, role assignment, password reset-style updates, and role-permission administration
- CRM customer/vendor changes and shipping mutations now flow into the shared audit trail - CRM customer/vendor changes and shipping mutations now flow into the shared audit trail
- startup validation now checks storage paths, database connectivity, client bundle readiness, Chromium availability, and risky production defaults during server boot - startup validation now checks storage paths, writable storage readiness, database connectivity, client bundle readiness, Chromium availability, and risky production defaults during server boot
- backup and restore guidance now surfaces directly in diagnostics, along with exportable support snapshot JSON for support handoff - backup and restore guidance now surfaces directly in diagnostics, along with exportable support bundles for support handoff
- support logs now capture startup warnings, HTTP failures, and server errors for admin-side debugging review
- backup verification items and restore-drill expected outcomes now live in the admin runbook surface
- operator-facing review of recent high-impact changes without direct database access - operator-facing review of recent high-impact changes without direct database access
Current follow-up direction: Current follow-up direction:
- backup verification checklist and restore drill guidance - better user and session visibility for operational admins
- deeper startup diagnostics and support export helpers - safer destructive-action confirmations and recovery messaging
## UI Notes ## UI Notes

View File

@@ -51,8 +51,10 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni
- Admin diagnostics screen with runtime footprint, record counts, storage-path visibility, and recent audit activity - Admin diagnostics screen with runtime footprint, record counts, storage-path visibility, and recent audit activity
- Dedicated user-management screen for account creation, activation, role assignment, and role-permission editing - Dedicated user-management screen for account creation, activation, role assignment, and role-permission editing
- CRM customer/vendor changes and shipping mutations covered by the shared audit trail - CRM customer/vendor changes and shipping mutations covered by the shared audit trail
- Startup validation during server boot with checks for storage paths, database connectivity, client bundle readiness, Chromium availability, and risky production defaults - Startup validation during server boot with checks for storage paths, writable directories, database connectivity, client bundle readiness, Chromium availability, and risky production defaults
- Backup/restore guidance and exportable support snapshots surfaced through the admin diagnostics workflow - Backup/restore guidance and exportable support bundles surfaced through the admin diagnostics workflow
- Backup verification checklist and restore-drill runbook surfaced through the admin diagnostics workflow
- Support-log viewing for startup warnings, HTTP failures, and server errors surfaced through the admin diagnostics workflow
- Route-level frontend code-splitting and vendor chunking to keep the initial client payload lighter - Route-level frontend code-splitting and vendor chunking to keep the initial client payload lighter
- SKU-searchable BOM component selection for inventory-scale datasets - SKU-searchable BOM component selection for inventory-scale datasets
- Theme persistence fixes and denser responsive workspace layouts - Theme persistence fixes and denser responsive workspace layouts
@@ -259,7 +261,8 @@ Foundation slice shipped:
- Admin diagnostics screen for runtime footprint, storage visibility, key record counts, and recent audit activity - Admin diagnostics screen for runtime footprint, storage visibility, key record counts, and recent audit activity
- Expanded role-management UI with account creation, activation, role assignment, and permission administration - Expanded role-management UI with account creation, activation, role assignment, and permission administration
- CRM customer/vendor changes and shipping mutations covered by the shared audit trail - CRM customer/vendor changes and shipping mutations covered by the shared audit trail
- Startup validation during server boot with checks for storage paths, database connectivity, client bundle readiness, Chromium availability, and risky production defaults - Startup validation during server boot with checks for storage paths, writable directories, database connectivity, client bundle readiness, Chromium availability, and risky production defaults
- Backup/restore guidance, support-bundle exports, and support-log viewing surfaced through the admin diagnostics workflow
- Expanded role management UI - Expanded role management UI
- Permission assignment administration - Permission assignment administration
@@ -273,7 +276,7 @@ QOL subfeatures:
- Safer destructive-action confirmations and recovery messaging - Safer destructive-action confirmations and recovery messaging
- Better user/session visibility for operational admins - Better user/session visibility for operational admins
- More explicit environment validation on startup - More explicit environment validation on startup
- Log-view and export helpers for support/debugging - Support-log filtering, retention controls, and broader support-package polish
- Backup verification checklist and restore drill guidance - Backup verification checklist and restore drill guidance
## Revisit / Deferred Items ## Revisit / Deferred Items
@@ -294,5 +297,5 @@ QOL subfeatures:
## Near-term priority order ## Near-term priority order
1. Backup verification checklist and restore drill guidance 1. Better user and session visibility for operational admins
2. Deeper startup diagnostics and support export helpers 2. Safer destructive-action confirmations and recovery messaging

View File

@@ -36,7 +36,7 @@
- Organize domain modules under `src/modules/<domain>`. - Organize domain modules under `src/modules/<domain>`.
- Keep HTTP routers thin; place business logic in services. - Keep HTTP routers thin; place business logic in services.
- Centralize Prisma access, auth middleware, and file storage utilities in `src/lib`. - Centralize Prisma access, auth middleware, file storage utilities, startup validation, and support logging in `src/lib`.
- Store persistence-related constants under `src/config`. - Store persistence-related constants under `src/config`.
- Serve the built frontend from the API layer in production. - Serve the built frontend from the API layer in production.

View File

@@ -4,6 +4,7 @@ import type {
AdminPermissionOptionDto, AdminPermissionOptionDto,
AdminRoleDto, AdminRoleDto,
AdminRoleInput, AdminRoleInput,
SupportLogEntryDto,
SupportSnapshotDto, SupportSnapshotDto,
AdminUserDto, AdminUserDto,
AdminUserInput, AdminUserInput,
@@ -144,6 +145,9 @@ export const api = {
getSupportSnapshot(token: string) { getSupportSnapshot(token: string) {
return request<SupportSnapshotDto>("/api/v1/admin/support-snapshot", undefined, token); 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) { getAdminPermissions(token: string) {
return request<AdminPermissionOptionDto[]>("/api/v1/admin/permissions", undefined, token); 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 { Link } from "react-router-dom";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -21,6 +21,7 @@ export function AdminDiagnosticsPage() {
const { token } = useAuth(); const { token } = useAuth();
const [diagnostics, setDiagnostics] = useState<AdminDiagnosticsDto | null>(null); const [diagnostics, setDiagnostics] = useState<AdminDiagnosticsDto | null>(null);
const [backupGuidance, setBackupGuidance] = useState<BackupGuidanceDto | null>(null); const [backupGuidance, setBackupGuidance] = useState<BackupGuidanceDto | null>(null);
const [supportLogs, setSupportLogs] = useState<SupportLogEntryDto[]>([]);
const [status, setStatus] = useState("Loading diagnostics..."); const [status, setStatus] = useState("Loading diagnostics...");
useEffect(() => { useEffect(() => {
@@ -30,13 +31,14 @@ export function AdminDiagnosticsPage() {
let active = true; let active = true;
Promise.all([api.getAdminDiagnostics(token), api.getBackupGuidance(token)]) Promise.all([api.getAdminDiagnostics(token), api.getBackupGuidance(token), api.getSupportLogs(token)])
.then(([nextDiagnostics, nextBackupGuidance]) => { .then(([nextDiagnostics, nextBackupGuidance, nextSupportLogs]) => {
if (!active) { if (!active) {
return; return;
} }
setDiagnostics(nextDiagnostics); setDiagnostics(nextDiagnostics);
setBackupGuidance(nextBackupGuidance); setBackupGuidance(nextBackupGuidance);
setSupportLogs(nextSupportLogs);
setStatus("Diagnostics loaded."); setStatus("Diagnostics loaded.");
}) })
.catch((error: Error) => { .catch((error: Error) => {
@@ -71,10 +73,28 @@ export function AdminDiagnosticsPage() {
setStatus("Support snapshot exported."); 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 = [ const summaryCards = [
["Server time", formatDateTime(diagnostics.serverTime)], ["Server time", formatDateTime(diagnostics.serverTime)],
["Node runtime", diagnostics.nodeVersion], ["Node runtime", diagnostics.nodeVersion],
["Audit events", diagnostics.auditEventCount.toString()], ["Audit events", diagnostics.auditEventCount.toString()],
["Support logs", diagnostics.supportLogCount.toString()],
["Active users", `${diagnostics.activeUserCount} / ${diagnostics.userCount}`], ["Active users", `${diagnostics.activeUserCount} / ${diagnostics.userCount}`],
["Sales docs", diagnostics.salesDocumentCount.toString()], ["Sales docs", diagnostics.salesDocumentCount.toString()],
["Work orders", diagnostics.workOrderCount.toString()], ["Work orders", diagnostics.workOrderCount.toString()],
@@ -96,12 +116,18 @@ export function AdminDiagnosticsPage() {
]; ];
const startupStatusTone = const startupStatusTone =
diagnostics.startupStatus === "PASS" diagnostics.startup.status === "PASS"
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-200" ? "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-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"; : "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 ( return (
<div className="space-y-6"> <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"> <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} onClick={handleExportSupportSnapshot}
className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text" 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> </button>
<Link to="/settings/users" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text"> <Link to="/settings/users" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
User management User management
@@ -178,6 +211,32 @@ export function AdminDiagnosticsPage() {
</div> </div>
</div> </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>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5"> <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> <h3 className="mt-2 text-lg font-bold text-text">Boot-time readiness checks</h3>
</div> </div>
<span className={`inline-flex rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] ${startupStatusTone}`}> <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> </span>
</div> </div>
<div className="mt-5 grid gap-3 xl:grid-cols-2"> <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 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"> <div className="flex items-center justify-between gap-3">
<p className="text-sm font-semibold text-text">{check.label}</p> <p className="text-sm font-semibold text-text">{check.label}</p>
@@ -201,6 +260,14 @@ export function AdminDiagnosticsPage() {
</div> </div>
))} ))}
</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>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5"> <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> </div>
</section> </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"> <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 className="flex items-center justify-between gap-3">
<div> <div>

View File

@@ -11,6 +11,7 @@ import { paths } from "./config/paths.js";
import { verifyToken } from "./lib/auth.js"; import { verifyToken } from "./lib/auth.js";
import { getCurrentUserById } from "./lib/current-user.js"; import { getCurrentUserById } from "./lib/current-user.js";
import { fail, ok } from "./lib/http.js"; import { fail, ok } from "./lib/http.js";
import { recordSupportLog } from "./lib/support-log.js";
import { adminRouter } from "./modules/admin/router.js"; import { adminRouter } from "./modules/admin/router.js";
import { authRouter } from "./modules/auth/router.js"; import { authRouter } from "./modules/auth/router.js";
import { crmRouter } from "./modules/crm/router.js"; import { crmRouter } from "./modules/crm/router.js";
@@ -52,6 +53,29 @@ export function createApp() {
next(); next();
}); });
app.use((request, response, next) => {
response.on("finish", () => {
if (response.locals.supportLogRecorded || response.statusCode < 400 || request.path === "/api/v1/health") {
return;
}
recordSupportLog({
level: response.statusCode >= 500 ? "ERROR" : "WARN",
source: "http-response",
message: `${request.method} ${request.originalUrl} returned ${response.statusCode}.`,
context: {
method: request.method,
path: request.originalUrl,
statusCode: response.statusCode,
actorId: request.authUser?.id ?? null,
ip: request.ip,
},
});
});
next();
});
app.get("/api/v1/health", (_request, response) => ok(response, { status: "ok" })); app.get("/api/v1/health", (_request, response) => ok(response, { status: "ok" }));
app.use("/api/v1/auth", authRouter); app.use("/api/v1/auth", authRouter);
app.use("/api/v1/admin", adminRouter); app.use("/api/v1/admin", adminRouter);
@@ -74,7 +98,19 @@ export function createApp() {
}); });
} }
app.use((error: Error, _request: express.Request, response: express.Response, _next: express.NextFunction) => { app.use((error: Error, request: express.Request, response: express.Response, _next: express.NextFunction) => {
response.locals.supportLogRecorded = true;
recordSupportLog({
level: "ERROR",
source: "express-error",
message: error.message || "Unexpected server error.",
context: {
method: request.method,
path: request.originalUrl,
actorId: request.authUser?.id ?? null,
stack: error.stack ?? null,
},
});
return fail(response, 500, "INTERNAL_ERROR", error.message || "Unexpected server error."); return fail(response, 500, "INTERNAL_ERROR", error.message || "Unexpected server error.");
}); });

View File

@@ -1,16 +1,16 @@
import type { StartupValidationCheckDto } from "@mrp/shared"; import type { StartupValidationReportDto } from "@mrp/shared";
interface StartupValidationReport { let latestStartupReport: StartupValidationReportDto = {
status: "PASS" | "WARN" | "FAIL";
checks: StartupValidationCheckDto[];
}
let latestStartupReport: StartupValidationReport = {
status: "WARN", status: "WARN",
generatedAt: new Date(0).toISOString(),
durationMs: 0,
passCount: 0,
warnCount: 0,
failCount: 0,
checks: [], checks: [],
}; };
export function setLatestStartupReport(report: StartupValidationReport) { export function setLatestStartupReport(report: StartupValidationReportDto) {
latestStartupReport = report; latestStartupReport = report;
} }

View File

@@ -1,4 +1,5 @@
import type { StartupValidationCheckDto } from "@mrp/shared"; import type { StartupValidationCheckDto, StartupValidationReportDto } from "@mrp/shared";
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
@@ -6,11 +7,6 @@ import { env } from "../config/env.js";
import { paths } from "../config/paths.js"; import { paths } from "../config/paths.js";
import { prisma } from "./prisma.js"; import { prisma } from "./prisma.js";
interface StartupValidationReport {
status: "PASS" | "WARN" | "FAIL";
checks: StartupValidationCheckDto[];
}
async function pathExists(targetPath: string) { async function pathExists(targetPath: string) {
try { try {
await fs.access(targetPath); await fs.access(targetPath);
@@ -20,32 +16,70 @@ async function pathExists(targetPath: string) {
} }
} }
export async function collectStartupValidationReport(): Promise<StartupValidationReport> { async function canWritePath(targetPath: string) {
try {
await fs.access(targetPath, fsConstants.W_OK);
return true;
} catch {
return false;
}
}
export async function collectStartupValidationReport(): Promise<StartupValidationReportDto> {
const startedAt = Date.now();
const checks: StartupValidationCheckDto[] = []; const checks: StartupValidationCheckDto[] = [];
const dataDirExists = await pathExists(paths.dataDir);
const uploadsDirExists = await pathExists(paths.uploadsDir);
const prismaDirExists = await pathExists(paths.prismaDir);
const databaseFilePath = path.join(paths.prismaDir, "app.db");
const databaseFileExists = await pathExists(databaseFilePath);
const clientBundlePath = path.join(paths.clientDistDir, "index.html");
const clientBundleExists = await pathExists(clientBundlePath);
const puppeteerPath = env.PUPPETEER_EXECUTABLE_PATH || "/usr/bin/chromium";
const puppeteerExists = await pathExists(puppeteerPath);
const dataDirWritable = dataDirExists && (await canWritePath(paths.dataDir));
const uploadsDirWritable = uploadsDirExists && (await canWritePath(paths.uploadsDir));
checks.push({ checks.push({
id: "data-dir", id: "data-dir",
label: "Data directory", label: "Data directory",
status: (await pathExists(paths.dataDir)) ? "PASS" : "FAIL", status: dataDirExists ? "PASS" : "FAIL",
message: (await pathExists(paths.dataDir)) ? `Data directory available at ${paths.dataDir}.` : `Data directory is missing: ${paths.dataDir}.`, message: dataDirExists ? `Data directory available at ${paths.dataDir}.` : `Data directory is missing: ${paths.dataDir}.`,
}); });
checks.push({ checks.push({
id: "uploads-dir", id: "uploads-dir",
label: "Uploads directory", label: "Uploads directory",
status: (await pathExists(paths.uploadsDir)) ? "PASS" : "FAIL", status: uploadsDirExists ? "PASS" : "FAIL",
message: (await pathExists(paths.uploadsDir)) message: uploadsDirExists ? `Uploads directory available at ${paths.uploadsDir}.` : `Uploads directory is missing: ${paths.uploadsDir}.`,
? `Uploads directory available at ${paths.uploadsDir}.`
: `Uploads directory is missing: ${paths.uploadsDir}.`,
}); });
checks.push({ checks.push({
id: "prisma-dir", id: "prisma-dir",
label: "Prisma directory", label: "Prisma directory",
status: (await pathExists(paths.prismaDir)) ? "PASS" : "FAIL", status: prismaDirExists ? "PASS" : "FAIL",
message: (await pathExists(paths.prismaDir)) message: prismaDirExists ? `Prisma data directory available at ${paths.prismaDir}.` : `Prisma data directory is missing: ${paths.prismaDir}.`,
? `Prisma data directory available at ${paths.prismaDir}.` });
: `Prisma data directory is missing: ${paths.prismaDir}.`,
checks.push({
id: "database-file",
label: "Database file",
status: databaseFileExists ? "PASS" : env.NODE_ENV === "production" ? "FAIL" : "WARN",
message: databaseFileExists ? `SQLite database file found at ${databaseFilePath}.` : `SQLite database file is missing: ${databaseFilePath}.`,
});
checks.push({
id: "data-dir-write",
label: "Data directory writable",
status: dataDirWritable ? "PASS" : "FAIL",
message: dataDirWritable ? `Application can write to ${paths.dataDir}.` : `Application cannot write to ${paths.dataDir}.`,
});
checks.push({
id: "uploads-dir-write",
label: "Uploads directory writable",
status: uploadsDirWritable ? "PASS" : "FAIL",
message: uploadsDirWritable ? `Application can write to ${paths.uploadsDir}.` : `Application cannot write to ${paths.uploadsDir}.`,
}); });
try { try {
@@ -69,10 +103,8 @@ export async function collectStartupValidationReport(): Promise<StartupValidatio
checks.push({ checks.push({
id: "client-dist", id: "client-dist",
label: "Client bundle", label: "Client bundle",
status: (await pathExists(path.join(paths.clientDistDir, "index.html"))) ? "PASS" : "FAIL", status: clientBundleExists ? "PASS" : "FAIL",
message: (await pathExists(path.join(paths.clientDistDir, "index.html"))) message: clientBundleExists ? `Client bundle found at ${paths.clientDistDir}.` : `Production client bundle is missing from ${paths.clientDistDir}.`,
? `Client bundle found at ${paths.clientDistDir}.`
: `Production client bundle is missing from ${paths.clientDistDir}.`,
}); });
} else { } else {
checks.push({ checks.push({
@@ -83,8 +115,6 @@ export async function collectStartupValidationReport(): Promise<StartupValidatio
}); });
} }
const puppeteerPath = env.PUPPETEER_EXECUTABLE_PATH || "/usr/bin/chromium";
const puppeteerExists = await pathExists(puppeteerPath);
checks.push({ checks.push({
id: "puppeteer-runtime", id: "puppeteer-runtime",
label: "PDF runtime", label: "PDF runtime",
@@ -104,6 +134,16 @@ export async function collectStartupValidationReport(): Promise<StartupValidatio
: `Client origin is configured as ${env.CLIENT_ORIGIN}.`, : `Client origin is configured as ${env.CLIENT_ORIGIN}.`,
}); });
checks.push({
id: "jwt-secret",
label: "JWT secret",
status: env.NODE_ENV === "production" && env.JWT_SECRET === "change-me" ? "WARN" : "PASS",
message:
env.NODE_ENV === "production" && env.JWT_SECRET === "change-me"
? "Production is still using the default JWT secret."
: "JWT secret is not using the default production value.",
});
checks.push({ checks.push({
id: "admin-password", id: "admin-password",
label: "Bootstrap admin password", label: "Bootstrap admin password",
@@ -122,6 +162,11 @@ export async function collectStartupValidationReport(): Promise<StartupValidatio
return { return {
status, status,
generatedAt: new Date().toISOString(),
durationMs: Date.now() - startedAt,
passCount: checks.filter((check) => check.status === "PASS").length,
warnCount: checks.filter((check) => check.status === "WARN").length,
failCount: checks.filter((check) => check.status === "FAIL").length,
checks, checks,
}; };
} }

View File

@@ -0,0 +1,46 @@
import type { SupportLogEntryDto } from "@mrp/shared";
import { randomUUID } from "node:crypto";
const SUPPORT_LOG_LIMIT = 200;
const supportLogs: SupportLogEntryDto[] = [];
function serializeContext(context?: Record<string, unknown>) {
if (!context) {
return "{}";
}
try {
return JSON.stringify(context);
} catch {
return JSON.stringify({ serializationError: "Unable to serialize support log context." });
}
}
export function recordSupportLog(entry: {
level: SupportLogEntryDto["level"];
source: string;
message: string;
context?: Record<string, unknown>;
}) {
supportLogs.unshift({
id: randomUUID(),
level: entry.level,
source: entry.source,
message: entry.message,
contextJson: serializeContext(entry.context),
createdAt: new Date().toISOString(),
});
if (supportLogs.length > SUPPORT_LOG_LIMIT) {
supportLogs.length = SUPPORT_LOG_LIMIT;
}
}
export function listSupportLogs(limit = 50) {
return supportLogs.slice(0, Math.max(0, limit));
}
export function getSupportLogCount() {
return supportLogs.length;
}

View File

@@ -9,6 +9,7 @@ import {
createAdminUser, createAdminUser,
getBackupGuidance, getBackupGuidance,
getAdminDiagnostics, getAdminDiagnostics,
getSupportLogs,
getSupportSnapshot, getSupportSnapshot,
listAdminPermissions, listAdminPermissions,
listAdminRoles, listAdminRoles,
@@ -50,6 +51,10 @@ adminRouter.get("/support-snapshot", requirePermissions([permissions.adminManage
return ok(response, await getSupportSnapshot()); return ok(response, await getSupportSnapshot());
}); });
adminRouter.get("/support-logs", requirePermissions([permissions.adminManage]), async (_request, response) => {
return ok(response, getSupportLogs());
});
adminRouter.get("/permissions", requirePermissions([permissions.adminManage]), async (_request, response) => { adminRouter.get("/permissions", requirePermissions([permissions.adminManage]), async (_request, response) => {
return ok(response, await listAdminPermissions()); return ok(response, await listAdminPermissions());
}); });

View File

@@ -8,8 +8,8 @@ import type {
AdminUserInput, AdminUserInput,
SupportSnapshotDto, SupportSnapshotDto,
AuditEventDto, AuditEventDto,
SupportLogEntryDto,
} from "@mrp/shared"; } from "@mrp/shared";
import fs from "node:fs/promises";
import { env } from "../../config/env.js"; import { env } from "../../config/env.js";
import { paths } from "../../config/paths.js"; import { paths } from "../../config/paths.js";
@@ -17,6 +17,7 @@ import { logAuditEvent } from "../../lib/audit.js";
import { hashPassword } from "../../lib/password.js"; import { hashPassword } from "../../lib/password.js";
import { prisma } from "../../lib/prisma.js"; import { prisma } from "../../lib/prisma.js";
import { getLatestStartupReport } from "../../lib/startup-state.js"; import { getLatestStartupReport } from "../../lib/startup-state.js";
import { getSupportLogCount, listSupportLogs } from "../../lib/support-log.js";
function mapAuditEvent(record: { function mapAuditEvent(record: {
id: string; id: string;
@@ -45,6 +46,17 @@ function mapAuditEvent(record: {
}; };
} }
function mapSupportLogEntry(record: SupportLogEntryDto): SupportLogEntryDto {
return {
id: record.id,
level: record.level,
source: record.source,
message: record.message,
contextJson: record.contextJson,
createdAt: record.createdAt,
};
}
function mapRole(record: { function mapRole(record: {
id: string; id: string;
name: string; name: string;
@@ -468,6 +480,7 @@ export async function updateAdminUser(userId: string, payload: AdminUserInput, a
export async function getAdminDiagnostics(): Promise<AdminDiagnosticsDto> { export async function getAdminDiagnostics(): Promise<AdminDiagnosticsDto> {
const startupReport = getLatestStartupReport(); const startupReport = getLatestStartupReport();
const recentSupportLogs = listSupportLogs(50);
const [ const [
companyProfile, companyProfile,
userCount, userCount,
@@ -519,8 +532,6 @@ export async function getAdminDiagnostics(): Promise<AdminDiagnosticsDto> {
}), }),
]); ]);
await Promise.all([fs.access(paths.dataDir), fs.access(paths.uploadsDir)]);
return { return {
serverTime: new Date().toISOString(), serverTime: new Date().toISOString(),
nodeVersion: process.version, nodeVersion: process.version,
@@ -544,9 +555,10 @@ export async function getAdminDiagnostics(): Promise<AdminDiagnosticsDto> {
shipmentCount, shipmentCount,
attachmentCount, attachmentCount,
auditEventCount, auditEventCount,
startupStatus: startupReport.status, supportLogCount: getSupportLogCount(),
startupChecks: startupReport.checks, startup: startupReport,
recentAuditEvents: recentAuditEvents.map(mapAuditEvent), recentAuditEvents: recentAuditEvents.map(mapAuditEvent),
recentSupportLogs: recentSupportLogs.map(mapSupportLogEntry),
}; };
} }
@@ -600,11 +612,70 @@ export function getBackupGuidance(): BackupGuidanceDto {
detail: "Confirm admin login, attachment access, and PDF generation after restore to verify the operational surface is healthy.", detail: "Confirm admin login, attachment access, and PDF generation after restore to verify the operational surface is healthy.",
}, },
], ],
verificationChecklist: [
{
id: "backup-size-check",
label: "Confirm backup contains data and uploads",
detail: "Verify the backup archive or copied directory includes the SQLite database and uploads tree rather than only one of them.",
evidence: "Directory listing or archive manifest showing prisma/app.db and uploads/ content.",
},
{
id: "timestamp-check",
label: "Check backup freshness",
detail: "Confirm the backup timestamp matches the expected backup window and is newer than the last major data-entry period you need to protect.",
evidence: "Backup timestamp recorded in your scheduler, NAS share, or copied folder metadata.",
},
{
id: "snapshot-export",
label: "Capture a support snapshot with the backup",
detail: "Export the support snapshot from diagnostics when taking a formal backup so the runtime state and active-user footprint are recorded alongside it.",
evidence: "JSON support snapshot stored with the backup set or support ticket.",
},
{
id: "app-stop-check",
label: "Verify writes were stopped before copy",
detail: "Use a controlled maintenance stop or container stop before backup to reduce the chance of a partial SQLite copy.",
evidence: "Maintenance log entry, Docker stop event, or operator note recorded with the backup.",
},
],
restoreDrillSteps: [
{
id: "prepare-drill-target",
label: "Prepare isolated restore target",
detail: "Restore into an isolated container or duplicate environment instead of the live production instance.",
expectedOutcome: "A clean target environment is ready to receive the backed-up data directory without impacting production.",
},
{
id: "load-backed-up-data",
label: "Load the full backup set",
detail: `Restore the full backed-up data directory so ${paths.prismaDir}/app.db and uploads are returned together.`,
expectedOutcome: "The restore target contains both database and file assets with the original directory structure intact.",
},
{
id: "boot-restored-app",
label: "Start the restored application",
detail: "Launch the restored app and allow startup validation plus migrations to complete normally.",
expectedOutcome: "The application starts without startup-validation failures and the diagnostics page loads.",
},
{
id: "run-functional-checks",
label: "Run post-restore functional checks",
detail: "Verify login, one attachment download, one PDF render, and one representative transactional detail page such as inventory, purchasing, or shipping.",
expectedOutcome: "Core operational flows work in the restored environment and file/PDF dependencies remain valid.",
},
{
id: "record-drill-results",
label: "Record restore-drill results",
detail: "Capture the drill date, backup source used, startup status, and any gaps discovered so future recovery work improves over time.",
expectedOutcome: "A dated restore-drill record exists for support and disaster-recovery review.",
},
],
}; };
} }
export async function getSupportSnapshot(): Promise<SupportSnapshotDto> { export async function getSupportSnapshot(): Promise<SupportSnapshotDto> {
const diagnostics = await getAdminDiagnostics(); const diagnostics = await getAdminDiagnostics();
const backupGuidance = getBackupGuidance();
const [users, roles] = await Promise.all([ const [users, roles] = await Promise.all([
prisma.user.findMany({ prisma.user.findMany({
where: { isActive: true }, where: { isActive: true },
@@ -620,5 +691,11 @@ export async function getSupportSnapshot(): Promise<SupportSnapshotDto> {
userCount: diagnostics.userCount, userCount: diagnostics.userCount,
roleCount: roles, roleCount: roles,
activeUserEmails: users.map((user) => user.email), activeUserEmails: users.map((user) => user.email),
backupGuidance,
recentSupportLogs: diagnostics.recentSupportLogs,
}; };
} }
export function getSupportLogs() {
return listSupportLogs(100).map(mapSupportLogEntry);
}

View File

@@ -4,14 +4,37 @@ import { bootstrapAppData } from "./lib/bootstrap.js";
import { prisma } from "./lib/prisma.js"; import { prisma } from "./lib/prisma.js";
import { setLatestStartupReport } from "./lib/startup-state.js"; import { setLatestStartupReport } from "./lib/startup-state.js";
import { assertStartupReadiness } from "./lib/startup-validation.js"; import { assertStartupReadiness } from "./lib/startup-validation.js";
import { recordSupportLog } from "./lib/support-log.js";
async function start() { async function start() {
await bootstrapAppData(); await bootstrapAppData();
const startupReport = await assertStartupReadiness(); const startupReport = await assertStartupReadiness();
setLatestStartupReport(startupReport); setLatestStartupReport(startupReport);
recordSupportLog({
level: startupReport.status === "PASS" ? "INFO" : startupReport.status === "WARN" ? "WARN" : "ERROR",
source: "startup-validation",
message: `Startup validation completed with status ${startupReport.status}.`,
context: {
generatedAt: startupReport.generatedAt,
durationMs: startupReport.durationMs,
passCount: startupReport.passCount,
warnCount: startupReport.warnCount,
failCount: startupReport.failCount,
},
});
for (const check of startupReport.checks.filter((entry) => entry.status !== "PASS")) { for (const check of startupReport.checks.filter((entry) => entry.status !== "PASS")) {
console.warn(`[startup:${check.status.toLowerCase()}] ${check.label}: ${check.message}`); console.warn(`[startup:${check.status.toLowerCase()}] ${check.label}: ${check.message}`);
recordSupportLog({
level: check.status === "WARN" ? "WARN" : "ERROR",
source: "startup-check",
message: `${check.label}: ${check.message}`,
context: {
checkId: check.id,
status: check.status,
},
});
} }
const app = createApp(); const app = createApp();
@@ -30,6 +53,14 @@ async function start() {
start().catch(async (error) => { start().catch(async (error) => {
console.error(error); console.error(error);
recordSupportLog({
level: "ERROR",
source: "server-startup",
message: error instanceof Error ? error.message : "Server startup failed.",
context: {
stack: error instanceof Error ? error.stack ?? null : null,
},
});
await prisma.$disconnect(); await prisma.$disconnect();
process.exit(1); process.exit(1);
}); });

View File

@@ -60,12 +60,30 @@ export interface StartupValidationCheckDto {
message: string; message: string;
} }
export interface StartupValidationReportDto {
status: "PASS" | "WARN" | "FAIL";
generatedAt: string;
durationMs: number;
passCount: number;
warnCount: number;
failCount: number;
checks: StartupValidationCheckDto[];
}
export interface BackupChecklistItemDto { export interface BackupChecklistItemDto {
id: string; id: string;
label: string; label: string;
detail: string; detail: string;
} }
export interface BackupVerificationItemDto extends BackupChecklistItemDto {
evidence: string;
}
export interface RestoreDrillStepDto extends BackupChecklistItemDto {
expectedOutcome: string;
}
export interface BackupGuidanceDto { export interface BackupGuidanceDto {
dataPath: string; dataPath: string;
databasePath: string; databasePath: string;
@@ -73,6 +91,17 @@ export interface BackupGuidanceDto {
recommendedBackupTarget: string; recommendedBackupTarget: string;
backupSteps: BackupChecklistItemDto[]; backupSteps: BackupChecklistItemDto[];
restoreSteps: BackupChecklistItemDto[]; restoreSteps: BackupChecklistItemDto[];
verificationChecklist: BackupVerificationItemDto[];
restoreDrillSteps: RestoreDrillStepDto[];
}
export interface SupportLogEntryDto {
id: string;
level: "INFO" | "WARN" | "ERROR";
source: string;
message: string;
contextJson: string;
createdAt: string;
} }
export interface SupportSnapshotDto { export interface SupportSnapshotDto {
@@ -81,6 +110,8 @@ export interface SupportSnapshotDto {
userCount: number; userCount: number;
roleCount: number; roleCount: number;
activeUserEmails: string[]; activeUserEmails: string[];
backupGuidance: BackupGuidanceDto;
recentSupportLogs: SupportLogEntryDto[];
} }
export interface AdminDiagnosticsDto { export interface AdminDiagnosticsDto {
@@ -106,7 +137,8 @@ export interface AdminDiagnosticsDto {
shipmentCount: number; shipmentCount: number;
attachmentCount: number; attachmentCount: number;
auditEventCount: number; auditEventCount: number;
startupStatus: "PASS" | "WARN" | "FAIL"; supportLogCount: number;
startupChecks: StartupValidationCheckDto[]; startup: StartupValidationReportDto;
recentAuditEvents: AuditEventDto[]; recentAuditEvents: AuditEventDto[];
recentSupportLogs: SupportLogEntryDto[];
} }