feat: 90-day backfill + auto-seed + status page redesign (batch 0.33.8.4)
- New backfillDataSource export and POST route for 89-day history pull - Auto-seed 89 days on first connect, 30 days for routine syncs - sinceEpoch() replaced with sinceEpochDays(days) with explicit param - Status page errorRow now filters AND status = 'error' - Full status page redesign: colored top borders, card icons, section labels, health banner with glowing dot, consistent spacing - Bump v0.33.8.3 -> v0.33.8.4
This commit is contained in:
parent
0f9f48e255
commit
a15436b637
25
HISTORY.md
25
HISTORY.md
|
|
@ -1,5 +1,30 @@
|
||||||
# Bill Tracker — Changelog
|
# Bill Tracker — Changelog
|
||||||
|
|
||||||
|
## v0.33.8.4
|
||||||
|
|
||||||
|
### 🚀 Features
|
||||||
|
|
||||||
|
- **90-day backfill button** — New "90d Backfill" button per SimpleFIN connection pulls up to 89 days of transaction history. Available alongside Sync Now; both disable while either is in-flight.
|
||||||
|
- **Auto-seed on first connect** — New connections automatically get an 89-day seed sync on first connect (vs 30-day routine syncs).
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
- **Status page error logic** — `errorRow` query now adds `AND status = 'error'`. Erlist advisories stored in `last_error` on `status = 'active'` sources no longer show "Error" on the SimpleFIN Sync card.
|
||||||
|
|
||||||
|
### 🎨 Design
|
||||||
|
|
||||||
|
- **Status page redesign** — Cards now have colored top borders matching their tone, icons in headers, organized into Infrastructure/Services/App Health/Software/Errors sections with section labels and dividers. Health banner with glowing dot indicator. Consistent padding, text sizing, and spacing throughout.
|
||||||
|
|
||||||
|
### 🛠 Internal
|
||||||
|
|
||||||
|
- `sinceEpoch()` replaced with `sinceEpochDays(days)` — explicit parameter instead of reading config.
|
||||||
|
- `runSync()` now accepts `{ days }` option; detects first sync (89 days) vs routine (30 days).
|
||||||
|
- New `backfillDataSource()` export — forces 89 days regardless.
|
||||||
|
- New `POST /api/data-sources/:id/backfill` route.
|
||||||
|
- New `api.backfillDataSource(id)` client method.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v0.33.8.3
|
## v0.33.8.3
|
||||||
|
|
||||||
### 🚀 Features
|
### 🚀 Features
|
||||||
|
|
|
||||||
|
|
@ -317,6 +317,7 @@ export const api = {
|
||||||
simplefinStatus: () => get('/data-sources/simplefin/status'),
|
simplefinStatus: () => get('/data-sources/simplefin/status'),
|
||||||
connectSimplefin: (setupToken) => post('/data-sources/simplefin/connect', { setupToken }),
|
connectSimplefin: (setupToken) => post('/data-sources/simplefin/connect', { setupToken }),
|
||||||
syncDataSource: (id) => post(`/data-sources/${id}/sync`),
|
syncDataSource: (id) => post(`/data-sources/${id}/sync`),
|
||||||
|
backfillDataSource: (id) => post(`/data-sources/${id}/backfill`),
|
||||||
deleteDataSource: (id) => del(`/data-sources/${id}`),
|
deleteDataSource: (id) => del(`/data-sources/${id}`),
|
||||||
dataSourceAccounts: (sourceId) => get(`/data-sources/${sourceId}/accounts`),
|
dataSourceAccounts: (sourceId) => get(`/data-sources/${sourceId}/accounts`),
|
||||||
setAccountMonitored: (sourceId, accountId, monitored) => put(`/data-sources/${sourceId}/accounts/${accountId}`, { monitored }),
|
setAccountMonitored: (sourceId, accountId, monitored) => put(`/data-sources/${sourceId}/accounts/${accountId}`, { monitored }),
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
AlertTriangle, Building2, ChevronDown, ChevronRight,
|
AlertTriangle, Building2, ChevronDown, ChevronRight,
|
||||||
Eye, EyeOff, ExternalLink, Link2Off, Loader2, RefreshCw, Unlink,
|
Eye, EyeOff, ExternalLink, History, Link2Off, Loader2, RefreshCw, Unlink,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
@ -296,6 +296,7 @@ export default function BankSyncSection({ onConnectionChange }) {
|
||||||
const [setupToken, setSetupToken] = useState('');
|
const [setupToken, setSetupToken] = useState('');
|
||||||
const [connecting, setConnecting] = useState(false);
|
const [connecting, setConnecting] = useState(false);
|
||||||
const [syncing, setSyncing] = useState(null);
|
const [syncing, setSyncing] = useState(null);
|
||||||
|
const [backfilling, setBackfilling] = useState(null);
|
||||||
const [disconnectTarget, setDisconnectTarget] = useState(null);
|
const [disconnectTarget, setDisconnectTarget] = useState(null);
|
||||||
const [disconnecting, setDisconnecting] = useState(false);
|
const [disconnecting, setDisconnecting] = useState(false);
|
||||||
const [expandedAccount, setExpandedAccount] = useState(null);
|
const [expandedAccount, setExpandedAccount] = useState(null);
|
||||||
|
|
@ -426,6 +427,24 @@ export default function BankSyncSection({ onConnectionChange }) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBackfill = async (id) => {
|
||||||
|
setBackfilling(id);
|
||||||
|
try {
|
||||||
|
const result = await api.backfillDataSource(id);
|
||||||
|
if (result.errlist) {
|
||||||
|
toast.warning(`Backfill complete — ${result.transactionsNew} new transaction(s). Some connections need attention: ${result.errlist}`);
|
||||||
|
} else {
|
||||||
|
toast.success(`Backfill complete — ${result.transactionsNew} new transaction(s) pulled from the last 90 days.`);
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message || 'Backfill failed');
|
||||||
|
await load();
|
||||||
|
} finally {
|
||||||
|
setBackfilling(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDisconnect = async () => {
|
const handleDisconnect = async () => {
|
||||||
if (!disconnectTarget) return;
|
if (!disconnectTarget) return;
|
||||||
setDisconnecting(true);
|
setDisconnecting(true);
|
||||||
|
|
@ -551,13 +570,24 @@ export default function BankSyncSection({ onConnectionChange }) {
|
||||||
<Button
|
<Button
|
||||||
size="sm" variant="outline"
|
size="sm" variant="outline"
|
||||||
onClick={() => handleSync(conn.id)}
|
onClick={() => handleSync(conn.id)}
|
||||||
disabled={syncing === conn.id}
|
disabled={syncing === conn.id || backfilling === conn.id}
|
||||||
className="h-8 text-xs gap-1.5"
|
className="h-8 text-xs gap-1.5"
|
||||||
>
|
>
|
||||||
{syncing === conn.id
|
{syncing === conn.id
|
||||||
? <><Loader2 className="h-3.5 w-3.5 animate-spin" />Syncing…</>
|
? <><Loader2 className="h-3.5 w-3.5 animate-spin" />Syncing…</>
|
||||||
: <><RefreshCw className="h-3.5 w-3.5" />Sync Now</>}
|
: <><RefreshCw className="h-3.5 w-3.5" />Sync Now</>}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm" variant="outline"
|
||||||
|
onClick={() => handleBackfill(conn.id)}
|
||||||
|
disabled={syncing === conn.id || backfilling === conn.id}
|
||||||
|
className="h-8 text-xs gap-1.5 text-muted-foreground"
|
||||||
|
title="Pull up to 90 days of transaction history"
|
||||||
|
>
|
||||||
|
{backfilling === conn.id
|
||||||
|
? <><Loader2 className="h-3.5 w-3.5 animate-spin" />Backfilling…</>
|
||||||
|
: <><History className="h-3.5 w-3.5" />90d Backfill</>}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm" variant="ghost"
|
size="sm" variant="ghost"
|
||||||
onClick={() => setDisconnectTarget(conn)}
|
onClick={() => setDisconnectTarget(conn)}
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,17 @@
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
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 { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
import { cn, fmtUptime, fmtBytes } from '@/lib/utils';
|
import { cn, fmtUptime, fmtBytes } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { MarkdownText } from '@/components/MarkdownText';
|
import { MarkdownText } from '@/components/MarkdownText';
|
||||||
|
|
||||||
// ─── Skeleton Card ────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
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) {
|
function formatMemory(runtime) {
|
||||||
if (runtime.memory_used_bytes ?? runtime.memory?.used ?? runtime.memory) {
|
if (runtime.memory_used_bytes ?? runtime.memory?.used ?? runtime.memory) {
|
||||||
|
|
@ -61,17 +32,41 @@ function formatDateTime(value) {
|
||||||
return date.toLocaleString();
|
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 }) {
|
function StatusPill({ tone = 'muted', children }) {
|
||||||
const cls = {
|
const cls = {
|
||||||
good: 'bg-emerald-500/10 text-emerald-500 border-emerald-500/25',
|
good: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/25',
|
||||||
warn: 'bg-amber-500/10 text-amber-500 border-amber-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-500 border-red-500/25',
|
bad: 'bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/25',
|
||||||
muted: 'bg-muted text-muted-foreground border-border',
|
muted: 'bg-muted/60 text-muted-foreground border-border',
|
||||||
}[tone];
|
}[tone] ?? 'bg-muted/60 text-muted-foreground border-border';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={cn(
|
<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,
|
cls,
|
||||||
)}>
|
)}>
|
||||||
{children}
|
{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 (
|
return (
|
||||||
<div className="bg-card rounded-xl border border-border shadow-sm p-6">
|
<div className={cn(
|
||||||
<div className="flex items-center justify-between gap-3 mb-4">
|
'flex flex-col bg-card rounded-xl border border-border border-t-[3px] shadow-sm',
|
||||||
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground">{title}</h3>
|
accentCls,
|
||||||
{status && <StatusPill tone={tone}>{status}</StatusPill>}
|
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>
|
||||||
<div className="space-y-0">{children}</div>
|
<div className="px-5 py-3 flex-1">{children}</div>
|
||||||
</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'];
|
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 }) {
|
const tone = hasUpdate ? 'warn' : isKnown && !hasError ? 'good' : 'muted';
|
||||||
if (!version) {
|
const status = hasUpdate ? 'Update Available'
|
||||||
return (
|
: hasError ? 'Check Failed'
|
||||||
<div className="mt-4">
|
: isKnown ? 'Up to Date'
|
||||||
<div className="h-28 rounded-lg bg-muted/50 animate-pulse" />
|
: 'Unknown';
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group notes by category, preserving CATEGORY_ORDER priority
|
const StatusIcon = hasUpdate ? ArrowUpCircle : hasError ? AlertCircle : CheckCircle2;
|
||||||
const grouped = CATEGORY_ORDER.reduce((acc, cat) => {
|
const iconCls = hasUpdate ? 'text-amber-500' : hasError ? 'text-red-500' : isKnown ? 'text-emerald-500' : 'text-muted-foreground';
|
||||||
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 (
|
return (
|
||||||
<StatusCard title="Release Notes" status={`v${version.version}`} tone="good">
|
<StatusCard title="Software Update" icon={RefreshCw} status={status} tone={tone}>
|
||||||
<StatRow label="Total Changes" value={version.notes?.length ? `${version.notes.length} items` : null} />
|
<div className="flex items-center gap-2 py-1.5 border-b border-border/40">
|
||||||
<StatRow label="Last Updated" value={formatDateTime(historyMeta?.updated_at)} />
|
<StatusIcon className={cn('h-3.5 w-3.5 shrink-0', iconCls)} />
|
||||||
<div className="py-2 border-b border-border/50">
|
<span className="text-xs font-medium">
|
||||||
<p className="text-sm text-muted-foreground">Preview</p>
|
{hasUpdate ? `v${update.latest_version} is available`
|
||||||
<p className="text-sm font-medium mt-1">
|
: isKnown && !hasError ? 'Running the latest version'
|
||||||
<MarkdownText text={preview ?? 'No release notes available.'} />
|
: hasError ? 'Could not reach update server'
|
||||||
</p>
|
: 'Update status unknown'}
|
||||||
|
</span>
|
||||||
</div>
|
</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">
|
<div className="pt-3">
|
||||||
<Button asChild variant="outline" size="sm">
|
<Button variant="outline" size="sm" onClick={onCheckNow} disabled={checking}
|
||||||
<Link to="/release-notes">View Full Release Notes</Link>
|
className="h-7 text-xs gap-1.5">
|
||||||
|
<RefreshCw className={cn('h-3 w-3', checking && 'animate-spin')} />
|
||||||
|
{checking ? 'Checking…' : 'Check Now'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</StatusCard>
|
</StatusCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Update Card ─────────────────────────────────────────────────────────────
|
// ─── Release Notes Card ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
function UpdateCard({ update, onCheckNow, checking }) {
|
function ReleaseNotesCard({ version, historyMeta }) {
|
||||||
const hasUpdate = !!update.has_update;
|
if (!version) return <SkeletonCard />;
|
||||||
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 grouped = CATEGORY_ORDER.reduce((acc, cat) => {
|
||||||
const status = hasUpdate ? 'Update Available'
|
const notes = version.notes?.filter(n => n.category === cat) ?? [];
|
||||||
: hasError ? 'Check Failed'
|
if (notes.length) acc[cat] = notes;
|
||||||
: isKnown ? 'Up to Date'
|
return acc;
|
||||||
: 'Unknown';
|
}, {});
|
||||||
|
version.notes?.forEach(n => {
|
||||||
|
if (!grouped[n.category]) grouped[n.category] = [...(grouped[n.category] ?? []), n];
|
||||||
|
});
|
||||||
|
|
||||||
const Icon = hasUpdate ? ArrowUpCircle
|
const categories = Object.keys(grouped);
|
||||||
: hasError ? AlertCircle
|
const preview = categories.length ? grouped[categories[0]]?.[0]?.text : null;
|
||||||
: CheckCircle2;
|
|
||||||
|
|
||||||
const iconCls = hasUpdate ? 'text-amber-500'
|
|
||||||
: hasError ? 'text-red-500'
|
|
||||||
: isKnown ? 'text-emerald-500'
|
|
||||||
: 'text-muted-foreground';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StatusCard title="Software Update" status={status} tone={tone}>
|
<StatusCard title="Release Notes" icon={ScrollText} status={`v${version.version}`} tone="good">
|
||||||
<div className="flex items-center gap-2 py-2 border-b border-border/50">
|
<StatRow label="Total Changes" value={version.notes?.length ? `${version.notes.length} items` : null} />
|
||||||
<Icon className={cn('h-4 w-4 shrink-0', iconCls)} />
|
<StatRow label="Last Updated" value={formatDateTime(historyMeta?.updated_at)} />
|
||||||
<span className="text-sm font-medium">
|
<div className="py-1.5 border-b border-border/40">
|
||||||
{hasUpdate
|
<p className="text-[11px] text-muted-foreground/60 uppercase tracking-wide mb-1">Preview</p>
|
||||||
? `v${update.latest_version} is available`
|
<p className="text-xs font-medium line-clamp-2 leading-relaxed">
|
||||||
: isKnown && !hasError
|
<MarkdownText text={preview ?? 'No release notes available.'} />
|
||||||
? 'Running the latest version'
|
</p>
|
||||||
: hasError
|
|
||||||
? 'Could not reach update server'
|
|
||||||
: 'Status unknown'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</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">
|
<div className="pt-3">
|
||||||
<Button variant="outline" size="sm" onClick={onCheckNow} disabled={checking}
|
<Button asChild variant="outline" size="sm" className="h-7 text-xs">
|
||||||
className="gap-1.5">
|
<Link to="/release-notes">View Full Notes</Link>
|
||||||
<RefreshCw className={cn('h-3 w-3', checking && 'animate-spin')} />
|
|
||||||
{checking ? 'Checking…' : 'Check Now'}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</StatusCard>
|
</StatusCard>
|
||||||
|
|
@ -225,11 +223,11 @@ function UpdateCard({ update, onCheckNow, checking }) {
|
||||||
// ─── StatusPage ───────────────────────────────────────────────────────────────
|
// ─── StatusPage ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function StatusPage() {
|
export default function StatusPage() {
|
||||||
const [data, setData] = useState(null);
|
const [data, setData] = useState(null);
|
||||||
const [version, setVersion] = useState(null);
|
const [version, setVersion] = useState(null);
|
||||||
const [historyMeta, setHistoryMeta] = useState(null);
|
const [historyMeta, setHistoryMeta] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [updateData, setUpdateData] = useState(null);
|
const [updateData, setUpdateData] = useState(null);
|
||||||
const [updateChecking, setUpdateChecking] = useState(false);
|
const [updateChecking, setUpdateChecking] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
|
|
@ -237,22 +235,14 @@ export default function StatusPage() {
|
||||||
setVersion(null);
|
setVersion(null);
|
||||||
setHistoryMeta(null);
|
setHistoryMeta(null);
|
||||||
try {
|
try {
|
||||||
const [statusData, versionData] = await Promise.all([
|
const [statusData, versionData] = await Promise.all([api.status(), api.version()]);
|
||||||
api.status(),
|
|
||||||
api.version(),
|
|
||||||
]);
|
|
||||||
setData(statusData);
|
setData(statusData);
|
||||||
setUpdateData(statusData?.update ?? null);
|
setUpdateData(statusData?.update ?? null);
|
||||||
setVersion(versionData);
|
setVersion(versionData);
|
||||||
try {
|
try {
|
||||||
const historyData = await api.releaseHistory();
|
const historyData = await api.releaseHistory();
|
||||||
setHistoryMeta({
|
setHistoryMeta({ version: historyData.version, updated_at: historyData.updated_at });
|
||||||
version: historyData.version,
|
} catch { setHistoryMeta(null); }
|
||||||
updated_at: historyData.updated_at,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
setHistoryMeta(null);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to load status.');
|
toast.error(err.message || 'Failed to load status.');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -265,8 +255,7 @@ export default function StatusPage() {
|
||||||
const handleCheckNow = useCallback(async () => {
|
const handleCheckNow = useCallback(async () => {
|
||||||
setUpdateChecking(true);
|
setUpdateChecking(true);
|
||||||
try {
|
try {
|
||||||
const result = await api.checkForUpdates();
|
setUpdateData(await api.checkForUpdates());
|
||||||
setUpdateData(result);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Update check failed');
|
toast.error(err.message || 'Update check failed');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -274,118 +263,222 @@ export default function StatusPage() {
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Flatten nested status shape gracefully
|
// Normalize the nested response shape
|
||||||
const app = data?.application ?? data?.app ?? {};
|
const app = data?.application ?? data?.app ?? {};
|
||||||
const rt = data?.runtime ?? {};
|
const rt = data?.runtime ?? {};
|
||||||
const db = data?.database ?? data?.db ?? {};
|
const db = data?.database ?? data?.db ?? {};
|
||||||
const stats = data?.statistics ?? data?.stats ?? {};
|
const stats = data?.statistics ?? data?.stats ?? {};
|
||||||
const worker = data?.worker ?? data?.jobs ?? {};
|
const worker = data?.worker ?? data?.jobs ?? {};
|
||||||
const notifications = data?.notifications ?? data?.email ?? {};
|
const notifications = data?.notifications ?? data?.email ?? {};
|
||||||
const backups = data?.backups ?? data?.backup ?? {};
|
const backups = data?.backups ?? data?.backup ?? {};
|
||||||
const server = data?.server ?? data?.clock ?? {};
|
const server = data?.server ?? data?.clock ?? {};
|
||||||
const tracker = data?.tracker ?? data?.tracker_health ?? {};
|
const tracker = data?.tracker ?? data?.tracker_health ?? {};
|
||||||
const cleanup = data?.cleanup ?? {};
|
const cleanup = data?.cleanup ?? {};
|
||||||
const bankSync = data?.bank_sync ?? data?.simplefin ?? {};
|
const bankSync = data?.bank_sync ?? data?.simplefin ?? {};
|
||||||
const errors = data?.errors ?? data?.recent_errors ?? [];
|
const errors = data?.errors ?? data?.recent_errors ?? [];
|
||||||
|
|
||||||
const dbOk = db.connected ?? (db.status === 'connected') ?? true;
|
const dbOk = db.connected ?? (db.status === 'connected') ?? true;
|
||||||
const workerOk = !worker.enabled ? null : worker.last_error ? false : true;
|
const workerOk = !worker.enabled ? null : worker.last_error ? false : true;
|
||||||
const notificationsConfigured = notifications.configured ?? notifications.smtp_configured ?? null;
|
const notificationsConfigured = notifications.configured ?? notifications.smtp_configured ?? null;
|
||||||
const backupsEnabled = backups.enabled ?? null;
|
const backupsEnabled = backups.enabled ?? null;
|
||||||
const recentErrors = Array.isArray(errors) ? errors : [];
|
const recentErrors = Array.isArray(errors) ? errors : [];
|
||||||
|
|
||||||
const bankSyncEnabled = bankSync.enabled ?? false;
|
const bankSyncEnabled = bankSync.enabled ?? false;
|
||||||
const bankSyncStatus = !bankSyncEnabled ? 'Disabled'
|
const bankSyncStatus = !bankSyncEnabled ? 'Disabled'
|
||||||
: bankSync.running ? 'Syncing'
|
: bankSync.running ? 'Syncing'
|
||||||
: bankSync.last_error ? 'Error'
|
: bankSync.last_error ? 'Error'
|
||||||
: bankSync.source_count > 0 ? 'Connected'
|
: (bankSync.source_count ?? 0) > 0 ? 'Connected'
|
||||||
: 'No Sources';
|
: 'No Sources';
|
||||||
const bankSyncTone = !bankSyncEnabled ? 'muted'
|
const bankSyncTone = !bankSyncEnabled ? 'muted'
|
||||||
: bankSync.last_error ? 'warn'
|
: bankSync.last_error ? 'warn'
|
||||||
: bankSync.source_count > 0 ? 'good'
|
: (bankSync.source_count ?? 0) > 0 ? 'good'
|
||||||
: 'warn';
|
: 'warn';
|
||||||
|
|
||||||
|
const utcOffset = server.utc_offset != null
|
||||||
|
? `UTC${server.utc_offset >= 0 ? '+' : ''}${server.utc_offset}`
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|
||||||
{/* Page header — flat on background */}
|
{/* ── Page header ──────────────────────────────────────────────── */}
|
||||||
<div className="flex flex-col gap-3 mb-8 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 mb-6 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Server Status</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Server Status</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">
|
<p className="text-sm text-muted-foreground mt-0.5">Health and operational status</p>
|
||||||
{!data ? 'Loading…' : data.ok ? 'All systems operational' : 'One or more systems need attention'}
|
|
||||||
</p>
|
|
||||||
</div>
|
</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')} />
|
<RefreshCw className={cn('h-3.5 w-3.5 mr-1.5', loading && 'animate-spin')} />
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 2x2 grid of stat cards */}
|
{/* ── Health banner ────────────────────────────────────────────── */}
|
||||||
{loading ? (
|
{!loading && data && (
|
||||||
<div className="grid gap-4 mb-6 md:grid-cols-2">
|
<div className={cn(
|
||||||
<SkeletonCard />
|
'flex items-center gap-3 px-4 py-2.5 rounded-lg border mb-6',
|
||||||
<SkeletonCard />
|
data.ok
|
||||||
<SkeletonCard />
|
? 'bg-emerald-500/5 border-emerald-500/20'
|
||||||
<SkeletonCard />
|
: 'bg-red-500/5 border-red-500/20',
|
||||||
</div>
|
)}>
|
||||||
) : (
|
<span className={cn(
|
||||||
<div className="grid gap-4 mb-6 md:grid-cols-2">
|
'h-2 w-2 rounded-full shrink-0',
|
||||||
|
data.ok
|
||||||
{/* Application */}
|
? 'bg-emerald-500 shadow-[0_0_0_3px_rgb(34_197_94/0.15)]'
|
||||||
<StatusCard title="Application" status={data?.ok ? 'Online' : 'Degraded'} tone={data?.ok ? 'good' : 'bad'}>
|
: 'bg-red-500 shadow-[0_0_0_3px_rgb(239_68_68/0.15)]',
|
||||||
<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} />
|
<span className={cn(
|
||||||
<StatRow
|
'text-sm font-semibold',
|
||||||
label="Uptime"
|
data.ok ? 'text-emerald-700 dark:text-emerald-400' : 'text-red-600 dark:text-red-400',
|
||||||
value={fmtUptime(app.uptime_seconds ?? app.uptime ?? data?.uptime_seconds ?? 0)}
|
)}>
|
||||||
last
|
{data.ok ? 'All systems operational' : 'One or more systems need attention'}
|
||||||
/>
|
</span>
|
||||||
</StatusCard>
|
<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>}
|
||||||
{/* Runtime */}
|
<span>{fmtUptime(app.uptime_seconds ?? data?.uptime_seconds ?? 0)} uptime</span>
|
||||||
<StatusCard title="Runtime">
|
</div>
|
||||||
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && (
|
{/* ── Loading skeleton ─────────────────────────────────────────── */}
|
||||||
|
{loading ? (
|
||||||
<>
|
<>
|
||||||
<div className="mb-3">
|
{[3, 5].map((count, si) => (
|
||||||
<h2 className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
<React.Fragment key={si}>
|
||||||
Operations
|
<div className={cn('flex items-center gap-3 mb-3', si > 0 && 'mt-8')}>
|
||||||
</h2>
|
<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>
|
||||||
|
|
||||||
<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 && (
|
{updateData && (
|
||||||
<UpdateCard
|
<UpdateCard
|
||||||
update={updateData}
|
update={updateData}
|
||||||
|
|
@ -393,128 +486,45 @@ export default function StatusPage() {
|
||||||
checking={updateChecking}
|
checking={updateChecking}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<ReleaseNotesCard version={version} historyMeta={historyMeta} />
|
||||||
<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>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "bill-tracker",
|
"name": "bill-tracker",
|
||||||
"version": "0.33.8.3",
|
"version": "0.33.8.4",
|
||||||
"description": "Monthly bill tracking system",
|
"description": "Monthly bill tracking system",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ const router = require('express').Router();
|
||||||
const { getDb } = require('../db/database');
|
const { getDb } = require('../db/database');
|
||||||
const { standardizeError } = require('../middleware/errorFormatter');
|
const { standardizeError } = require('../middleware/errorFormatter');
|
||||||
const { decorateDataSource, ensureManualDataSource } = require('../services/transactionService');
|
const { decorateDataSource, ensureManualDataSource } = require('../services/transactionService');
|
||||||
const { connectSimplefin, syncDataSource, disconnectDataSource } = require('../services/bankSyncService');
|
const { connectSimplefin, syncDataSource, backfillDataSource, disconnectDataSource } = require('../services/bankSyncService');
|
||||||
const { sanitizeErrorMessage } = require('../services/simplefinService');
|
const { sanitizeErrorMessage } = require('../services/simplefinService');
|
||||||
const { getBankSyncConfig } = require('../services/bankSyncConfigService');
|
const { getBankSyncConfig } = require('../services/bankSyncConfigService');
|
||||||
|
|
||||||
|
|
@ -189,6 +189,28 @@ router.post('/:id/sync', async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── POST /api/data-sources/:id/backfill ─────────────────────────────────────
|
||||||
|
|
||||||
|
router.post('/:id/backfill', async (req, res) => {
|
||||||
|
if (!getBankSyncConfig().enabled) {
|
||||||
|
return res.status(503).json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
if (!Number.isInteger(id) || id < 1) {
|
||||||
|
return res.status(400).json(standardizeError('Invalid data source id', 'VALIDATION_ERROR', 'id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const result = await backfillDataSource(db, req.user.id, id);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
const { msg, status } = safeError(err, 'Backfill failed');
|
||||||
|
res.status(status).json(standardizeError(msg, err?.code || 'SIMPLEFIN_ERROR'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ─── DELETE /api/data-sources/:id ────────────────────────────────────────────
|
// ─── DELETE /api/data-sources/:id ────────────────────────────────────────────
|
||||||
|
|
||||||
router.delete('/:id', (req, res) => {
|
router.delete('/:id', (req, res) => {
|
||||||
|
|
|
||||||
|
|
@ -282,7 +282,8 @@ router.get('/', async (req, res) => {
|
||||||
`).get();
|
`).get();
|
||||||
const errorRow = db.prepare(`
|
const errorRow = db.prepare(`
|
||||||
SELECT last_error FROM data_sources
|
SELECT last_error FROM data_sources
|
||||||
WHERE type = 'provider_sync' AND provider = 'simplefin' AND last_error IS NOT NULL
|
WHERE type = 'provider_sync' AND provider = 'simplefin'
|
||||||
|
AND status = 'error' AND last_error IS NOT NULL
|
||||||
ORDER BY updated_at DESC LIMIT 1
|
ORDER BY updated_at DESC LIMIT 1
|
||||||
`).get();
|
`).get();
|
||||||
bankSync = {
|
bankSync = {
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,11 @@ const { getBankSyncConfig } = require('./bankSyncConfigService');
|
||||||
const { decorateDataSource } = require('./transactionService');
|
const { decorateDataSource } = require('./transactionService');
|
||||||
const { applyMerchantRules } = require('./billMerchantRuleService');
|
const { applyMerchantRules } = require('./billMerchantRuleService');
|
||||||
|
|
||||||
function sinceEpoch() {
|
const SEED_SYNC_DAYS = 89; // Initial connect / explicit backfill (SimpleFIN Bridge ~90-day cap)
|
||||||
const { sync_days } = getBankSyncConfig();
|
const ROUTINE_SYNC_DAYS = 30; // Auto-sync and manual "Sync Now"
|
||||||
return Math.floor((Date.now() - sync_days * 86400 * 1000) / 1000);
|
|
||||||
|
function sinceEpochDays(days) {
|
||||||
|
return Math.floor((Date.now() - days * 86400 * 1000) / 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeErrorMessage(err) {
|
function safeErrorMessage(err) {
|
||||||
|
|
@ -78,9 +80,11 @@ function insertTransactionIfNew(db, txRow) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runSync(db, userId, dataSource) {
|
async function runSync(db, userId, dataSource, { days } = {}) {
|
||||||
const accessUrl = decryptSecret(dataSource.encrypted_secret);
|
const accessUrl = decryptSecret(dataSource.encrypted_secret);
|
||||||
const since = sinceEpoch();
|
const isFirstSync = !dataSource.last_sync_at;
|
||||||
|
const syncDays = days ?? (isFirstSync ? SEED_SYNC_DAYS : ROUTINE_SYNC_DAYS);
|
||||||
|
const since = sinceEpochDays(syncDays);
|
||||||
|
|
||||||
const raw = await fetchAccountsAndTransactions(accessUrl, since);
|
const raw = await fetchAccountsAndTransactions(accessUrl, since);
|
||||||
const accounts = Array.isArray(raw.accounts) ? raw.accounts : [];
|
const accounts = Array.isArray(raw.accounts) ? raw.accounts : [];
|
||||||
|
|
@ -184,6 +188,33 @@ async function syncDataSource(db, userId, dataSourceId) {
|
||||||
return { dataSource: decorateDataSource(fresh), ...syncResult };
|
return { dataSource: decorateDataSource(fresh), ...syncResult };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function backfillDataSource(db, userId, dataSourceId) {
|
||||||
|
assertEncryptionReady();
|
||||||
|
|
||||||
|
const dataSource = db.prepare(`
|
||||||
|
SELECT * FROM data_sources
|
||||||
|
WHERE id = ? AND user_id = ? AND type = 'provider_sync' AND provider = 'simplefin'
|
||||||
|
`).get(dataSourceId, userId);
|
||||||
|
|
||||||
|
if (!dataSource) throw Object.assign(new Error('SimpleFIN connection not found'), { status: 404 });
|
||||||
|
if (!dataSource.encrypted_secret) throw new Error('No stored credentials for this connection');
|
||||||
|
|
||||||
|
let syncResult;
|
||||||
|
try {
|
||||||
|
syncResult = await runSync(db, userId, dataSource, { days: SEED_SYNC_DAYS });
|
||||||
|
} catch (err) {
|
||||||
|
const msg = safeErrorMessage(err);
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE data_sources SET last_error = ?, status = 'error', updated_at = datetime('now')
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(msg, dataSourceId);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fresh = db.prepare('SELECT * FROM data_sources WHERE id = ? AND user_id = ?').get(dataSourceId, userId);
|
||||||
|
return { dataSource: decorateDataSource(fresh), ...syncResult };
|
||||||
|
}
|
||||||
|
|
||||||
function disconnectDataSource(db, userId, dataSourceId) {
|
function disconnectDataSource(db, userId, dataSourceId) {
|
||||||
const row = db.prepare(`
|
const row = db.prepare(`
|
||||||
SELECT id FROM data_sources WHERE id = ? AND user_id = ? AND provider = 'simplefin'
|
SELECT id FROM data_sources WHERE id = ? AND user_id = ? AND provider = 'simplefin'
|
||||||
|
|
@ -195,4 +226,4 @@ function disconnectDataSource(db, userId, dataSourceId) {
|
||||||
db.prepare('DELETE FROM data_sources WHERE id = ? AND user_id = ?').run(dataSourceId, userId);
|
db.prepare('DELETE FROM data_sources WHERE id = ? AND user_id = ?').run(dataSourceId, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { connectSimplefin, syncDataSource, disconnectDataSource };
|
module.exports = { connectSimplefin, syncDataSource, backfillDataSource, disconnectDataSource };
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue