diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index fe9ca1e..98bc36f 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -1,14 +1,20 @@ import { useEffect, useMemo, useState } from 'react'; import { + Activity, AlertCircle, AlertTriangle, CheckCircle2, - CircleDot, Clock, ExternalLink, + Gauge, + Inbox, PauseCircle, + Power, Radio, + Sparkles, + TrendingUp, XCircle, + Zap, } from 'lucide-react'; import { pb, N8N_URL, type WorkflowRecord, type ExecutionRecord, type ExecutionStatus } from '@/lib/pocketbase'; import { cn } from '@/lib/utils'; @@ -20,8 +26,10 @@ import { cn } from '@/lib/utils'; function timeAgo(iso?: string) { if (!iso) return '—'; const diffMs = Date.now() - new Date(iso).getTime(); - const mins = Math.round(diffMs / 60000); - if (mins < 1) return 'just now'; + const secs = Math.round(diffMs / 1000); + if (secs < 5) return 'just now'; + if (secs < 60) return `${secs}s ago`; + const mins = Math.round(secs / 60); if (mins < 60) return `${mins}m ago`; const hrs = Math.round(mins / 60); if (hrs < 24) return `${hrs}h ago`; @@ -31,15 +39,16 @@ function timeAgo(iso?: string) { function statusBadgeClass(status: ExecutionStatus) { switch (status) { case 'success': - return 'bg-success/10 text-success'; + return 'bg-success-soft text-success ring-1 ring-inset ring-success/20'; case 'error': - return 'bg-error/10 text-error'; + return 'bg-error-soft text-error ring-1 ring-inset ring-error/25'; case 'running': + return 'bg-pending-soft text-pending ring-1 ring-inset ring-pending/25'; case 'waiting': - return 'bg-pending/10 text-pending'; + return 'bg-pending-soft text-pending ring-1 ring-inset ring-pending/25'; case 'canceled': default: - return 'bg-muted text-muted-foreground'; + return 'bg-muted text-muted-foreground ring-1 ring-inset ring-border'; } } @@ -95,7 +104,7 @@ export default function App() { setExecutions(ex); setLastSync(new Date()); setLoading(false); - } catch (e) { + } catch { setError('Could not reach PocketBase. Check VITE_POCKETBASE_URL and that the collections exist.'); setLoading(false); } @@ -133,8 +142,6 @@ export default function App() { }; }, []); - // ── Computed ──────────────────────────────────────────────────────────────── - const lastRunByWorkflow = useMemo(() => { const map = new Map(); for (const e of executions) { @@ -208,247 +215,118 @@ export default function App() { return out; }, [workflows, executions, lastRunByWorkflow]); - // ── Render ───────────────────────────────────────────────────────────────── - return (
-
- {/* ── Header ──────────────────────────────────────────────────── */} -
-
-

n8n Workflow Monitor

-

Read-only · synced via PocketBase

+ {/* ── Header (sticky, glassmorphism) ──────────────────────────────── */} +
+
+
+
+
+
+ +
+
+
+

Command Center

+

n8n workflow monitor

