|
|
|
|
@ -1,46 +1,17 @@
|
|
|
|
|
import React, { useState, useEffect, useCallback } from 'react';
|
|
|
|
|
import { Link } from 'react-router-dom';
|
|
|
|
|
import { RefreshCw, ArrowUpCircle, CheckCircle2, AlertCircle } from 'lucide-react';
|
|
|
|
|
import {
|
|
|
|
|
Activity, AlertCircle, ArrowUpCircle, BarChart3, Bell, CalendarClock,
|
|
|
|
|
CheckCircle2, Clock, Cpu, Database, Globe, HardDrive, Landmark,
|
|
|
|
|
RefreshCw, ScrollText, Wrench,
|
|
|
|
|
} 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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function formatMemory(runtime) {
|
|
|
|
|
if (runtime.memory_used_bytes ?? runtime.memory?.used ?? runtime.memory) {
|
|
|
|
|
@ -61,17 +32,41 @@ function formatDateTime(value) {
|
|
|
|
|
return date.toLocaleString();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Design primitives ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function SectionLabel({ children }) {
|
|
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function StatRow({ label, value, last }) {
|
|
|
|
|
return (
|
|
|
|
|
<div className={cn('flex items-center justify-between py-1.5 gap-3', !last && 'border-b border-border/40')}>
|
|
|
|
|
<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%]">
|
|
|
|
|
{value ?? <span className="text-muted-foreground/50">—</span>}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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];
|
|
|
|
|
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',
|
|
|
|
|
}[tone] ?? 'bg-muted/60 text-muted-foreground border-border';
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<span className={cn(
|
|
|
|
|
'inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide',
|
|
|
|
|
'inline-flex items-center shrink-0 rounded border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
|
|
|
|
|
cls,
|
|
|
|
|
)}>
|
|
|
|
|
{children}
|
|
|
|
|
@ -79,143 +74,146 @@ function StatusPill({ tone = 'muted', children }) {
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function StatusCard({ title, status, tone = 'muted', children }) {
|
|
|
|
|
function StatusCard({ title, icon: Icon, status, tone = 'muted', children, className }) {
|
|
|
|
|
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',
|
|
|
|
|
}[tone] ?? 'border-t-border/60';
|
|
|
|
|
|
|
|
|
|
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 className={cn(
|
|
|
|
|
'flex flex-col bg-card rounded-xl border border-border border-t-[3px] shadow-sm',
|
|
|
|
|
accentCls,
|
|
|
|
|
className,
|
|
|
|
|
)}>
|
|
|
|
|
<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>}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-0">{children}</div>
|
|
|
|
|
<div className="px-5 py-3 flex-1">{children}</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function SkeletonCard() {
|
|
|
|
|
return (
|
|
|
|
|
<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" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="px-5 py-3 space-y-0">
|
|
|
|
|
{[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">
|
|
|
|
|
<div className="h-3 w-20 rounded bg-muted/60" />
|
|
|
|
|
<div className="h-3 w-14 rounded bg-muted/50" />
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Update Card ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
const CATEGORY_ORDER = ['Added', 'Changed', 'Fixed', 'Removed', 'Deprecated', 'Security'];
|
|
|
|
|
|
|
|
|
|
// ─── Release Notes Section ────────────────────────────────────────────────────
|
|
|
|
|
function UpdateCard({ update, onCheckNow, checking }) {
|
|
|
|
|
const hasUpdate = !!update.has_update;
|
|
|
|
|
const isKnown = update.up_to_date !== null && update.up_to_date !== undefined;
|
|
|
|
|
const hasError = !!update.error;
|
|
|
|
|
|
|
|
|
|
function ReleaseNotesSection({ version, historyMeta }) {
|
|
|
|
|
if (!version) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="mt-4">
|
|
|
|
|
<div className="h-28 rounded-lg bg-muted/50 animate-pulse" />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
const tone = hasUpdate ? 'warn' : isKnown && !hasError ? 'good' : 'muted';
|
|
|
|
|
const status = hasUpdate ? 'Update Available'
|
|
|
|
|
: hasError ? 'Check Failed'
|
|
|
|
|
: isKnown ? 'Up to Date'
|
|
|
|
|
: 'Unknown';
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
const StatusIcon = hasUpdate ? ArrowUpCircle : hasError ? AlertCircle : CheckCircle2;
|
|
|
|
|
const iconCls = hasUpdate ? 'text-amber-500' : hasError ? 'text-red-500' : isKnown ? 'text-emerald-500' : 'text-muted-foreground';
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<StatusCard title="Release Notes" status={`v${version.version}`} tone="good">
|
|
|
|
|
<StatRow label="Total Changes" value={version.notes?.length ? `${version.notes.length} items` : 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>
|
|
|
|
|
<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">
|
|
|
|
|
{hasUpdate ? `v${update.latest_version} is available`
|
|
|
|
|
: isKnown && !hasError ? 'Running the latest version'
|
|
|
|
|
: hasError ? 'Could not reach update server'
|
|
|
|
|
: 'Update status unknown'}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<StatRow label="Running" value={update.current_version ? `v${update.current_version}` : null} />
|
|
|
|
|
<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>
|
|
|
|
|
{update.latest_version ? (
|
|
|
|
|
update.latest_release_url ? (
|
|
|
|
|
<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' : '')}>
|
|
|
|
|
v{update.latest_version} ↗
|
|
|
|
|
</a>
|
|
|
|
|
) : (
|
|
|
|
|
<span className="text-xs font-medium">v{update.latest_version}</span>
|
|
|
|
|
)
|
|
|
|
|
) : (
|
|
|
|
|
<span className="text-xs text-muted-foreground/50">—</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<StatRow label="Last Checked" value={formatDateTime(update.last_checked_at)} />
|
|
|
|
|
{update.error && (
|
|
|
|
|
<p className="text-[11px] text-red-400 leading-relaxed pt-1.5">{update.error}</p>
|
|
|
|
|
)}
|
|
|
|
|
<div className="pt-3">
|
|
|
|
|
<Button asChild variant="outline" size="sm">
|
|
|
|
|
<Link to="/release-notes">View Full Release Notes</Link>
|
|
|
|
|
<Button variant="outline" size="sm" onClick={onCheckNow} disabled={checking}
|
|
|
|
|
className="h-7 text-xs gap-1.5">
|
|
|
|
|
<RefreshCw className={cn('h-3 w-3', checking && 'animate-spin')} />
|
|
|
|
|
{checking ? 'Checking…' : 'Check Now'}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</StatusCard>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Update Card ─────────────────────────────────────────────────────────────
|
|
|
|
|
// ─── Release Notes Card ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function UpdateCard({ update, onCheckNow, checking }) {
|
|
|
|
|
const hasUpdate = !!update.has_update;
|
|
|
|
|
const isKnown = update.up_to_date !== null && update.up_to_date !== undefined;
|
|
|
|
|
const hasError = !!update.error;
|
|
|
|
|
function ReleaseNotesCard({ version, historyMeta }) {
|
|
|
|
|
if (!version) return <SkeletonCard />;
|
|
|
|
|
|
|
|
|
|
const tone = hasUpdate ? 'warn' : isKnown && !hasError ? 'good' : 'muted';
|
|
|
|
|
const status = hasUpdate ? 'Update Available'
|
|
|
|
|
: hasError ? 'Check Failed'
|
|
|
|
|
: isKnown ? 'Up to Date'
|
|
|
|
|
: 'Unknown';
|
|
|
|
|
const grouped = CATEGORY_ORDER.reduce((acc, cat) => {
|
|
|
|
|
const notes = version.notes?.filter(n => n.category === cat) ?? [];
|
|
|
|
|
if (notes.length) acc[cat] = notes;
|
|
|
|
|
return acc;
|
|
|
|
|
}, {});
|
|
|
|
|
version.notes?.forEach(n => {
|
|
|
|
|
if (!grouped[n.category]) grouped[n.category] = [...(grouped[n.category] ?? []), n];
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const Icon = hasUpdate ? ArrowUpCircle
|
|
|
|
|
: hasError ? AlertCircle
|
|
|
|
|
: CheckCircle2;
|
|
|
|
|
|
|
|
|
|
const iconCls = hasUpdate ? 'text-amber-500'
|
|
|
|
|
: hasError ? 'text-red-500'
|
|
|
|
|
: isKnown ? 'text-emerald-500'
|
|
|
|
|
: 'text-muted-foreground';
|
|
|
|
|
const categories = Object.keys(grouped);
|
|
|
|
|
const preview = categories.length ? grouped[categories[0]]?.[0]?.text : null;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<StatusCard title="Software Update" status={status} tone={tone}>
|
|
|
|
|
<div className="flex items-center gap-2 py-2 border-b border-border/50">
|
|
|
|
|
<Icon className={cn('h-4 w-4 shrink-0', iconCls)} />
|
|
|
|
|
<span className="text-sm font-medium">
|
|
|
|
|
{hasUpdate
|
|
|
|
|
? `v${update.latest_version} is available`
|
|
|
|
|
: isKnown && !hasError
|
|
|
|
|
? 'Running the latest version'
|
|
|
|
|
: hasError
|
|
|
|
|
? 'Could not reach update server'
|
|
|
|
|
: 'Status unknown'}
|
|
|
|
|
</span>
|
|
|
|
|
<StatusCard title="Release Notes" icon={ScrollText} status={`v${version.version}`} tone="good">
|
|
|
|
|
<StatRow label="Total Changes" value={version.notes?.length ? `${version.notes.length} items` : null} />
|
|
|
|
|
<StatRow label="Last Updated" value={formatDateTime(historyMeta?.updated_at)} />
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
<StatRow label="Running" value={update.current_version ? `v${update.current_version}` : null} />
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center justify-between py-2 border-b border-border/50">
|
|
|
|
|
<span className="text-sm text-muted-foreground">Latest Release</span>
|
|
|
|
|
{update.latest_version ? (
|
|
|
|
|
update.latest_release_url ? (
|
|
|
|
|
<a
|
|
|
|
|
href={update.latest_release_url}
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noopener noreferrer"
|
|
|
|
|
className={cn(
|
|
|
|
|
'text-sm font-medium underline-offset-2 hover:underline',
|
|
|
|
|
hasUpdate ? 'text-amber-500' : '',
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
v{update.latest_version} ↗
|
|
|
|
|
</a>
|
|
|
|
|
) : (
|
|
|
|
|
<span className="text-sm font-medium">v{update.latest_version}</span>
|
|
|
|
|
)
|
|
|
|
|
) : (
|
|
|
|
|
<span className="text-sm font-medium text-muted-foreground">—</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<StatRow label="Last Checked" value={formatDateTime(update.last_checked_at)} />
|
|
|
|
|
|
|
|
|
|
{update.error && (
|
|
|
|
|
<div className="py-2 border-b border-border/50">
|
|
|
|
|
<p className="text-xs text-red-400 leading-relaxed">{update.error}</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div className="pt-3">
|
|
|
|
|
<Button variant="outline" size="sm" onClick={onCheckNow} disabled={checking}
|
|
|
|
|
className="gap-1.5">
|
|
|
|
|
<RefreshCw className={cn('h-3 w-3', checking && 'animate-spin')} />
|
|
|
|
|
{checking ? 'Checking…' : 'Check Now'}
|
|
|
|
|
<Button asChild variant="outline" size="sm" className="h-7 text-xs">
|
|
|
|
|
<Link to="/release-notes">View Full Notes</Link>
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</StatusCard>
|
|
|
|
|
@ -225,11 +223,11 @@ function UpdateCard({ update, onCheckNow, checking }) {
|
|
|
|
|
// ─── 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 [updateData, setUpdateData] = useState(null);
|
|
|
|
|
const [data, setData] = useState(null);
|
|
|
|
|
const [version, setVersion] = useState(null);
|
|
|
|
|
const [historyMeta, setHistoryMeta] = useState(null);
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
const [updateData, setUpdateData] = useState(null);
|
|
|
|
|
const [updateChecking, setUpdateChecking] = useState(false);
|
|
|
|
|
|
|
|
|
|
const load = useCallback(async () => {
|
|
|
|
|
@ -237,22 +235,14 @@ export default function StatusPage() {
|
|
|
|
|
setVersion(null);
|
|
|
|
|
setHistoryMeta(null);
|
|
|
|
|
try {
|
|
|
|
|
const [statusData, versionData] = await Promise.all([
|
|
|
|
|
api.status(),
|
|
|
|
|
api.version(),
|
|
|
|
|
]);
|
|
|
|
|
const [statusData, versionData] = await Promise.all([api.status(), api.version()]);
|
|
|
|
|
setData(statusData);
|
|
|
|
|
setUpdateData(statusData?.update ?? null);
|
|
|
|
|
setVersion(versionData);
|
|
|
|
|
try {
|
|
|
|
|
const historyData = await api.releaseHistory();
|
|
|
|
|
setHistoryMeta({
|
|
|
|
|
version: historyData.version,
|
|
|
|
|
updated_at: historyData.updated_at,
|
|
|
|
|
});
|
|
|
|
|
} catch {
|
|
|
|
|
setHistoryMeta(null);
|
|
|
|
|
}
|
|
|
|
|
setHistoryMeta({ version: historyData.version, updated_at: historyData.updated_at });
|
|
|
|
|
} catch { setHistoryMeta(null); }
|
|
|
|
|
} catch (err) {
|
|
|
|
|
toast.error(err.message || 'Failed to load status.');
|
|
|
|
|
} finally {
|
|
|
|
|
@ -265,8 +255,7 @@ export default function StatusPage() {
|
|
|
|
|
const handleCheckNow = useCallback(async () => {
|
|
|
|
|
setUpdateChecking(true);
|
|
|
|
|
try {
|
|
|
|
|
const result = await api.checkForUpdates();
|
|
|
|
|
setUpdateData(result);
|
|
|
|
|
setUpdateData(await api.checkForUpdates());
|
|
|
|
|
} catch (err) {
|
|
|
|
|
toast.error(err.message || 'Update check failed');
|
|
|
|
|
} finally {
|
|
|
|
|
@ -274,118 +263,222 @@ export default function StatusPage() {
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// 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 ?? {};
|
|
|
|
|
// Normalize the nested response shape
|
|
|
|
|
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 bankSync = data?.bank_sync ?? data?.simplefin ?? {};
|
|
|
|
|
const errors = data?.errors ?? data?.recent_errors ?? [];
|
|
|
|
|
const backups = data?.backups ?? data?.backup ?? {};
|
|
|
|
|
const server = data?.server ?? data?.clock ?? {};
|
|
|
|
|
const tracker = data?.tracker ?? data?.tracker_health ?? {};
|
|
|
|
|
const cleanup = data?.cleanup ?? {};
|
|
|
|
|
const bankSync = data?.bank_sync ?? data?.simplefin ?? {};
|
|
|
|
|
const errors = data?.errors ?? data?.recent_errors ?? [];
|
|
|
|
|
|
|
|
|
|
const dbOk = db.connected ?? (db.status === 'connected') ?? true;
|
|
|
|
|
const workerOk = !worker.enabled ? null : worker.last_error ? false : true;
|
|
|
|
|
const dbOk = db.connected ?? (db.status === 'connected') ?? true;
|
|
|
|
|
const workerOk = !worker.enabled ? null : worker.last_error ? false : true;
|
|
|
|
|
const notificationsConfigured = notifications.configured ?? notifications.smtp_configured ?? null;
|
|
|
|
|
const backupsEnabled = backups.enabled ?? null;
|
|
|
|
|
const recentErrors = Array.isArray(errors) ? errors : [];
|
|
|
|
|
const backupsEnabled = backups.enabled ?? null;
|
|
|
|
|
const recentErrors = Array.isArray(errors) ? errors : [];
|
|
|
|
|
|
|
|
|
|
const bankSyncEnabled = bankSync.enabled ?? false;
|
|
|
|
|
const bankSyncStatus = !bankSyncEnabled ? 'Disabled'
|
|
|
|
|
: bankSync.running ? 'Syncing'
|
|
|
|
|
: bankSync.last_error ? 'Error'
|
|
|
|
|
: bankSync.source_count > 0 ? 'Connected'
|
|
|
|
|
const bankSyncStatus = !bankSyncEnabled ? 'Disabled'
|
|
|
|
|
: bankSync.running ? 'Syncing'
|
|
|
|
|
: bankSync.last_error ? 'Error'
|
|
|
|
|
: (bankSync.source_count ?? 0) > 0 ? 'Connected'
|
|
|
|
|
: 'No Sources';
|
|
|
|
|
const bankSyncTone = !bankSyncEnabled ? 'muted'
|
|
|
|
|
: bankSync.last_error ? 'warn'
|
|
|
|
|
: bankSync.source_count > 0 ? 'good'
|
|
|
|
|
const bankSyncTone = !bankSyncEnabled ? 'muted'
|
|
|
|
|
: bankSync.last_error ? 'warn'
|
|
|
|
|
: (bankSync.source_count ?? 0) > 0 ? 'good'
|
|
|
|
|
: 'warn';
|
|
|
|
|
|
|
|
|
|
const utcOffset = server.utc_offset != null
|
|
|
|
|
? `UTC${server.utc_offset >= 0 ? '+' : ''}${server.utc_offset}`
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
|
|
|
|
|
|
|
|
|
{/* Page header — flat on background */}
|
|
|
|
|
<div className="flex flex-col gap-3 mb-8 sm:flex-row sm:items-center sm:justify-between">
|
|
|
|
|
{/* ── Page header ──────────────────────────────────────────────── */}
|
|
|
|
|
<div className="flex flex-col gap-3 mb-6 sm:flex-row sm:items-start sm:justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<h1 className="text-2xl font-bold tracking-tight">Server Status</h1>
|
|
|
|
|
<p className="text-sm text-muted-foreground mt-0.5">
|
|
|
|
|
{!data ? 'Loading…' : data.ok ? 'All systems operational' : 'One or more systems need attention'}
|
|
|
|
|
</p>
|
|
|
|
|
<p className="text-sm text-muted-foreground mt-0.5">Health and operational status</p>
|
|
|
|
|
</div>
|
|
|
|
|
<Button variant="outline" size="sm" onClick={load} disabled={loading}>
|
|
|
|
|
<Button variant="outline" size="sm" onClick={load} disabled={loading} className="self-start">
|
|
|
|
|
<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={data?.ok ? 'Online' : 'Degraded'} tone={data?.ok ? 'good' : 'bad'}>
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
{/* ── Health banner ────────────────────────────────────────────── */}
|
|
|
|
|
{!loading && data && (
|
|
|
|
|
<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',
|
|
|
|
|
)}>
|
|
|
|
|
{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">
|
|
|
|
|
{(app.version ?? data?.version) && <span>v{app.version ?? data?.version}</span>}
|
|
|
|
|
<span>{fmtUptime(app.uptime_seconds ?? data?.uptime_seconds ?? 0)} uptime</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{!loading && (
|
|
|
|
|
{/* ── Loading skeleton ─────────────────────────────────────────── */}
|
|
|
|
|
{loading ? (
|
|
|
|
|
<>
|
|
|
|
|
<div className="mb-3">
|
|
|
|
|
<h2 className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
|
|
|
|
Operations
|
|
|
|
|
</h2>
|
|
|
|
|
{[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">
|
|
|
|
|
{Array.from({ length: count }).map((_, i) => <SkeletonCard key={i} />)}
|
|
|
|
|
</div>
|
|
|
|
|
</React.Fragment>
|
|
|
|
|
))}
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
|
|
|
|
|
{/* ── Infrastructure ─────────────────────────────────────────── */}
|
|
|
|
|
<SectionLabel>Infrastructure</SectionLabel>
|
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
|
|
|
|
|
|
|
|
|
<StatusCard title="Application" icon={Globe}
|
|
|
|
|
status={data?.ok ? 'Online' : 'Degraded'}
|
|
|
|
|
tone={data?.ok ? 'good' : 'bad'}
|
|
|
|
|
>
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
<StatusCard title="Database" icon={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)} />
|
|
|
|
|
{dbOk
|
|
|
|
|
? <StatRow label="Health" value="Healthy" last />
|
|
|
|
|
: <StatRow label="Error" value={db.last_error} last />}
|
|
|
|
|
</StatusCard>
|
|
|
|
|
|
|
|
|
|
<StatusCard title="Runtime" icon={Cpu}>
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="grid gap-4 mb-6 md:grid-cols-2 xl:grid-cols-3">
|
|
|
|
|
{/* ── Services ───────────────────────────────────────────────── */}
|
|
|
|
|
<SectionLabel>Services</SectionLabel>
|
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
|
|
|
|
|
|
|
|
|
<StatusCard title="Daily Worker" icon={CalendarClock}
|
|
|
|
|
status={workerOk === null ? 'Pending' : workerOk ? 'Running' : 'Error'}
|
|
|
|
|
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="SimpleFIN Sync" icon={Landmark}
|
|
|
|
|
status={bankSyncStatus}
|
|
|
|
|
tone={bankSyncTone}
|
|
|
|
|
>
|
|
|
|
|
<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 />
|
|
|
|
|
</StatusCard>
|
|
|
|
|
|
|
|
|
|
<StatusCard title="Notifications" icon={Bell}
|
|
|
|
|
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 == 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" icon={HardDrive}
|
|
|
|
|
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="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" icon={Wrench}
|
|
|
|
|
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 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>
|
|
|
|
|
|
|
|
|
|
</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}>
|
|
|
|
|
<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="Statistics" icon={BarChart3}>
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
<StatusCard title="Server Clock" icon={Clock}>
|
|
|
|
|
<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 />
|
|
|
|
|
</StatusCard>
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* ── Software ───────────────────────────────────────────────── */}
|
|
|
|
|
<SectionLabel>Software</SectionLabel>
|
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
|
|
|
{updateData && (
|
|
|
|
|
<UpdateCard
|
|
|
|
|
update={updateData}
|
|
|
|
|
@ -393,128 +486,45 @@ export default function StatusPage() {
|
|
|
|
|
checking={updateChecking}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<StatusCard
|
|
|
|
|
title="Daily Worker"
|
|
|
|
|
status={workerOk === null ? 'Pending' : workerOk ? 'Running' : 'Error'}
|
|
|
|
|
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="SimpleFIN Sync"
|
|
|
|
|
status={bankSyncStatus}
|
|
|
|
|
tone={bankSyncTone}
|
|
|
|
|
>
|
|
|
|
|
<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 />
|
|
|
|
|
</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>
|
|
|
|
|
<ReleaseNotesCard version={version} historyMeta={historyMeta} />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* ── 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}`}
|
|
|
|
|
className={cn('py-1.5', i !== Math.min(recentErrors.length, 5) - 1 && 'border-b border-border/40')}
|
|
|
|
|
>
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Release Notes */}
|
|
|
|
|
<ReleaseNotesSection version={version} historyMeta={historyMeta} />
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|