2026-07-04 22:33:15 -05:00
|
|
|
import React, { useState, useEffect, useCallback, type ComponentType, type ReactNode } from 'react';
|
2026-05-03 19:51:57 -05:00
|
|
|
import { Link } from 'react-router-dom';
|
2026-05-29 19:58:52 -05:00
|
|
|
import {
|
2026-07-06 14:23:53 -05:00
|
|
|
Activity,
|
|
|
|
|
AlertCircle,
|
|
|
|
|
ArrowUpCircle,
|
|
|
|
|
BarChart3,
|
|
|
|
|
Bell,
|
|
|
|
|
CalendarClock,
|
|
|
|
|
CheckCircle2,
|
|
|
|
|
Clock,
|
|
|
|
|
Cpu,
|
|
|
|
|
Database,
|
|
|
|
|
Globe,
|
|
|
|
|
HardDrive,
|
|
|
|
|
Landmark,
|
|
|
|
|
RefreshCw,
|
|
|
|
|
ScrollText,
|
|
|
|
|
Wrench,
|
2026-05-29 19:58:52 -05:00
|
|
|
} from 'lucide-react';
|
2026-05-03 19:51:57 -05:00
|
|
|
import { toast } from 'sonner';
|
|
|
|
|
import { api } from '@/api';
|
2026-07-04 22:33:15 -05:00
|
|
|
import { cn, fmtUptime, fmtBytes, errMessage } from '@/lib/utils';
|
2026-05-03 19:51:57 -05:00
|
|
|
import { Button } from '@/components/ui/button';
|
2026-07-03 10:05:37 -05:00
|
|
|
import { Switch } from '@/components/ui/switch';
|
2026-05-03 19:51:57 -05:00
|
|
|
import { MarkdownText } from '@/components/MarkdownText';
|
|
|
|
|
|
2026-07-04 22:33:15 -05:00
|
|
|
type Dict = Record<string, unknown>;
|
|
|
|
|
|
|
|
|
|
interface UpdateData {
|
|
|
|
|
has_update?: boolean;
|
|
|
|
|
up_to_date?: boolean | null;
|
|
|
|
|
error?: string;
|
|
|
|
|
latest_version?: string;
|
|
|
|
|
current_version?: string;
|
|
|
|
|
latest_release_url?: string;
|
|
|
|
|
last_checked_at?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface Note {
|
|
|
|
|
category?: string;
|
|
|
|
|
text?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface VersionData {
|
|
|
|
|
version?: string;
|
|
|
|
|
notes?: Note[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface HistoryMeta {
|
|
|
|
|
version?: string;
|
|
|
|
|
updated_at?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ErrItem {
|
|
|
|
|
timestamp?: string;
|
|
|
|
|
message?: string;
|
|
|
|
|
source?: string;
|
|
|
|
|
type?: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 19:58:52 -05:00
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-07-04 22:33:15 -05:00
|
|
|
function formatMemory(runtime: Dict): string | null {
|
2026-07-06 14:23:53 -05:00
|
|
|
const memUsed =
|
|
|
|
|
runtime.memory_used_bytes ??
|
|
|
|
|
(runtime.memory as { used?: unknown } | undefined)?.used ??
|
|
|
|
|
runtime.memory;
|
2026-07-04 22:33:15 -05:00
|
|
|
if (memUsed) return fmtBytes(memUsed as number);
|
2026-05-03 19:51:57 -05:00
|
|
|
if (runtime.memory_mb) return `${runtime.memory_mb} MB`;
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-04 22:33:15 -05:00
|
|
|
function formatBytesMaybe(value: unknown): string | null {
|
|
|
|
|
return value === undefined || value === null ? null : fmtBytes(value as number);
|
2026-05-03 19:51:57 -05:00
|
|
|
}
|
|
|
|
|
|
2026-07-04 22:33:15 -05:00
|
|
|
function formatDateTime(value: unknown): string | null {
|
2026-05-03 19:51:57 -05:00
|
|
|
if (!value) return null;
|
2026-07-04 22:33:15 -05:00
|
|
|
const date = new Date(value as string | number);
|
|
|
|
|
if (Number.isNaN(date.getTime())) return String(value);
|
2026-05-03 19:51:57 -05:00
|
|
|
return date.toLocaleString();
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 19:58:52 -05:00
|
|
|
// ─── Design primitives ────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-07-04 22:33:15 -05:00
|
|
|
function SectionLabel({ children }: { children: ReactNode }) {
|
2026-05-29 19:58:52 -05:00
|
|
|
return (
|
|
|
|
|
<div className="flex items-center gap-3 mb-3 mt-8 first:mt-0">
|
|
|
|
|
<span className="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground/50 whitespace-nowrap">
|
|
|
|
|
{children}
|
|
|
|
|
</span>
|
|
|
|
|
<div className="flex-1 h-px bg-border/40" />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-04 22:33:15 -05:00
|
|
|
function StatRow({ label, value, last }: { label: ReactNode; value?: unknown; last?: boolean }) {
|
2026-05-29 19:58:52 -05:00
|
|
|
return (
|
2026-07-06 14:23:53 -05:00
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
'flex items-center justify-between py-1.5 gap-3',
|
|
|
|
|
!last && 'border-b border-border/40',
|
|
|
|
|
)}
|
|
|
|
|
>
|
2026-05-29 19:58:52 -05:00
|
|
|
<span className="text-xs text-muted-foreground shrink-0">{label}</span>
|
|
|
|
|
<span className="text-xs font-medium tabular-nums text-right truncate max-w-[60%]">
|
2026-07-04 22:33:15 -05:00
|
|
|
{value == null ? <span className="text-muted-foreground/50">—</span> : (value as ReactNode)}
|
2026-05-29 19:58:52 -05:00
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-04 22:33:15 -05:00
|
|
|
function StatusPill({ tone = 'muted', children }: { tone?: string; children: ReactNode }) {
|
2026-07-06 14:23:53 -05:00
|
|
|
const cls =
|
|
|
|
|
(
|
|
|
|
|
{
|
|
|
|
|
good: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/25',
|
|
|
|
|
warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-400/30',
|
|
|
|
|
bad: 'bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/25',
|
|
|
|
|
muted: 'bg-muted/60 text-muted-foreground border-border',
|
|
|
|
|
} as Record<string, string>
|
|
|
|
|
)[tone] ?? 'bg-muted/60 text-muted-foreground border-border';
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
return (
|
2026-07-06 14:23:53 -05:00
|
|
|
<span
|
|
|
|
|
className={cn(
|
|
|
|
|
'inline-flex items-center shrink-0 rounded border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
|
|
|
|
|
cls,
|
|
|
|
|
)}
|
|
|
|
|
>
|
2026-05-03 19:51:57 -05:00
|
|
|
{children}
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
function StatusCard({
|
|
|
|
|
title,
|
|
|
|
|
icon: Icon,
|
|
|
|
|
status,
|
|
|
|
|
tone = 'muted',
|
|
|
|
|
children,
|
|
|
|
|
className,
|
|
|
|
|
}: {
|
2026-07-04 22:33:15 -05:00
|
|
|
title: ReactNode;
|
|
|
|
|
icon?: ComponentType<{ className?: string }>;
|
|
|
|
|
status?: ReactNode;
|
|
|
|
|
tone?: string;
|
|
|
|
|
children: ReactNode;
|
|
|
|
|
className?: string;
|
|
|
|
|
}) {
|
2026-07-06 14:23:53 -05:00
|
|
|
const accentCls =
|
|
|
|
|
(
|
|
|
|
|
{
|
|
|
|
|
good: 'border-t-emerald-500/60',
|
|
|
|
|
warn: 'border-t-amber-400/70',
|
|
|
|
|
bad: 'border-t-red-500/60',
|
|
|
|
|
muted: 'border-t-border/60',
|
|
|
|
|
} as Record<string, string>
|
|
|
|
|
)[tone] ?? 'border-t-border/60';
|
2026-05-29 19:58:52 -05:00
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
return (
|
2026-07-06 14:23:53 -05:00
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
'flex flex-col bg-card rounded-xl border border-border border-t-[3px] shadow-sm',
|
|
|
|
|
accentCls,
|
|
|
|
|
className,
|
|
|
|
|
)}
|
|
|
|
|
>
|
2026-05-29 19:58:52 -05:00
|
|
|
<div className="flex items-center justify-between gap-2 px-5 pt-4 pb-3 border-b border-border/30">
|
|
|
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
|
|
|
{Icon && <Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground/50" />}
|
|
|
|
|
<h3 className="text-[11px] font-bold uppercase tracking-widest text-muted-foreground/80 truncate">
|
|
|
|
|
{title}
|
|
|
|
|
</h3>
|
|
|
|
|
</div>
|
|
|
|
|
{status != null && <StatusPill tone={tone}>{status}</StatusPill>}
|
2026-05-03 19:51:57 -05:00
|
|
|
</div>
|
2026-05-29 19:58:52 -05:00
|
|
|
<div className="px-5 py-3 flex-1">{children}</div>
|
2026-05-03 19:51:57 -05:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 19:58:52 -05:00
|
|
|
function SkeletonCard() {
|
2026-05-03 19:51:57 -05:00
|
|
|
return (
|
2026-05-29 19:58:52 -05:00
|
|
|
<div className="flex flex-col bg-card rounded-xl border border-border border-t-[3px] border-t-border/60 shadow-sm animate-pulse">
|
|
|
|
|
<div className="flex items-center justify-between gap-2 px-5 pt-4 pb-3 border-b border-border/30">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<div className="h-3.5 w-3.5 rounded bg-muted/70" />
|
|
|
|
|
<div className="h-3 w-20 rounded bg-muted/70" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="h-4 w-14 rounded bg-muted/50" />
|
2026-05-03 19:51:57 -05:00
|
|
|
</div>
|
2026-05-29 19:58:52 -05:00
|
|
|
<div className="px-5 py-3 space-y-0">
|
2026-07-06 14:23:53 -05:00
|
|
|
{[1, 2, 3].map((i) => (
|
|
|
|
|
<div
|
|
|
|
|
key={i}
|
|
|
|
|
className="flex justify-between items-center py-1.5 border-b border-border/40 last:border-0"
|
|
|
|
|
>
|
2026-05-29 19:58:52 -05:00
|
|
|
<div className="h-3 w-20 rounded bg-muted/60" />
|
|
|
|
|
<div className="h-3 w-14 rounded bg-muted/50" />
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
2026-05-03 19:51:57 -05:00
|
|
|
</div>
|
2026-05-29 19:58:52 -05:00
|
|
|
</div>
|
2026-05-03 19:51:57 -05:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 19:58:52 -05:00
|
|
|
// ─── Update Card ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
const CATEGORY_ORDER = ['Added', 'Changed', 'Fixed', 'Removed', 'Deprecated', 'Security'];
|
2026-05-14 21:00:07 -05:00
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
function UpdateCard({
|
|
|
|
|
update,
|
|
|
|
|
onCheckNow,
|
|
|
|
|
checking,
|
|
|
|
|
enabled = true,
|
|
|
|
|
onToggle,
|
|
|
|
|
}: {
|
2026-07-04 22:33:15 -05:00
|
|
|
update: UpdateData;
|
|
|
|
|
onCheckNow: () => void;
|
|
|
|
|
checking: boolean;
|
|
|
|
|
enabled?: boolean;
|
|
|
|
|
onToggle: (enabled: boolean) => void;
|
|
|
|
|
}) {
|
2026-05-29 19:58:52 -05:00
|
|
|
const hasUpdate = !!update.has_update;
|
2026-07-06 14:23:53 -05:00
|
|
|
const isKnown = update.up_to_date !== null && update.up_to_date !== undefined;
|
|
|
|
|
const hasError = !!update.error;
|
|
|
|
|
|
|
|
|
|
const tone = hasUpdate ? 'warn' : isKnown && !hasError ? 'good' : 'muted';
|
|
|
|
|
const status = hasUpdate
|
|
|
|
|
? 'Update Available'
|
|
|
|
|
: hasError
|
|
|
|
|
? 'Check Failed'
|
|
|
|
|
: isKnown
|
|
|
|
|
? 'Up to Date'
|
|
|
|
|
: 'Unknown';
|
2026-05-14 21:00:07 -05:00
|
|
|
|
2026-05-29 19:58:52 -05:00
|
|
|
const StatusIcon = hasUpdate ? ArrowUpCircle : hasError ? AlertCircle : CheckCircle2;
|
2026-07-06 14:23:53 -05:00
|
|
|
const iconCls = hasUpdate
|
|
|
|
|
? 'text-amber-500'
|
|
|
|
|
: hasError
|
|
|
|
|
? 'text-red-500'
|
|
|
|
|
: isKnown
|
|
|
|
|
? 'text-emerald-500'
|
|
|
|
|
: 'text-muted-foreground';
|
2026-05-14 21:00:07 -05:00
|
|
|
|
|
|
|
|
return (
|
2026-05-29 19:58:52 -05:00
|
|
|
<StatusCard title="Software Update" icon={RefreshCw} status={status} tone={tone}>
|
|
|
|
|
<div className="flex items-center gap-2 py-1.5 border-b border-border/40">
|
|
|
|
|
<StatusIcon className={cn('h-3.5 w-3.5 shrink-0', iconCls)} />
|
|
|
|
|
<span className="text-xs font-medium">
|
2026-07-06 14:23:53 -05:00
|
|
|
{hasUpdate
|
|
|
|
|
? `v${update.latest_version} is available`
|
|
|
|
|
: isKnown && !hasError
|
|
|
|
|
? 'Running the latest version'
|
|
|
|
|
: hasError
|
|
|
|
|
? 'Could not reach update server'
|
|
|
|
|
: 'Update status unknown'}
|
2026-05-14 21:00:07 -05:00
|
|
|
</span>
|
|
|
|
|
</div>
|
2026-07-06 14:23:53 -05:00
|
|
|
<StatRow
|
|
|
|
|
label="Running"
|
|
|
|
|
value={update.current_version ? `v${update.current_version}` : null}
|
|
|
|
|
/>
|
2026-05-29 19:58:52 -05:00
|
|
|
<div className="flex items-center justify-between py-1.5 border-b border-border/40 gap-3">
|
|
|
|
|
<span className="text-xs text-muted-foreground shrink-0">Latest</span>
|
2026-05-14 21:00:07 -05:00
|
|
|
{update.latest_version ? (
|
|
|
|
|
update.latest_release_url ? (
|
2026-07-06 14:23:53 -05:00
|
|
|
<a
|
|
|
|
|
href={update.latest_release_url}
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noopener noreferrer"
|
|
|
|
|
className={cn(
|
|
|
|
|
'text-xs font-medium underline-offset-2 hover:underline truncate',
|
|
|
|
|
hasUpdate ? 'text-amber-500' : '',
|
|
|
|
|
)}
|
|
|
|
|
>
|
2026-05-14 21:00:07 -05:00
|
|
|
v{update.latest_version} ↗
|
|
|
|
|
</a>
|
|
|
|
|
) : (
|
2026-05-29 19:58:52 -05:00
|
|
|
<span className="text-xs font-medium">v{update.latest_version}</span>
|
2026-05-14 21:00:07 -05:00
|
|
|
)
|
|
|
|
|
) : (
|
2026-05-29 19:58:52 -05:00
|
|
|
<span className="text-xs text-muted-foreground/50">—</span>
|
2026-05-14 21:00:07 -05:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<StatRow label="Last Checked" value={formatDateTime(update.last_checked_at)} />
|
|
|
|
|
{update.error && (
|
2026-05-29 19:58:52 -05:00
|
|
|
<p className="text-[11px] text-red-400 leading-relaxed pt-1.5">{update.error}</p>
|
2026-05-14 21:00:07 -05:00
|
|
|
)}
|
2026-07-03 10:05:37 -05:00
|
|
|
<div className="flex items-center justify-between gap-3 py-1.5 border-t border-border/40 mt-1">
|
|
|
|
|
<span className="text-xs text-muted-foreground">
|
|
|
|
|
Automatic version check
|
2026-07-06 14:23:53 -05:00
|
|
|
<span className="block text-[10px] text-muted-foreground/60">
|
|
|
|
|
External request; off = no phone-home
|
|
|
|
|
</span>
|
2026-07-03 10:05:37 -05:00
|
|
|
</span>
|
2026-07-06 14:23:53 -05:00
|
|
|
<Switch
|
|
|
|
|
checked={enabled}
|
|
|
|
|
onCheckedChange={onToggle}
|
|
|
|
|
aria-label="Enable automatic version check"
|
|
|
|
|
/>
|
2026-07-03 10:05:37 -05:00
|
|
|
</div>
|
2026-05-14 21:00:07 -05:00
|
|
|
<div className="pt-3">
|
2026-07-06 14:23:53 -05:00
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={onCheckNow}
|
|
|
|
|
disabled={checking || !enabled}
|
|
|
|
|
className="h-7 text-xs gap-1.5"
|
|
|
|
|
>
|
2026-05-14 21:00:07 -05:00
|
|
|
<RefreshCw className={cn('h-3 w-3', checking && 'animate-spin')} />
|
|
|
|
|
{checking ? 'Checking…' : 'Check Now'}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</StatusCard>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 19:58:52 -05:00
|
|
|
// ─── Release Notes Card ───────────────────────────────────────────────────────
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
function ReleaseNotesCard({
|
|
|
|
|
version,
|
|
|
|
|
historyMeta,
|
|
|
|
|
}: {
|
|
|
|
|
version: VersionData | null;
|
|
|
|
|
historyMeta: HistoryMeta | null;
|
|
|
|
|
}) {
|
2026-05-29 19:58:52 -05:00
|
|
|
if (!version) return <SkeletonCard />;
|
|
|
|
|
|
2026-07-04 22:33:15 -05:00
|
|
|
const grouped = CATEGORY_ORDER.reduce<Record<string, Note[]>>((acc, cat) => {
|
2026-07-06 14:23:53 -05:00
|
|
|
const notes = version.notes?.filter((n) => n.category === cat) ?? [];
|
2026-05-29 19:58:52 -05:00
|
|
|
if (notes.length) acc[cat] = notes;
|
|
|
|
|
return acc;
|
|
|
|
|
}, {});
|
2026-07-06 14:23:53 -05:00
|
|
|
version.notes?.forEach((n) => {
|
2026-07-04 22:33:15 -05:00
|
|
|
const cat = n.category ?? '';
|
|
|
|
|
if (!grouped[cat]) grouped[cat] = [...(grouped[cat] ?? []), n];
|
2026-05-29 19:58:52 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const categories = Object.keys(grouped);
|
2026-07-04 22:33:15 -05:00
|
|
|
const first = categories[0];
|
|
|
|
|
const preview = first ? grouped[first]?.[0]?.text : null;
|
2026-05-29 19:58:52 -05:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<StatusCard title="Release Notes" icon={ScrollText} status={`v${version.version}`} tone="good">
|
2026-07-06 14:23:53 -05:00
|
|
|
<StatRow
|
|
|
|
|
label="Total Changes"
|
|
|
|
|
value={version.notes?.length ? `${version.notes.length} items` : null}
|
|
|
|
|
/>
|
|
|
|
|
<StatRow label="Last Updated" value={formatDateTime(historyMeta?.updated_at)} />
|
2026-05-29 19:58:52 -05:00
|
|
|
<div className="py-1.5 border-b border-border/40">
|
|
|
|
|
<p className="text-[11px] text-muted-foreground/60 uppercase tracking-wide mb-1">Preview</p>
|
|
|
|
|
<p className="text-xs font-medium line-clamp-2 leading-relaxed">
|
|
|
|
|
<MarkdownText text={preview ?? 'No release notes available.'} />
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="pt-3">
|
|
|
|
|
<Button asChild variant="outline" size="sm" className="h-7 text-xs">
|
|
|
|
|
<Link to="/release-notes">View Full Notes</Link>
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</StatusCard>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
// ─── StatusPage ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export default function StatusPage() {
|
2026-07-06 14:23:53 -05:00
|
|
|
const [data, setData] = useState<Dict | null>(null);
|
|
|
|
|
const [version, setVersion] = useState<VersionData | null>(null);
|
|
|
|
|
const [historyMeta, setHistoryMeta] = useState<HistoryMeta | null>(null);
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
const [updateData, setUpdateData] = useState<UpdateData | null>(null);
|
2026-05-14 21:00:07 -05:00
|
|
|
const [updateChecking, setUpdateChecking] = useState(false);
|
2026-07-06 14:23:53 -05:00
|
|
|
const [updateEnabled, setUpdateEnabled] = useState(true);
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
const load = useCallback(async () => {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
setVersion(null);
|
|
|
|
|
setHistoryMeta(null);
|
|
|
|
|
try {
|
2026-07-04 22:33:15 -05:00
|
|
|
const [statusData, versionData] = await Promise.all([
|
|
|
|
|
api.status() as Promise<Dict>,
|
|
|
|
|
api.version() as Promise<VersionData>,
|
|
|
|
|
]);
|
2026-05-03 19:51:57 -05:00
|
|
|
setData(statusData);
|
2026-07-04 22:33:15 -05:00
|
|
|
setUpdateData((statusData?.update ?? null) as UpdateData | null);
|
2026-05-03 19:51:57 -05:00
|
|
|
setVersion(versionData);
|
|
|
|
|
try {
|
2026-07-06 14:23:53 -05:00
|
|
|
const historyData = (await api.releaseHistory()) as HistoryMeta;
|
2026-05-29 19:58:52 -05:00
|
|
|
setHistoryMeta({ version: historyData.version, updated_at: historyData.updated_at });
|
2026-07-06 14:23:53 -05:00
|
|
|
} catch {
|
|
|
|
|
setHistoryMeta(null);
|
|
|
|
|
}
|
2026-07-03 10:05:37 -05:00
|
|
|
try {
|
2026-07-06 14:23:53 -05:00
|
|
|
const s = (await api.getUpdateCheckSetting()) as { enabled?: boolean };
|
2026-07-03 10:05:37 -05:00
|
|
|
setUpdateEnabled(s.enabled !== false);
|
2026-07-06 14:23:53 -05:00
|
|
|
} catch {
|
|
|
|
|
/* keep default */
|
|
|
|
|
}
|
2026-05-03 19:51:57 -05:00
|
|
|
} catch (err) {
|
2026-07-04 22:33:15 -05:00
|
|
|
toast.error(errMessage(err, 'Failed to load status.'));
|
2026-05-03 19:51:57 -05:00
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
useEffect(() => {
|
|
|
|
|
load();
|
|
|
|
|
}, [load]);
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-05-14 21:00:07 -05:00
|
|
|
const handleCheckNow = useCallback(async () => {
|
|
|
|
|
setUpdateChecking(true);
|
|
|
|
|
try {
|
2026-07-06 14:23:53 -05:00
|
|
|
setUpdateData((await api.checkForUpdates()) as UpdateData);
|
2026-05-14 21:00:07 -05:00
|
|
|
} catch (err) {
|
2026-07-04 22:33:15 -05:00
|
|
|
toast.error(errMessage(err, 'Update check failed'));
|
2026-05-14 21:00:07 -05:00
|
|
|
} finally {
|
|
|
|
|
setUpdateChecking(false);
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
const handleToggleUpdateCheck = useCallback(
|
|
|
|
|
async (enabled: boolean) => {
|
|
|
|
|
setUpdateEnabled(enabled); // optimistic
|
|
|
|
|
try {
|
|
|
|
|
await api.setUpdateCheckSetting(enabled);
|
|
|
|
|
if (enabled) handleCheckNow();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setUpdateEnabled(!enabled); // revert
|
|
|
|
|
toast.error(errMessage(err, 'Failed to update setting'));
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[handleCheckNow],
|
|
|
|
|
);
|
2026-07-03 10:05:37 -05:00
|
|
|
|
2026-05-29 19:58:52 -05:00
|
|
|
// Normalize the nested response shape
|
2026-07-06 14:23:53 -05:00
|
|
|
const app = (data?.application ?? data?.app ?? {}) as Dict;
|
|
|
|
|
const rt = (data?.runtime ?? {}) as Dict;
|
|
|
|
|
const db = (data?.database ?? data?.db ?? {}) as Dict;
|
|
|
|
|
const stats = (data?.statistics ?? data?.stats ?? {}) as Dict;
|
|
|
|
|
const worker = (data?.worker ?? data?.jobs ?? {}) as Dict;
|
2026-07-04 22:33:15 -05:00
|
|
|
const notifications = (data?.notifications ?? data?.email ?? {}) as Dict;
|
2026-07-06 14:23:53 -05:00
|
|
|
const backups = (data?.backups ?? data?.backup ?? {}) as Dict;
|
|
|
|
|
const server = (data?.server ?? data?.clock ?? {}) as Dict;
|
|
|
|
|
const tracker = (data?.tracker ?? data?.tracker_health ?? {}) as Dict;
|
|
|
|
|
const cleanup = (data?.cleanup ?? {}) as Dict;
|
|
|
|
|
const bankSync = (data?.bank_sync ?? data?.simplefin ?? {}) as Dict;
|
|
|
|
|
const errors = (data?.errors ?? data?.recent_errors ?? []) as ErrItem[];
|
|
|
|
|
|
|
|
|
|
const dbOk = db.connected ?? (db.status ? db.status === 'connected' : true);
|
|
|
|
|
const workerOk = !worker.enabled ? null : worker.last_error ? false : true;
|
2026-05-03 19:51:57 -05:00
|
|
|
const notificationsConfigured = notifications.configured ?? notifications.smtp_configured ?? null;
|
2026-07-06 14:23:53 -05:00
|
|
|
const backupsEnabled = backups.enabled ?? null;
|
|
|
|
|
const backupsScheduled = backups.scheduled_enabled ?? null;
|
|
|
|
|
const backupsStatus =
|
|
|
|
|
backupsEnabled === null
|
|
|
|
|
? 'Pending'
|
|
|
|
|
: !backupsEnabled
|
|
|
|
|
? 'Disabled'
|
|
|
|
|
: backupsScheduled
|
|
|
|
|
? 'Scheduled'
|
|
|
|
|
: 'Manual Only';
|
|
|
|
|
const backupsTone =
|
|
|
|
|
backupsEnabled === null
|
|
|
|
|
? 'muted'
|
|
|
|
|
: !backupsEnabled
|
|
|
|
|
? 'warn'
|
|
|
|
|
: backupsScheduled
|
|
|
|
|
? 'good'
|
|
|
|
|
: 'warn';
|
|
|
|
|
const recentErrors = Array.isArray(errors) ? errors : [];
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-05-29 19:21:46 -05:00
|
|
|
const bankSyncEnabled = bankSync.enabled ?? false;
|
2026-07-06 14:23:53 -05:00
|
|
|
const bankSyncStatus = !bankSyncEnabled
|
|
|
|
|
? 'Disabled'
|
|
|
|
|
: bankSync.running
|
|
|
|
|
? 'Syncing'
|
|
|
|
|
: bankSync.last_error
|
|
|
|
|
? 'Error'
|
|
|
|
|
: Number(bankSync.source_count ?? 0) > 0
|
|
|
|
|
? 'Connected'
|
|
|
|
|
: 'No Sources';
|
|
|
|
|
const bankSyncTone = !bankSyncEnabled
|
|
|
|
|
? 'muted'
|
|
|
|
|
: bankSync.last_error
|
|
|
|
|
? 'warn'
|
|
|
|
|
: Number(bankSync.source_count ?? 0) > 0
|
|
|
|
|
? 'good'
|
|
|
|
|
: 'warn';
|
2026-05-29 19:21:46 -05:00
|
|
|
|
2026-07-04 22:33:15 -05:00
|
|
|
const off = server.utc_offset;
|
2026-07-06 14:23:53 -05:00
|
|
|
const utcOffset = off != null ? `UTC${(off as number) >= 0 ? '+' : ''}${off}` : null;
|
2026-05-29 19:58:52 -05:00
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
const lastResult = cleanup.last_result as
|
|
|
|
|
| {
|
|
|
|
|
import_sessions?: { pruned?: number };
|
|
|
|
|
temp_export_files?: { removed?: number };
|
|
|
|
|
backup_partials?: { removed?: number };
|
|
|
|
|
}
|
|
|
|
|
| undefined;
|
2026-07-04 22:33:15 -05:00
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
return (
|
|
|
|
|
<div>
|
2026-05-29 19:58:52 -05:00
|
|
|
{/* ── Page header ──────────────────────────────────────────────── */}
|
|
|
|
|
<div className="flex flex-col gap-3 mb-6 sm:flex-row sm:items-start sm:justify-between">
|
2026-05-03 19:51:57 -05:00
|
|
|
<div>
|
|
|
|
|
<h1 className="text-2xl font-bold tracking-tight">Server Status</h1>
|
2026-05-29 19:58:52 -05:00
|
|
|
<p className="text-sm text-muted-foreground mt-0.5">Health and operational status</p>
|
2026-05-03 19:51:57 -05:00
|
|
|
</div>
|
2026-07-06 14:23:53 -05:00
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={load}
|
|
|
|
|
disabled={loading}
|
|
|
|
|
className="self-start"
|
|
|
|
|
>
|
2026-05-03 19:51:57 -05:00
|
|
|
<RefreshCw className={cn('h-3.5 w-3.5 mr-1.5', loading && 'animate-spin')} />
|
|
|
|
|
Refresh
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-05-29 19:58:52 -05:00
|
|
|
{/* ── Health banner ────────────────────────────────────────────── */}
|
|
|
|
|
{!loading && data && (
|
2026-07-06 14:23:53 -05:00
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
'flex items-center gap-3 px-4 py-2.5 rounded-lg border mb-6',
|
|
|
|
|
data.ok ? 'bg-emerald-500/5 border-emerald-500/20' : 'bg-red-500/5 border-red-500/20',
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<span
|
|
|
|
|
className={cn(
|
|
|
|
|
'h-2 w-2 rounded-full shrink-0',
|
|
|
|
|
data.ok
|
|
|
|
|
? 'bg-emerald-500 shadow-[0_0_0_3px_rgb(34_197_94/0.15)]'
|
|
|
|
|
: 'bg-red-500 shadow-[0_0_0_3px_rgb(239_68_68/0.15)]',
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
<span
|
|
|
|
|
className={cn(
|
|
|
|
|
'text-sm font-semibold',
|
|
|
|
|
data.ok ? 'text-emerald-700 dark:text-emerald-400' : 'text-red-600 dark:text-red-400',
|
|
|
|
|
)}
|
|
|
|
|
>
|
2026-05-29 19:58:52 -05:00
|
|
|
{data.ok ? 'All systems operational' : 'One or more systems need attention'}
|
|
|
|
|
</span>
|
|
|
|
|
<div className="ml-auto hidden sm:flex items-center gap-4 text-xs text-muted-foreground tabular-nums">
|
2026-07-06 14:23:53 -05:00
|
|
|
{(app.version ?? data?.version) ? (
|
|
|
|
|
<span>v{String(app.version ?? data?.version)}</span>
|
|
|
|
|
) : null}
|
2026-07-04 22:33:15 -05:00
|
|
|
<span>{fmtUptime(Number(app.uptime_seconds ?? data?.uptime_seconds ?? 0))} uptime</span>
|
2026-05-29 19:58:52 -05:00
|
|
|
</div>
|
2026-05-03 19:51:57 -05:00
|
|
|
</div>
|
2026-05-29 19:58:52 -05:00
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* ── Loading skeleton ─────────────────────────────────────────── */}
|
|
|
|
|
{loading ? (
|
|
|
|
|
<>
|
|
|
|
|
{[3, 5].map((count, si) => (
|
|
|
|
|
<React.Fragment key={si}>
|
|
|
|
|
<div className={cn('flex items-center gap-3 mb-3', si > 0 && 'mt-8')}>
|
|
|
|
|
<div className="h-2.5 w-24 rounded bg-muted/60 animate-pulse" />
|
|
|
|
|
<div className="flex-1 h-px bg-border/40" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 mb-2">
|
2026-07-06 14:23:53 -05:00
|
|
|
{Array.from({ length: count }).map((_, i) => (
|
|
|
|
|
<SkeletonCard key={i} />
|
|
|
|
|
))}
|
2026-05-29 19:58:52 -05:00
|
|
|
</div>
|
|
|
|
|
</React.Fragment>
|
|
|
|
|
))}
|
|
|
|
|
</>
|
2026-05-03 19:51:57 -05:00
|
|
|
) : (
|
2026-05-29 19:58:52 -05:00
|
|
|
<>
|
|
|
|
|
{/* ── Infrastructure ─────────────────────────────────────────── */}
|
|
|
|
|
<SectionLabel>Infrastructure</SectionLabel>
|
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
2026-07-06 14:23:53 -05:00
|
|
|
<StatusCard
|
|
|
|
|
title="Application"
|
|
|
|
|
icon={Globe}
|
2026-05-29 19:58:52 -05:00
|
|
|
status={data?.ok ? 'Online' : 'Degraded'}
|
|
|
|
|
tone={data?.ok ? 'good' : 'bad'}
|
|
|
|
|
>
|
2026-07-06 14:23:53 -05:00
|
|
|
<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(
|
|
|
|
|
Number(app.uptime_seconds ?? app.uptime ?? data?.uptime_seconds ?? 0),
|
|
|
|
|
)}
|
|
|
|
|
last
|
|
|
|
|
/>
|
2026-05-29 19:58:52 -05:00
|
|
|
</StatusCard>
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
<StatusCard
|
|
|
|
|
title="Database"
|
|
|
|
|
icon={Database}
|
2026-05-29 19:58:52 -05:00
|
|
|
status={dbOk ? 'Connected' : 'Error'}
|
|
|
|
|
tone={dbOk ? 'good' : 'bad'}
|
|
|
|
|
>
|
2026-07-06 14:23:53 -05:00
|
|
|
<StatRow label="Size" value={formatBytesMaybe(db.size_bytes ?? db.size)} />
|
|
|
|
|
<StatRow
|
|
|
|
|
label="Last Modified"
|
|
|
|
|
value={formatDateTime(db.last_modified ?? db.modified_at)}
|
|
|
|
|
/>
|
|
|
|
|
{dbOk ? (
|
|
|
|
|
<StatRow label="Health" value="Healthy" last />
|
|
|
|
|
) : (
|
|
|
|
|
<StatRow label="Error" value={db.last_error} last />
|
|
|
|
|
)}
|
2026-05-29 19:58:52 -05:00
|
|
|
</StatusCard>
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-05-29 19:58:52 -05:00
|
|
|
<StatusCard title="Runtime" icon={Cpu}>
|
2026-07-06 14:23:53 -05:00
|
|
|
<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 />
|
2026-05-29 19:58:52 -05:00
|
|
|
</StatusCard>
|
2026-05-03 19:51:57 -05:00
|
|
|
</div>
|
|
|
|
|
|
2026-05-29 19:58:52 -05:00
|
|
|
{/* ── Services ───────────────────────────────────────────────── */}
|
|
|
|
|
<SectionLabel>Services</SectionLabel>
|
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
2026-07-06 14:23:53 -05:00
|
|
|
<StatusCard
|
|
|
|
|
title="Daily Worker"
|
|
|
|
|
icon={CalendarClock}
|
2026-05-29 19:21:46 -05:00
|
|
|
status={workerOk === null ? 'Pending' : workerOk ? 'Running' : 'Error'}
|
2026-05-03 19:51:57 -05:00
|
|
|
tone={workerOk === null ? 'muted' : workerOk ? 'good' : 'bad'}
|
|
|
|
|
>
|
2026-07-06 14:23:53 -05:00
|
|
|
<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)} />
|
2026-05-03 19:51:57 -05:00
|
|
|
<StatRow label="Last Error" value={worker.last_error} last />
|
|
|
|
|
</StatusCard>
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
<StatusCard
|
|
|
|
|
title="SimpleFIN Sync"
|
|
|
|
|
icon={Landmark}
|
2026-05-29 19:21:46 -05:00
|
|
|
status={bankSyncStatus}
|
|
|
|
|
tone={bankSyncTone}
|
|
|
|
|
>
|
2026-07-06 14:23:53 -05:00
|
|
|
<StatRow
|
|
|
|
|
label="Connections"
|
|
|
|
|
value={bankSyncEnabled ? (bankSync.source_count ?? 0) : null}
|
|
|
|
|
/>
|
|
|
|
|
<StatRow
|
|
|
|
|
label="Accounts"
|
|
|
|
|
value={bankSyncEnabled ? (bankSync.account_count ?? 0) : null}
|
|
|
|
|
/>
|
|
|
|
|
<StatRow
|
|
|
|
|
label="Last Sync"
|
|
|
|
|
value={bankSyncEnabled ? formatDateTime(bankSync.last_sync_at) : null}
|
|
|
|
|
/>
|
|
|
|
|
<StatRow
|
|
|
|
|
label="Next Check"
|
|
|
|
|
value={bankSyncEnabled ? formatDateTime(bankSync.next_run_at) : null}
|
|
|
|
|
/>
|
|
|
|
|
<StatRow
|
|
|
|
|
label="Interval"
|
|
|
|
|
value={
|
|
|
|
|
bankSyncEnabled && bankSync.interval_hours
|
|
|
|
|
? `Every ${bankSync.interval_hours}h`
|
|
|
|
|
: null
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
<StatRow label="Last Error" value={bankSync.last_error} last />
|
2026-05-29 19:21:46 -05:00
|
|
|
</StatusCard>
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
<StatusCard
|
|
|
|
|
title="Notifications"
|
|
|
|
|
icon={Bell}
|
|
|
|
|
status={
|
|
|
|
|
notificationsConfigured === null
|
|
|
|
|
? 'Pending'
|
|
|
|
|
: notificationsConfigured
|
|
|
|
|
? 'Configured'
|
|
|
|
|
: 'Missing'
|
|
|
|
|
}
|
|
|
|
|
tone={
|
|
|
|
|
notificationsConfigured === null
|
|
|
|
|
? 'muted'
|
|
|
|
|
: notificationsConfigured
|
|
|
|
|
? 'good'
|
|
|
|
|
: 'warn'
|
|
|
|
|
}
|
2026-05-03 19:51:57 -05:00
|
|
|
>
|
2026-07-06 14:23:53 -05:00
|
|
|
<StatRow
|
|
|
|
|
label="Enabled"
|
|
|
|
|
value={
|
|
|
|
|
notifications.enabled === undefined ? null : notifications.enabled ? 'Yes' : 'No'
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
<StatRow
|
|
|
|
|
label="SMTP"
|
|
|
|
|
value={
|
|
|
|
|
notificationsConfigured == null
|
|
|
|
|
? null
|
|
|
|
|
: notificationsConfigured
|
|
|
|
|
? 'Configured'
|
|
|
|
|
: 'Not configured'
|
|
|
|
|
}
|
|
|
|
|
/>
|
2026-05-03 19:51:57 -05:00
|
|
|
<StatRow label="Last Sent" value={formatDateTime(notifications.last_sent_at)} />
|
2026-07-06 14:23:53 -05:00
|
|
|
<StatRow
|
|
|
|
|
label="Failures"
|
|
|
|
|
value={notifications.failures ?? notifications.failed_count}
|
|
|
|
|
last
|
|
|
|
|
/>
|
2026-05-03 19:51:57 -05:00
|
|
|
</StatusCard>
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
<StatusCard title="Backups" icon={HardDrive} status={backupsStatus} tone={backupsTone}>
|
|
|
|
|
<StatRow
|
|
|
|
|
label="Schedule"
|
2026-05-30 21:20:51 -05:00
|
|
|
value={
|
2026-07-06 14:23:53 -05:00
|
|
|
backupsEnabled === null
|
|
|
|
|
? null
|
|
|
|
|
: !backupsEnabled
|
|
|
|
|
? 'Disabled'
|
|
|
|
|
: backupsScheduled
|
|
|
|
|
? `${backups.scheduled_frequency ?? ''} ${backups.scheduled_time ?? ''}`.trim() ||
|
|
|
|
|
'Enabled'
|
|
|
|
|
: 'Not scheduled'
|
2026-05-30 21:20:51 -05:00
|
|
|
}
|
|
|
|
|
/>
|
2026-07-06 14:23:53 -05:00
|
|
|
<StatRow label="Last Backup" value={formatDateTime(backups.last_backup_at)} />
|
|
|
|
|
<StatRow label="Next Backup" value={formatDateTime(backups.next_backup_at)} />
|
|
|
|
|
<StatRow label="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 />
|
2026-05-03 19:51:57 -05:00
|
|
|
</StatusCard>
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
<StatusCard
|
|
|
|
|
title="Maintenance"
|
|
|
|
|
icon={Wrench}
|
2026-05-03 19:51:57 -05:00
|
|
|
status={cleanup.last_run_at ? 'OK' : 'Pending'}
|
|
|
|
|
tone={cleanup.last_run_at ? 'good' : 'muted'}
|
|
|
|
|
>
|
2026-07-06 14:23:53 -05:00
|
|
|
<StatRow label="Last Run" value={formatDateTime(cleanup.last_run_at)} />
|
|
|
|
|
<StatRow
|
|
|
|
|
label="Import Sessions"
|
|
|
|
|
value={
|
|
|
|
|
lastResult?.import_sessions?.pruned != null
|
|
|
|
|
? `${lastResult.import_sessions.pruned} pruned`
|
|
|
|
|
: null
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
<StatRow
|
|
|
|
|
label="Temp Files"
|
|
|
|
|
value={
|
|
|
|
|
lastResult?.temp_export_files?.removed != null
|
|
|
|
|
? `${lastResult.temp_export_files.removed} removed`
|
|
|
|
|
: null
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
<StatRow
|
|
|
|
|
label="Backup Partials"
|
|
|
|
|
value={
|
|
|
|
|
lastResult?.backup_partials?.removed != null
|
|
|
|
|
? `${lastResult.backup_partials.removed} removed`
|
|
|
|
|
: null
|
|
|
|
|
}
|
|
|
|
|
last
|
|
|
|
|
/>
|
2026-05-03 19:51:57 -05:00
|
|
|
</StatusCard>
|
2026-05-29 19:58:52 -05:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* ── App Health ─────────────────────────────────────────────── */}
|
|
|
|
|
<SectionLabel>App Health</SectionLabel>
|
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
|
|
|
|
<StatusCard title="Tracker" icon={Activity}>
|
2026-07-06 14:23:53 -05:00
|
|
|
<StatRow label="Bills This Month" value={tracker.bills_this_month} />
|
2026-05-03 19:51:57 -05:00
|
|
|
<StatRow label="Payments This Month" value={tracker.payments_this_month} />
|
2026-07-06 14:23:53 -05:00
|
|
|
<StatRow label="Overdue" value={tracker.overdue_count} />
|
|
|
|
|
<StatRow label="Skipped" value={tracker.skipped_count} last />
|
2026-05-03 19:51:57 -05:00
|
|
|
</StatusCard>
|
|
|
|
|
|
2026-05-29 19:58:52 -05:00
|
|
|
<StatusCard title="Statistics" icon={BarChart3}>
|
2026-07-06 14:23:53 -05:00
|
|
|
<StatRow
|
|
|
|
|
label="Active Bills"
|
|
|
|
|
value={stats.active_bills ?? stats.bills ?? stats.active_bill_count}
|
|
|
|
|
/>
|
2026-05-29 19:58:52 -05:00
|
|
|
<StatRow label="Total Payments" value={stats.total_payments ?? stats.payments} />
|
2026-07-06 14:23:53 -05:00
|
|
|
<StatRow label="Users" value={stats.users ?? stats.user_count} />
|
|
|
|
|
<StatRow label="Sessions" value={stats.active_sessions ?? stats.sessions} last />
|
2026-05-29 19:58:52 -05:00
|
|
|
</StatusCard>
|
|
|
|
|
|
|
|
|
|
<StatusCard title="Server Clock" icon={Clock}>
|
2026-07-06 14:23:53 -05:00
|
|
|
<StatRow
|
|
|
|
|
label="Server Time"
|
|
|
|
|
value={formatDateTime(server.now ?? data?.server_time)}
|
|
|
|
|
/>
|
|
|
|
|
<StatRow label="Date" value={server.today ?? data?.today} />
|
|
|
|
|
<StatRow label="Timezone" value={server.timezone ?? data?.timezone} />
|
|
|
|
|
<StatRow label="UTC Offset" value={utcOffset} last />
|
2026-05-03 19:51:57 -05:00
|
|
|
</StatusCard>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-05-29 19:58:52 -05:00
|
|
|
{/* ── Software ───────────────────────────────────────────────── */}
|
|
|
|
|
<SectionLabel>Software</SectionLabel>
|
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
|
|
|
{updateData && (
|
|
|
|
|
<UpdateCard
|
|
|
|
|
update={updateData}
|
|
|
|
|
onCheckNow={handleCheckNow}
|
|
|
|
|
checking={updateChecking}
|
2026-07-03 10:05:37 -05:00
|
|
|
enabled={updateEnabled}
|
|
|
|
|
onToggle={handleToggleUpdateCheck}
|
2026-05-29 19:58:52 -05:00
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
<ReleaseNotesCard version={version} historyMeta={historyMeta} />
|
|
|
|
|
</div>
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-05-29 19:58:52 -05:00
|
|
|
{/* ── Errors ─────────────────────────────────────────────────── */}
|
|
|
|
|
<SectionLabel>Errors</SectionLabel>
|
|
|
|
|
<StatusCard
|
|
|
|
|
title="Recent Errors"
|
|
|
|
|
icon={AlertCircle}
|
|
|
|
|
status={recentErrors.length ? `${recentErrors.length} Open` : 'None'}
|
|
|
|
|
tone={recentErrors.length ? 'bad' : 'good'}
|
|
|
|
|
>
|
|
|
|
|
{recentErrors.length ? (
|
|
|
|
|
recentErrors.slice(0, 5).map((err, i) => (
|
|
|
|
|
<div
|
|
|
|
|
key={`${err.timestamp ?? err.message ?? i}`}
|
2026-07-06 14:23:53 -05:00
|
|
|
className={cn(
|
|
|
|
|
'py-1.5',
|
|
|
|
|
i !== Math.min(recentErrors.length, 5) - 1 && 'border-b border-border/40',
|
|
|
|
|
)}
|
2026-05-29 19:58:52 -05:00
|
|
|
>
|
|
|
|
|
<div className="flex items-center justify-between gap-2">
|
|
|
|
|
<p className="text-xs font-medium leading-tight truncate">
|
|
|
|
|
{err.source ?? err.type ?? 'Application'}
|
|
|
|
|
</p>
|
|
|
|
|
{err.timestamp && (
|
|
|
|
|
<p className="text-[11px] text-muted-foreground shrink-0 tabular-nums">
|
|
|
|
|
{formatDateTime(err.timestamp)}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<p className="text-[11px] text-muted-foreground mt-0.5 line-clamp-1">
|
|
|
|
|
{err.message ?? String(err)}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
))
|
|
|
|
|
) : (
|
|
|
|
|
<p className="text-xs text-muted-foreground py-0.5">No recent errors recorded.</p>
|
|
|
|
|
)}
|
|
|
|
|
</StatusCard>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2026-05-03 19:51:57 -05:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|