+
+
+
+ + + {loading ? 'loading…' : `last sync ${lastSync ? timeAgo(lastSync.toISOString()) : '—'}`} +
- - {loading ? 'loading…' : error ? 'offline' : `last sync: ${lastSync ? timeAgo(lastSync.toISOString()) : '—'}`} -
+
+
{error && ( -
+
{error}
)} {/* ── Stats strip ────────────────────────────────────────────── */} -
+
} label="Active" value={ <> {stats.active} - of {stats.total} + / {stats.total} } valueClass="text-success" + glowClass="glow-success" + sub="workflows running" /> - } + label="Runs 24h" + value={stats.total24h} + sub="executions recorded" + /> + } label="Succeeded" value={ <> {stats.success24h} - 24h + / 24h } valueClass="text-success" + glowClass="glow-success" + sub="successful runs" /> } label="Errors" value={ <> {stats.errors24h} - 24h + / 24h } valueClass={stats.errors24h > 0 ? 'text-error' : ''} + glowClass={stats.errors24h > 0 ? 'glow-error' : ''} + sub="failed executions" /> } label="Fail rate" value={ <> - {stats.failRate.toFixed(1)}% - 24h + {stats.failRate.toFixed(1)} + % } - valueClass={stats.failRate > 5 ? 'text-error' : stats.failRate > 0 ? 'text-warning' : 'text-success'} + valueClass={ + stats.failRate > 5 + ? 'text-error' + : stats.failRate > 0 + ? 'text-warning' + : 'text-success' + } + glowClass={stats.failRate > 5 ? 'glow-error' : stats.failRate > 0 ? 'glow-warning' : ''} + sub="rolling 24h window" />
{/* ── Anomalies (Needs attention) ────────────────────────────── */} - {anomalies.length > 0 && ( -
-
-

⚠ Needs attention

-
-
    - {anomalies.map((a, i) => ( -
  • - {a.kind === 'burning' && ( - <> - - {a.workflow.name} - · - - last {a.errorCount} of {a.totalCount} runs failed - - - n8n - - - )} - {a.kind === 'stuck' && ( - <> - - - {workflows.find((w) => w.n8n_id === a.execution.workflow_n8n_id)?.name ?? - a.execution.workflow_n8n_id} - - · - - "running" for {timeAgo(a.execution.started_at)} (unusually long) - - - n8n - - - )} - {a.kind === 'stale' && ( - <> - - {a.workflow.name} - · - - active but hasn't run in{' '} - {a.lastRun ? timeAgo(a.lastRun) : 'recorded history'} - - - )} -
  • - ))} -
-
- )} + {/* ── 2-col: Workflows + Activity ────────────────────────────── */}
- {/* Workflows */} -
-
-

Workflows

- - {workflows.length} total - -
-
    - {workflows.length === 0 ? ( -
  • - {loading ? 'Loading…' : 'No workflows yet — the poller will sync them within 5 minutes.'} -
  • - ) : ( - workflows.map((wf) => { - const lastRun = lastRunByWorkflow.get(wf.n8n_id); - return ( -
  • - - - {wf.name} - - {lastRun ? ( - - {lastRun.status} - - ) : ( - - never - - )} - - {lastRun ? timeAgo(lastRun.started_at) : '—'} - -
  • - ); - }) - )} -
-
- - {/* Activity */} -
-
-

Activity

- - last {Math.min(executions.length, 50)} of {executions.length} - -
-
    - {executions.length === 0 ? ( -
  • - {loading ? 'Loading…' : 'No executions yet. They appear here within 5 minutes of the poller running.'} -
  • - ) : ( - executions.slice(0, 50).map((ex) => { - const wf = workflows.find((w) => w.n8n_id === ex.workflow_n8n_id); - return ( -
  • -
    - -
    -
    - {wf?.name ?? ex.workflow_n8n_id} - - {ex.status} - -
    - {ex.status === 'error' && ex.error_message && ( -

    - {ex.error_message} -

    - )} -
    - - {timeAgo(ex.started_at)} - -
    -
  • - ); - }) - )} -
-
+ +
@@ -456,24 +334,421 @@ export default function App() { } // ──────────────────────────────────────────────────────────────────────────── -// Stat card +// Live indicator (pulsing dot) +// ──────────────────────────────────────────────────────────────────────────── + +function LiveIndicator({ live }: { live: boolean }) { + return ( + + + {live && ( + + )} + + + + {live ? 'live' : 'offline'} + + + ); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Stat card (icon + label + big number + sub + color glow) // ──────────────────────────────────────────────────────────────────────────── function StatCard({ + icon, label, value, + sub, valueClass, + glowClass, }: { + icon: React.ReactNode; label: string; value: React.ReactNode; + sub: string; valueClass?: string; + glowClass?: string; }) { return ( -
-

- {label} +

+
+

+ {label} +

+ + {icon} + +
+

+ {value}

-

{value}

+

{sub}

+
+ ); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Anomalies (Needs attention) — tinted by severity +// ──────────────────────────────────────────────────────────────────────────── + +function AnomaliesPanel({ anomalies, workflows }: { anomalies: Anomaly[]; workflows: WorkflowRecord[] }) { + if (anomalies.length === 0) { + return ( + } + title="All clear" + meta="no anomalies" + accent="success" + > +
+
+ +
+

All systems healthy

+

+ No stale, burning, or stuck workflows detected. +

+
+
+ ); + } + + return ( + } + title="Needs attention" + meta={`${anomalies.length} ${anomalies.length === 1 ? 'anomaly' : 'anomalies'}`} + accent="warning" + > +
    + {anomalies.map((a, i) => ( +
  • + {a.kind === 'burning' && ( + + +
+ } + name={a.workflow.name} + detail={ + + last {a.errorCount} of {a.totalCount} runs failed (100% fail rate) + + } + href={`${N8N_URL}/execution/${a.workflow.n8n_id}`} + /> + )} + {a.kind === 'stuck' && ( + + +
+ } + name={ + workflows.find((w) => w.n8n_id === a.execution.workflow_n8n_id)?.name ?? + a.execution.workflow_n8n_id + } + detail={ + + "running" for {timeAgo(a.execution.started_at)} (unusually long) + + } + href={`${N8N_URL}/execution/${a.execution.n8n_execution_id}`} + /> + )} + {a.kind === 'stale' && ( + + +
+ } + name={a.workflow.name} + detail={ + + active but hasn't run in {a.lastRun ? timeAgo(a.lastRun) : 'recorded history'} + + } + /> + )} + + ))} + + + ); +} + +function AnomalyRow({ + icon, + name, + detail, + href, +}: { + icon: React.ReactNode; + name: string; + detail: React.ReactNode; + href?: string; +}) { + return ( + <> + {icon} + {name} + {detail} + {href && ( + + n8n + + + )} + + ); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Workflows panel +// ──────────────────────────────────────────────────────────────────────────── + +function WorkflowsPanel({ + workflows, + lastRunByWorkflow, + loading, +}: { + workflows: WorkflowRecord[]; + lastRunByWorkflow: Map; + loading: boolean; +}) { + return ( + } + title="Workflows" + meta={`${workflows.length} total`} + > + {workflows.length === 0 ? ( + } + message={loading ? 'Loading…' : 'No workflows yet'} + hint={loading ? undefined : 'The poller will sync them within 5 minutes.'} + /> + ) : ( +
    + {workflows.map((wf) => { + const lastRun = lastRunByWorkflow.get(wf.n8n_id); + return ( +
  • + + {wf.active && ( + + )} + + + + {wf.name} + + {lastRun ? ( + + {lastRun.status} + + ) : ( + + never + + )} + + {lastRun ? timeAgo(lastRun.started_at) : '—'} + +
  • + ); + })} +
+ )} +
+ ); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Activity panel +// ──────────────────────────────────────────────────────────────────────────── + +function ActivityPanel({ + executions, + workflows, + loading, +}: { + executions: ExecutionRecord[]; + workflows: WorkflowRecord[]; + loading: boolean; +}) { + return ( + } + title="Activity" + meta={`last ${Math.min(executions.length, 50)} of ${executions.length}`} + > + {executions.length === 0 ? ( + } + message={loading ? 'Loading…' : 'No executions yet'} + hint={loading ? undefined : 'They appear here within 5 minutes of the poller running.'} + /> + ) : ( +
    + {executions.slice(0, 50).map((ex) => { + const wf = workflows.find((w) => w.n8n_id === ex.workflow_n8n_id); + return ( +
  • + +
    +
    + {wf?.name ?? ex.workflow_n8n_id} + + {ex.status} + +
    + {ex.status === 'error' && ex.error_message && ( +

    + {ex.error_message} +

    + )} +
    + + {timeAgo(ex.started_at)} + +
  • + ); + })} +
