391 lines
16 KiB
JavaScript
391 lines
16 KiB
JavaScript
import { useState, useEffect, useCallback } from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import { RefreshCw } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { api } from '@/api';
|
|
import { cn, fmtUptime, fmtBytes } from '@/lib/utils';
|
|
import { Button } from '@/components/ui/button';
|
|
import { MarkdownText } from '@/components/MarkdownText';
|
|
|
|
// ─── Skeleton Card ────────────────────────────────────────────────────────────
|
|
|
|
function SkeletonCard() {
|
|
return (
|
|
<div className="bg-card rounded-xl border border-border shadow-sm p-6 animate-pulse">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="h-3 w-20 bg-muted rounded" />
|
|
<div className="h-3 w-12 bg-muted rounded" />
|
|
</div>
|
|
<div className="space-y-3">
|
|
{[1, 2, 3].map((i) => (
|
|
<div key={i} className="flex items-center justify-between py-2 border-b border-border/50 last:border-0">
|
|
<div className="h-3 w-24 bg-muted rounded" />
|
|
<div className="h-3 w-16 bg-muted rounded" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Stat Row ─────────────────────────────────────────────────────────────────
|
|
|
|
function StatRow({ label, value, last }) {
|
|
return (
|
|
<div className={cn(
|
|
'flex items-center justify-between py-2',
|
|
!last && 'border-b border-border/50',
|
|
)}>
|
|
<span className="text-sm text-muted-foreground">{label}</span>
|
|
<span className="text-sm font-medium tabular-nums">{value ?? '—'}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function formatMemory(runtime) {
|
|
if (runtime.memory_used_bytes ?? runtime.memory?.used ?? runtime.memory) {
|
|
return fmtBytes(runtime.memory_used_bytes ?? runtime.memory?.used ?? runtime.memory);
|
|
}
|
|
if (runtime.memory_mb) return `${runtime.memory_mb} MB`;
|
|
return null;
|
|
}
|
|
|
|
function formatBytesMaybe(value) {
|
|
return value === undefined || value === null ? null : fmtBytes(value);
|
|
}
|
|
|
|
function formatDateTime(value) {
|
|
if (!value) return null;
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return value;
|
|
return date.toLocaleString();
|
|
}
|
|
|
|
function StatusPill({ tone = 'muted', children }) {
|
|
const cls = {
|
|
good: 'bg-emerald-500/10 text-emerald-500 border-emerald-500/25',
|
|
warn: 'bg-amber-500/10 text-amber-500 border-amber-500/25',
|
|
bad: 'bg-red-500/10 text-red-500 border-red-500/25',
|
|
muted: 'bg-muted text-muted-foreground border-border',
|
|
}[tone];
|
|
|
|
return (
|
|
<span className={cn(
|
|
'inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide',
|
|
cls,
|
|
)}>
|
|
{children}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function StatusCard({ title, status, tone = 'muted', children }) {
|
|
return (
|
|
<div className="bg-card rounded-xl border border-border shadow-sm p-6">
|
|
<div className="flex items-center justify-between gap-3 mb-4">
|
|
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground">{title}</h3>
|
|
{status && <StatusPill tone={tone}>{status}</StatusPill>}
|
|
</div>
|
|
<div className="space-y-0">{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const CATEGORY_ORDER = ['Added', 'Changed', 'Fixed', 'Removed', 'Deprecated', 'Security'];
|
|
|
|
// ─── Release Notes Section ────────────────────────────────────────────────────
|
|
|
|
function ReleaseNotesSection({ version, historyMeta }) {
|
|
if (!version) {
|
|
return (
|
|
<div className="mt-4">
|
|
<div className="h-28 rounded-lg bg-muted/50 animate-pulse" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Group notes by category, preserving CATEGORY_ORDER priority
|
|
const grouped = CATEGORY_ORDER.reduce((acc, cat) => {
|
|
const notes = version.notes?.filter((n) => n.category === cat) ?? [];
|
|
if (notes.length) acc[cat] = notes;
|
|
return acc;
|
|
}, {});
|
|
|
|
// Catch any categories not in CATEGORY_ORDER
|
|
version.notes?.forEach((n) => {
|
|
if (!grouped[n.category]) {
|
|
grouped[n.category] = [...(grouped[n.category] ?? []), n];
|
|
}
|
|
});
|
|
|
|
const categories = Object.keys(grouped);
|
|
|
|
const preview = categories.length ? grouped[categories[0]]?.[0]?.text : null;
|
|
|
|
return (
|
|
<StatusCard title="Release Notes" status={`v${version.version}`} tone="good">
|
|
<StatRow label="Latest Release" value={categories[0] ?? null} />
|
|
<StatRow label="Last Updated" value={formatDateTime(historyMeta?.updated_at)} />
|
|
<div className="py-2 border-b border-border/50">
|
|
<p className="text-sm text-muted-foreground">Preview</p>
|
|
<p className="text-sm font-medium mt-1">
|
|
<MarkdownText text={preview ?? 'No release notes available.'} />
|
|
</p>
|
|
</div>
|
|
<div className="pt-3">
|
|
<Button asChild variant="outline" size="sm">
|
|
<Link to="/release-notes">View Full Release Notes</Link>
|
|
</Button>
|
|
</div>
|
|
</StatusCard>
|
|
);
|
|
}
|
|
|
|
// ─── StatusPage ───────────────────────────────────────────────────────────────
|
|
|
|
export default function StatusPage() {
|
|
const [data, setData] = useState(null);
|
|
const [version, setVersion] = useState(null);
|
|
const [historyMeta, setHistoryMeta] = useState(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
setVersion(null);
|
|
setHistoryMeta(null);
|
|
try {
|
|
const [statusData, versionData] = await Promise.all([
|
|
api.status(),
|
|
api.version(),
|
|
]);
|
|
setData(statusData);
|
|
setVersion(versionData);
|
|
try {
|
|
const historyData = await api.releaseHistory();
|
|
setHistoryMeta({
|
|
version: historyData.version,
|
|
updated_at: historyData.updated_at,
|
|
});
|
|
} catch {
|
|
setHistoryMeta(null);
|
|
}
|
|
} catch (err) {
|
|
toast.error(err.message || 'Failed to load status.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => { load(); }, [load]);
|
|
|
|
// Flatten nested status shape gracefully
|
|
const app = data?.application ?? data?.app ?? {};
|
|
const rt = data?.runtime ?? {};
|
|
const db = data?.database ?? data?.db ?? {};
|
|
const stats = data?.statistics ?? data?.stats ?? {};
|
|
const worker = data?.worker ?? data?.jobs ?? {};
|
|
const notifications = data?.notifications ?? data?.email ?? {};
|
|
const backups = data?.backups ?? data?.backup ?? {};
|
|
const server = data?.server ?? data?.clock ?? {};
|
|
const tracker = data?.tracker ?? data?.tracker_health ?? {};
|
|
const cleanup = data?.cleanup ?? {};
|
|
const errors = data?.errors ?? data?.recent_errors ?? [];
|
|
|
|
const dbOk = db.connected ?? (db.status === 'connected') ?? true;
|
|
const workerOk = worker.running ?? worker.enabled ?? null;
|
|
const notificationsConfigured = notifications.configured ?? notifications.smtp_configured ?? null;
|
|
const backupsEnabled = backups.enabled ?? null;
|
|
const recentErrors = Array.isArray(errors) ? errors : [];
|
|
|
|
return (
|
|
<div>
|
|
|
|
{/* Page header — flat on background */}
|
|
<div className="flex items-center justify-between mb-8">
|
|
<div>
|
|
<h1 className="text-2xl font-bold tracking-tight">Server Status</h1>
|
|
<p className="text-sm text-muted-foreground mt-0.5">
|
|
{data ? 'All systems operational' : 'Loading…'}
|
|
</p>
|
|
</div>
|
|
<Button variant="outline" size="sm" onClick={load} disabled={loading}>
|
|
<RefreshCw className={cn('h-3.5 w-3.5 mr-1.5', loading && 'animate-spin')} />
|
|
Refresh
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 2x2 grid of stat cards */}
|
|
{loading ? (
|
|
<div className="grid gap-4 mb-6 md:grid-cols-2">
|
|
<SkeletonCard />
|
|
<SkeletonCard />
|
|
<SkeletonCard />
|
|
<SkeletonCard />
|
|
</div>
|
|
) : (
|
|
<div className="grid gap-4 mb-6 md:grid-cols-2">
|
|
|
|
{/* Application */}
|
|
<StatusCard title="Application" status="Online" tone="good">
|
|
<StatRow label="Version" value={(app.version ?? data?.version) ? `v${app.version ?? data?.version}` : null} />
|
|
<StatRow label="Environment" value={app.environment ?? app.env ?? data?.environment ?? data?.env} />
|
|
<StatRow
|
|
label="Uptime"
|
|
value={fmtUptime(app.uptime_seconds ?? app.uptime ?? data?.uptime_seconds ?? 0)}
|
|
last
|
|
/>
|
|
</StatusCard>
|
|
|
|
{/* Runtime */}
|
|
<StatusCard title="Runtime">
|
|
<StatRow label="Node.js" value={rt.node_version ?? rt.node ?? data?.node_version} />
|
|
<StatRow label="Platform" value={rt.platform ?? data?.platform} />
|
|
<StatRow label="Architecture" value={rt.arch ?? data?.arch} />
|
|
<StatRow label="Memory" value={formatMemory(rt)} last />
|
|
</StatusCard>
|
|
|
|
{/* Database */}
|
|
<StatusCard title="Database" status={dbOk ? 'Connected' : 'Error'} tone={dbOk ? 'good' : 'bad'}>
|
|
<StatRow label="Size" value={formatBytesMaybe(db.size_bytes ?? db.size)} />
|
|
<StatRow label="Last Modified" value={formatDateTime(db.last_modified ?? db.modified_at)} />
|
|
<StatRow label="Free Disk" value={formatBytesMaybe(db.free_disk_bytes ?? db.disk_free_bytes)} />
|
|
<div className="flex items-start justify-between py-2">
|
|
<span className="text-sm text-muted-foreground">File path</span>
|
|
<code className="text-xs bg-muted px-1.5 py-0.5 rounded font-mono max-w-[55%] break-all text-right">
|
|
{db.file ?? db.path ?? db.filename ?? '—'}
|
|
</code>
|
|
</div>
|
|
</StatusCard>
|
|
|
|
{/* Statistics */}
|
|
<StatusCard title="Statistics">
|
|
<StatRow label="Active Bills" value={stats.active_bills ?? stats.bills ?? stats.active_bill_count} />
|
|
<StatRow label="Total Payments" value={stats.total_payments ?? stats.payments} />
|
|
<StatRow label="Users" value={stats.users ?? stats.user_count} />
|
|
<StatRow label="Sessions" value={stats.active_sessions ?? stats.sessions} last />
|
|
</StatusCard>
|
|
|
|
</div>
|
|
)}
|
|
|
|
{!loading && (
|
|
<>
|
|
<div className="mb-3">
|
|
<h2 className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
|
Operations
|
|
</h2>
|
|
</div>
|
|
|
|
<div className="grid gap-4 mb-6 md:grid-cols-2 xl:grid-cols-3">
|
|
<StatusCard
|
|
title="Daily Worker"
|
|
status={workerOk === null ? 'Pending' : workerOk ? 'Running' : 'Stopped'}
|
|
tone={workerOk === null ? 'muted' : workerOk ? 'good' : 'bad'}
|
|
>
|
|
<StatRow label="Enabled" value={worker.enabled === undefined ? null : worker.enabled ? 'Yes' : 'No'} />
|
|
<StatRow label="Last Run" value={formatDateTime(worker.last_run_at ?? worker.last_success_at)} />
|
|
<StatRow label="Next Run" value={formatDateTime(worker.next_run_at)} />
|
|
<StatRow label="Last Error" value={worker.last_error} last />
|
|
</StatusCard>
|
|
|
|
<StatusCard
|
|
title="Notifications"
|
|
status={notificationsConfigured === null ? 'Pending' : notificationsConfigured ? 'Configured' : 'Missing'}
|
|
tone={notificationsConfigured === null ? 'muted' : notificationsConfigured ? 'good' : 'warn'}
|
|
>
|
|
<StatRow label="Enabled" value={notifications.enabled === undefined ? null : notifications.enabled ? 'Yes' : 'No'} />
|
|
<StatRow label="SMTP" value={notificationsConfigured === undefined || notificationsConfigured === null ? null : notificationsConfigured ? 'Configured' : 'Not configured'} />
|
|
<StatRow label="Last Sent" value={formatDateTime(notifications.last_sent_at)} />
|
|
<StatRow label="Failures" value={notifications.failures ?? notifications.failed_count} last />
|
|
</StatusCard>
|
|
|
|
<StatusCard
|
|
title="Backups"
|
|
status={backupsEnabled === null ? 'Pending' : backupsEnabled ? 'Enabled' : 'Disabled'}
|
|
tone={backupsEnabled === null ? 'muted' : backupsEnabled ? 'good' : 'warn'}
|
|
>
|
|
<StatRow label="Last Backup" value={formatDateTime(backups.last_backup_at)} />
|
|
<StatRow label="Backup Count" value={backups.count ?? backups.backup_count} />
|
|
<StatRow label="Retention" value={backups.keep_count ?? backups.retention_count} />
|
|
<StatRow label="Last Error" value={backups.last_error} last />
|
|
</StatusCard>
|
|
|
|
<StatusCard
|
|
title="Maintenance"
|
|
status={cleanup.last_run_at ? 'OK' : 'Pending'}
|
|
tone={cleanup.last_run_at ? 'good' : 'muted'}
|
|
>
|
|
<StatRow label="Last Run" value={formatDateTime(cleanup.last_run_at)} />
|
|
<StatRow
|
|
label="Import Sessions"
|
|
value={cleanup.last_result?.import_sessions?.pruned != null
|
|
? `${cleanup.last_result.import_sessions.pruned} pruned`
|
|
: null}
|
|
/>
|
|
<StatRow
|
|
label="Temp Export Files"
|
|
value={cleanup.last_result?.temp_export_files?.removed != null
|
|
? `${cleanup.last_result.temp_export_files.removed} removed`
|
|
: null}
|
|
/>
|
|
<StatRow
|
|
label="Backup Partials"
|
|
value={cleanup.last_result?.backup_partials?.removed != null
|
|
? `${cleanup.last_result.backup_partials.removed} removed`
|
|
: null}
|
|
last
|
|
/>
|
|
</StatusCard>
|
|
|
|
<StatusCard title="Server Clock">
|
|
<StatRow label="Server Time" value={formatDateTime(server.now ?? data?.server_time)} />
|
|
<StatRow label="Server Date" value={server.today ?? data?.today} />
|
|
<StatRow label="Timezone" value={server.timezone ?? data?.timezone} />
|
|
<StatRow label="UTC Offset" value={server.utc_offset} last />
|
|
</StatusCard>
|
|
|
|
<StatusCard title="Tracker Health">
|
|
<StatRow label="Bills This Month" value={tracker.bills_this_month} />
|
|
<StatRow label="Payments This Month" value={tracker.payments_this_month} />
|
|
<StatRow label="Overdue" value={tracker.overdue_count} />
|
|
<StatRow label="Skipped" value={tracker.skipped_count} last />
|
|
</StatusCard>
|
|
|
|
<StatusCard
|
|
title="Recent Errors"
|
|
status={recentErrors.length ? `${recentErrors.length} Open` : 'None'}
|
|
tone={recentErrors.length ? 'bad' : 'good'}
|
|
>
|
|
{recentErrors.length ? (
|
|
recentErrors.slice(0, 4).map((err, index) => (
|
|
<div
|
|
key={`${err.timestamp ?? err.message ?? index}`}
|
|
className={cn(
|
|
'py-2',
|
|
index !== Math.min(recentErrors.length, 4) - 1 && 'border-b border-border/50',
|
|
)}
|
|
>
|
|
<p className="text-sm font-medium leading-tight">{err.source ?? err.type ?? 'Application'}</p>
|
|
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
|
|
{err.message ?? String(err)}
|
|
</p>
|
|
</div>
|
|
))
|
|
) : (
|
|
<>
|
|
<StatRow label="Last Error" value={null} />
|
|
<StatRow label="Open Incidents" value="0" last />
|
|
</>
|
|
)}
|
|
</StatusCard>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Release Notes */}
|
|
<ReleaseNotesSection version={version} historyMeta={historyMeta} />
|
|
|
|
</div>
|
|
);
|
|
}
|