import { useState, useEffect, useCallback } from 'react'; import type { ReactNode } from 'react'; import { RefreshCw, Download, Check, X, AlertTriangle, RotateCcw, ExternalLink } from 'lucide-react'; import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; import { cn } from '@/lib/utils'; import { Skeleton } from '@/components/ui/skeleton'; import { SettingsSection } from './SettingsSection'; import { SettingsField } from './SettingsField'; import { SettingsActions, SettingsPrimaryButton, SettingsSecondaryButton } from './SettingsActions'; import { EnvironmentChecks } from './EnvironmentChecks'; import { DEPLOY_FEEDBACK_KEY } from '@/hooks/use-deploy-feedback-enabled'; import { COMPOSE_DIFF_PREVIEW_KEY } from '@/hooks/use-compose-diff-preview-enabled'; // Mirrors the backend DiagnosticsReport (services/DiagnosticsService.ts). Kept // local because the frontend cannot import backend types. interface DiagnosticsReport { version: string | null; database: { ok: boolean; integrity: string; path: string; missingTables: string[] }; encryptionKey: { present: boolean; valid: boolean }; docker: { reachable: boolean; error?: string }; auth: { adminCount: number; userCount: number; mfaEnrolledCount: number; ssoProviders: Array<{ provider: string; enabled: boolean }>; }; config: Record; } type Health = 'ok' | 'warn' | 'error'; // Browser-local display preferences cleared by "Reset interface preferences". // The density key is internal to use-density; the other two are exported. const DENSITY_KEY = 'sencho.appearance.density'; const INTERFACE_PREF_KEYS = [DENSITY_KEY, DEPLOY_FEEDBACK_KEY, COMPOSE_DIFF_PREVIEW_KEY]; const CLI_COMMANDS: Array<{ cmd: string; purpose: string }> = [ { cmd: 'node dist/cli/resetMfa.js ', purpose: "Clear a user's two-factor enrolment" }, { cmd: 'node dist/cli/resetPassword.js ', purpose: "Reset a local user's password" }, { cmd: 'node dist/cli/createEmergencyAdmin.js ', purpose: 'Create a new admin account' }, { cmd: 'node dist/cli/clearSessions.js', purpose: 'Sign every user out' }, { cmd: 'node dist/cli/disableSso.js [provider]', purpose: 'Disable a broken SSO provider' }, { cmd: 'node dist/cli/diagnostics.js', purpose: 'Print this report as JSON' }, { cmd: 'node dist/cli/validateDb.js', purpose: 'Check database and encryption-key integrity' }, { cmd: 'node dist/cli/backupData.js [dir]', purpose: 'Back up the data directory' }, ]; // When Docker is unreachable, prefer the actual error the backend captured (a // bad socket path or permission denial is not self-healing); fall back to the // reassuring copy only when no specific cause was reported. function dockerHelper(report: DiagnosticsReport | null): string | undefined { if (!report || report.docker.reachable) return undefined; return report.docker.error ? `Unreachable: ${report.docker.error}` : 'Sencho reconnects on its own once Docker is back.'; } // Save text content to a file via a transient object URL. Used for both the // diagnostics JSON export and the offline command reference. function triggerDownload(filename: string, content: string, mime: string) { const url = URL.createObjectURL(new Blob([content], { type: mime })); const anchor = document.createElement('a'); anchor.href = url; anchor.download = filename; document.body.appendChild(anchor); anchor.click(); anchor.remove(); URL.revokeObjectURL(url); } // Plain-text command reference an operator can save while the app is reachable, // so the commands are on hand for exactly the situation where it is not. function cliReferenceText(): string { const lines = [ 'Sencho emergency recovery commands', '', 'Run each from a shell on the host running Sencho:', ' docker compose exec sencho ', '', ]; for (const { cmd, purpose } of CLI_COMMANDS) { lines.push(`# ${purpose}`, `docker compose exec sencho ${cmd}`, ''); } return lines.join('\n'); } function StatusValue({ health, children }: { health: Health; children: ReactNode }) { const Icon = health === 'ok' ? Check : health === 'warn' ? AlertTriangle : X; return ( {children} ); } function RecoverySkeleton() { return (
); } export function RecoverySection() { const [report, setReport] = useState(null); const [isLoading, setIsLoading] = useState(true); const load = useCallback(async () => { setIsLoading(true); try { const res = await apiFetch('/diagnostics', { localOnly: true }); if (!res.ok) { const err = await res.json().catch(() => ({})); toast.error(err?.error || 'Failed to load diagnostics.'); setReport(null); return; } setReport(await res.json() as DiagnosticsReport); } catch (e: unknown) { toast.error((e as Error)?.message || 'Failed to load diagnostics.'); setReport(null); } finally { setIsLoading(false); } }, []); useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect void load(); }, [load]); const exportReport = () => { if (!report) return; try { const stamp = new Date().toISOString().replace(/[:.]/g, '-'); triggerDownload(`sencho-diagnostics-${stamp}.json`, JSON.stringify(report, null, 2), 'application/json'); } catch (e: unknown) { toast.error((e as Error)?.message || 'Could not export diagnostics.'); } }; const downloadCommands = () => { try { triggerDownload('sencho-recovery-commands.txt', cliReferenceText(), 'text/plain'); } catch (e: unknown) { toast.error((e as Error)?.message || 'Could not download the command reference.'); } }; const resetInterface = () => { try { INTERFACE_PREF_KEYS.forEach(key => window.localStorage.removeItem(key)); toast.success('Interface preferences reset to defaults. Reloading...'); setTimeout(() => window.location.reload(), 600); } catch (e: unknown) { toast.error((e as Error)?.message || 'Could not reset interface preferences.'); } }; if (isLoading) return ; const dbHealth: Health = report?.database.ok ? 'ok' : 'error'; const keyHealth: Health = report?.encryptionKey.present && report.encryptionKey.valid ? 'ok' : 'error'; const dockerHealth: Health = report?.docker.reachable ? 'ok' : 'warn'; const adminHealth: Health = (report?.auth.adminCount ?? 0) > 0 ? 'ok' : 'warn'; return (
{report?.version ?? 'unknown'} {report?.database.ok ? 'Healthy' : 'Problem detected'} {!report?.encryptionKey.present ? 'Missing' : report.encryptionKey.valid ? 'Present' : 'Invalid'} {report?.docker.reachable ? 'Reachable' : 'Unreachable'} {report?.auth.adminCount ?? 0} of {report?.auth.userCount ?? 0} users {report?.auth.mfaEnrolledCount ?? 0} user(s) {report && report.auth.ssoProviders.length > 0 ? report.auth.ssoProviders.map(p => `${p.provider} (${p.enabled ? 'on' : 'off'})`).join(', ') : 'None configured'} void load()}> Refresh Export diagnostics
Reset Open guide

prefix each with: docker compose exec sencho

{CLI_COMMANDS.map(({ cmd, purpose }) => (
{cmd} {purpose}
))}
Download commands
); }