+ )} +
+ ); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Reusable panel wrapper +// ──────────────────────────────────────────────────────────────────────────── + +function Panel({ + icon, + title, + meta, + accent, + children, +}: { + icon?: React.ReactNode; + title: string; + meta?: string; + accent?: 'success' | 'warning' | 'error'; + children: React.ReactNode; +}) { + return ( +
+
+
+ {icon} +

{title}

+
+ {meta && ( + + {meta} + + )} +
+ {children} +
+ ); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Empty state +// ──────────────────────────────────────────────────────────────────────────── + +function EmptyState({ + icon, + message, + hint, +}: { + icon: React.ReactNode; + message: string; + hint?: string; +}) { + return ( +
+
+ {icon} +
+

{message}

+ {hint &&

{hint}

}
); } diff --git a/dashboard/src/index.css b/dashboard/src/index.css index 3a87ac2..cdf6702 100644 --- a/dashboard/src/index.css +++ b/dashboard/src/index.css @@ -8,11 +8,14 @@ color: theme('colors.foreground'); font-family: theme('fontFamily.sans'); -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; } body { - /* Subtle gradient to add depth without being noisy */ - background-image: radial-gradient(circle at 0% 0%, rgba(96, 165, 250, 0.04), transparent 40%), - radial-gradient(circle at 100% 100%, rgba(239, 68, 68, 0.03), transparent 40%); + /* Subtle radial gradient — adds depth without being noisy */ + background-image: + radial-gradient(ellipse 80% 50% at 20% 0%, rgba(96, 165, 250, 0.06), transparent 50%), + radial-gradient(ellipse 60% 40% at 80% 100%, rgba(239, 68, 68, 0.04), transparent 50%); + background-attachment: fixed; } ::selection { background-color: rgba(96, 165, 250, 0.3); @@ -23,7 +26,26 @@ .font-tabular { font-variant-numeric: tabular-nums; } - .text-shadow-glow { - text-shadow: 0 0 12px currentColor; + .text-balance { + text-wrap: balance; } + /* Custom scrollbar — subtle, dark-friendly */ + .scrollbar-thin::-webkit-scrollbar { + width: 6px; + height: 6px; + } + .scrollbar-thin::-webkit-scrollbar-track { + background: transparent; + } + .scrollbar-thin::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.08); + border-radius: 3px; + } + .scrollbar-thin::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.16); + } + /* Subtle glow for status elements */ + .glow-success { box-shadow: 0 0 24px -8px rgba(34, 197, 94, 0.4); } + .glow-error { box-shadow: 0 0 24px -8px rgba(239, 68, 68, 0.5); } + .glow-warning { box-shadow: 0 0 24px -8px rgba(245, 158, 11, 0.4); } } diff --git a/dashboard/tailwind.config.js b/dashboard/tailwind.config.js index 31982df..31d5bf6 100644 --- a/dashboard/tailwind.config.js +++ b/dashboard/tailwind.config.js @@ -5,47 +5,61 @@ export default { theme: { extend: { colors: { - // Surfaces (dark by default — command center look) - border: '#27272a', // zinc-800 - background: '#09090b', // zinc-950 - foreground: '#fafafa', // zinc-50 + // Surfaces — refined for a premium dark look + border: 'rgba(255, 255, 255, 0.08)', + 'border-strong': 'rgba(255, 255, 255, 0.14)', + background: '#08080a', + foreground: '#fafafa', muted: { - DEFAULT: '#18181b', // zinc-900 (panels) - foreground: '#a1a1aa', // zinc-400 (secondary text) + DEFAULT: 'rgba(255, 255, 255, 0.04)', + foreground: '#a1a1aa', }, card: { - DEFAULT: '#0f0f12', // slight lift from background + DEFAULT: 'rgba(255, 255, 255, 0.025)', foreground: '#fafafa', }, - popover: { - DEFAULT: '#18181b', - foreground: '#fafafa', - }, - // Status — high contrast on dark - success: '#22c55e', // green-500 - 'success-fg': '#052e16', // for badges - error: '#ef4444', // red-500 - 'error-fg': '#450a0a', - warning: '#f59e0b', // amber-500 - 'warning-fg': '#451a03', - pending: '#eab308', // yellow-500 - 'pending-fg': '#422006', - info: '#3b82f6', // blue-500 - 'info-fg': '#172554', - // Accent (active states, links) - accent: '#60a5fa', // blue-400 + // Status — slightly desaturated for refinement + success: '#22c55e', + 'success-soft': 'rgba(34, 197, 94, 0.12)', + error: '#f43f5e', + 'error-soft': 'rgba(244, 63, 94, 0.12)', + warning: '#f59e0b', + 'warning-soft': 'rgba(245, 158, 11, 0.12)', + pending: '#eab308', + 'pending-soft': 'rgba(234, 179, 8, 0.12)', + info: '#60a5fa', + accent: '#60a5fa', }, borderRadius: { - lg: '0.5rem', - md: '0.375rem', - sm: '0.25rem', + xl: '0.75rem', + '2xl': '1rem', }, fontFamily: { mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'monospace'], - sans: ['ui-sans-serif', 'system-ui', '-apple-system', 'sans-serif'], + sans: [ + 'ui-sans-serif', + 'system-ui', + '-apple-system', + 'BlinkMacSystemFont', + '"Segoe UI"', + 'Roboto', + '"Helvetica Neue"', + 'Arial', + 'sans-serif', + ], + }, + backdropBlur: { + xs: '2px', }, animation: { 'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite', + 'fade-in': 'fadeIn 0.3s ease-out', + }, + keyframes: { + fadeIn: { + '0%': { opacity: '0', transform: 'translateY(4px)' }, + '100%': { opacity: '1', transform: 'translateY(0)' }, + }, }, }, },