Redesign dashboard as command center + capture error_message in poller

Dashboard:
- Dark theme by default (zinc-950 background, high-contrast status colors)
- Layout: sticky header with live status, hero HealthBanner, FailuresPanel,
  AnomaliesPanel, 2-col grid (Workflows + Activity), StatsStrip footer
- Auto-detected anomalies: stale active workflows, 100% fail rate, stuck running
- Click any failure/activity row → links to n8n execution view (VITE_N8N_URL)
- VITE_N8N_URL env var threaded through Dockerfile + compose

Poller (poller.n8n.json):
- Fetch n8n Executions: includeData false → true (now includes execution data)
- Create/Update Execution Record: extract error_message from data.resultData.error
  using a safe IIFE that JSON.parses the data field

Also:
- Added vite-env.d.ts so VITE_* env vars are typed
- docker-compose.yml: VITE_N8N_URL added to build args
- .env.example: documented VITE_N8N_URL at root and dashboard level
This commit is contained in:
David 2026-08-23 19:11:29 +02:00
parent edab0c37b6
commit cd1a922976
14 changed files with 756 additions and 248 deletions

View file

@ -2,3 +2,6 @@
# Coolify: set in the service's Environment Variables tab.
# Standalone docker compose: copy this to .env and edit.
VITE_POCKETBASE_URL=http://127.0.0.1:8090
# n8n base URL — used for deep-links from each failure/execution to the n8n UI.
VITE_N8N_URL=http://localhost:5678

View file

@ -3,3 +3,7 @@
# In prod (Coolify / Cloudflare Tunnel / reverse proxy): set to your public PB URL.
# Example: VITE_POCKETBASE_URL=https://pocketbase.yourdomain.com
VITE_POCKETBASE_URL=http://127.0.0.1:8090
# n8n base URL — used for deep-links from each failure/execution back to n8n.
# Example: VITE_N8N_URL=https://wfdash.n8n.apphub.co.za
VITE_N8N_URL=http://localhost:5678

View file

@ -14,6 +14,10 @@ RUN npm ci
ARG VITE_POCKETBASE_URL
ENV VITE_POCKETBASE_URL=$VITE_POCKETBASE_URL
# n8n base URL for deep-links from each failure/execution back to n8n.
ARG VITE_N8N_URL
ENV VITE_N8N_URL=$VITE_N8N_URL
COPY . .
RUN npm run build

View file

@ -1,33 +1,28 @@
import { useEffect, useMemo, useState } from 'react';
import { pb, WorkflowRecord, ExecutionRecord } from '@/lib/pocketbase';
import {
AlertCircle,
AlertTriangle,
CheckCircle2,
CircleDot,
Clock,
ExternalLink,
PauseCircle,
Power,
Radio,
XCircle,
Zap,
} from 'lucide-react';
import { pb, N8N_URL, type WorkflowRecord, type ExecutionRecord, type ExecutionStatus } from '@/lib/pocketbase';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { StatusDot } from '@/components/status-dot';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { AlertCircle, CheckCircle2, Clock, Power } from 'lucide-react';
import { cn } from '@/lib/utils';
function statusVariant(status: ExecutionRecord['status']) {
switch (status) {
case 'success':
return 'success' as const;
case 'error':
return 'error' as const;
case 'running':
case 'waiting':
return 'pending' as const;
default:
return 'muted' as const;
}
}
// ────────────────────────────────────────────────────────────────────────────
// Helpers
// ────────────────────────────────────────────────────────────────────────────
function timeAgo(iso: string) {
function timeAgo(iso?: string) {
if (!iso) return '—';
const diffMs = Date.now() - new Date(iso).getTime();
const mins = Math.round(diffMs / 60000);
@ -38,11 +33,56 @@ function timeAgo(iso: string) {
return `${Math.round(hrs / 24)}d ago`;
}
function statusVariant(status: ExecutionStatus) {
switch (status) {
case 'success':
return 'success' as const;
case 'error':
return 'error' as const;
case 'running':
case 'waiting':
return 'pending' as const;
case 'canceled':
return 'muted' as const;
default:
return 'muted' as const;
}
}
function StatusIcon({ status, className }: { status: ExecutionStatus; className?: string }) {
const iconClass = cn('h-3.5 w-3.5 shrink-0', className);
switch (status) {
case 'success':
return <CheckCircle2 className={cn(iconClass, 'text-success')} />;
case 'error':
return <XCircle className={cn(iconClass, 'text-error')} />;
case 'running':
return <Radio className={cn(iconClass, 'text-pending animate-pulse')} />;
case 'waiting':
return <Clock className={cn(iconClass, 'text-pending')} />;
case 'canceled':
return <PauseCircle className={cn(iconClass, 'text-muted-foreground')} />;
}
}
const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
const STUCK_RUNNING_MS = 30 * 60 * 1000;
type Anomaly =
| { kind: 'stale'; workflow: WorkflowRecord; lastRun?: string }
| { kind: 'burning'; workflow: WorkflowRecord; errorCount: number; totalCount: number }
| { kind: 'stuck'; execution: ExecutionRecord };
// ────────────────────────────────────────────────────────────────────────────
// Main
// ────────────────────────────────────────────────────────────────────────────
export default function App() {
const [workflows, setWorkflows] = useState<WorkflowRecord[]>([]);
const [executions, setExecutions] = useState<ExecutionRecord[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastSync, setLastSync] = useState<Date | null>(null);
const [selectedWorkflow, setSelectedWorkflow] = useState<string | null>(null);
useEffect(() => {
@ -55,21 +95,21 @@ export default function App() {
pb.collection('workflows').getFullList<WorkflowRecord>({ sort: 'name' }),
pb.collection('executions').getFullList<ExecutionRecord>({
sort: '-started_at',
perPage: 100,
perPage: 200,
}),
]);
setWorkflows(wf);
setExecutions(ex);
setLastSync(new Date());
setLoading(false);
} catch (e) {
setError('Could not reach PocketBase. Check the URL in src/lib/pocketbase.ts and that the collections exist.');
setError('Could not reach PocketBase. Check VITE_POCKETBASE_URL and that the collections exist.');
setLoading(false);
}
}
load();
// Realtime subscriptions so the dashboard updates without polling.
pb.collection('workflows')
.subscribe<WorkflowRecord>('*', (e) => {
setWorkflows((prev) => {
@ -77,6 +117,7 @@ export default function App() {
if (e.action === 'delete') return others;
return [...others, e.record].sort((a, b) => a.name.localeCompare(b.name));
});
setLastSync(new Date());
})
.then((unsub) => (unsubWorkflows = unsub));
@ -89,6 +130,7 @@ export default function App() {
.sort((a, b) => (b.started_at || '').localeCompare(a.started_at || ''))
.slice(0, 200);
});
setLastSync(new Date());
})
.then((unsub) => (unsubExecutions = unsub));
@ -98,22 +140,7 @@ export default function App() {
};
}, []);
const stats = useMemo(() => {
const activeCount = workflows.filter((w) => w.active).length;
const last24h = executions.filter(
(e) => Date.now() - new Date(e.started_at).getTime() < 24 * 60 * 60 * 1000
);
const errors24h = last24h.filter((e) => e.status === 'error').length;
const success24h = last24h.filter((e) => e.status === 'success').length;
return {
total: workflows.length,
active: activeCount,
inactive: workflows.length - activeCount,
errors24h,
success24h,
total24h: last24h.length,
};
}, [workflows, executions]);
// ── Computed ────────────────────────────────────────────────────────────────
const lastRunByWorkflow = useMemo(() => {
const map = new Map<string, ExecutionRecord>();
@ -124,187 +151,577 @@ export default function App() {
return map;
}, [executions]);
const last24h = useMemo(
() =>
executions.filter(
(e) => e.started_at && Date.now() - new Date(e.started_at).getTime() < 24 * 60 * 60 * 1000
),
[executions]
);
const stats = useMemo(() => {
const activeCount = workflows.filter((w) => w.active).length;
const errors24h = last24h.filter((e) => e.status === 'error').length;
const success24h = last24h.filter((e) => e.status === 'success').length;
const failRate = last24h.length > 0 ? (errors24h / last24h.length) * 100 : 0;
return {
total: workflows.length,
active: activeCount,
inactive: workflows.length - activeCount,
errors24h,
success24h,
total24h: last24h.length,
failRate,
};
}, [workflows, last24h]);
const failures = useMemo(
() => executions.filter((e) => e.status === 'error').slice(0, 20),
[executions]
);
const anomalies = useMemo<Anomaly[]>(() => {
const out: Anomaly[] = [];
// Stale active workflow
for (const wf of workflows) {
if (!wf.active) continue;
const lastRun = lastRunByWorkflow.get(wf.n8n_id);
const lastTs = lastRun?.started_at ? new Date(lastRun.started_at).getTime() : 0;
if (Date.now() - lastTs > STALE_THRESHOLD_MS) {
out.push({ kind: 'stale', workflow: wf, lastRun: lastRun?.started_at });
}
}
// Burning: last 10 executions for this workflow are all errors (min 5 runs)
const byWorkflow = new Map<string, ExecutionRecord[]>();
for (const e of executions) {
const arr = byWorkflow.get(e.workflow_n8n_id) ?? [];
arr.push(e);
byWorkflow.set(e.workflow_n8n_id, arr);
}
for (const wf of workflows) {
const recent = (byWorkflow.get(wf.n8n_id) ?? [])
.sort((a, b) => (b.started_at || '').localeCompare(a.started_at || ''))
.slice(0, 10);
if (recent.length < 5) continue;
const errs = recent.filter((e) => e.status === 'error').length;
if (errs === recent.length) {
out.push({ kind: 'burning', workflow: wf, errorCount: errs, totalCount: recent.length });
}
}
// Stuck: running for > 30 min
for (const e of executions) {
if (e.status === 'running' && e.started_at) {
const age = Date.now() - new Date(e.started_at).getTime();
if (age > STUCK_RUNNING_MS) {
out.push({ kind: 'stuck', execution: e });
}
}
}
return out;
}, [workflows, executions, lastRunByWorkflow]);
const visibleExecutions = selectedWorkflow
? executions.filter((e) => e.workflow_n8n_id === selectedWorkflow)
: executions;
const pbOk = !error;
const hasFailures = failures.length > 0;
const hasAnomalies = anomalies.length > 0;
// ── Render ─────────────────────────────────────────────────────────────────
return (
<div className="min-h-screen bg-background">
<header className="border-b border-border px-6 py-4">
<h1 className="text-lg font-semibold">n8n Workflow Monitor</h1>
<p className="text-sm text-muted-foreground">Read-only status feed, synced from PocketBase</p>
<div className="min-h-screen text-foreground">
{/* ── Sticky header ───────────────────────────────────────────────── */}
<header className="sticky top-0 z-20 border-b border-border bg-background/85 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="mx-auto flex max-w-7xl items-center justify-between gap-4 px-6 py-3">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-accent/10 ring-1 ring-inset ring-accent/30">
<Zap className="h-4 w-4 text-accent" />
</div>
<div>
<h1 className="text-sm font-semibold tracking-wide">N8N&nbsp;COMMAND&nbsp;CENTER</h1>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
Read-only status feed
</p>
</div>
</div>
<div className="flex items-center gap-3 text-[11px] text-muted-foreground">
<LiveIndicator live={pbOk} />
<span className="hidden sm:inline">·</span>
<span className="hidden sm:inline font-tabular">
{lastSync ? `last sync ${timeAgo(lastSync.toISOString())}` : 'loading…'}
</span>
<span className="hidden md:inline">·</span>
<span className="hidden md:inline">{workflows.length} workflows tracked</span>
</div>
</div>
</header>
<main className="mx-auto max-w-6xl space-y-6 p-6">
{error && (
<Card className="border-error/40">
<CardContent className="flex items-center gap-2 py-4 text-error">
<AlertCircle className="h-4 w-4" />
<span className="text-sm">{error}</span>
</CardContent>
</Card>
<main className="mx-auto max-w-7xl space-y-4 px-6 py-6">
{error && <ErrorBanner message={error} />}
{/* ── Health banner ─────────────────────────────────────────────── */}
<HealthBanner
hasFailures={hasFailures}
errorCount={failures.length}
failRate={stats.failRate}
activeCount={stats.active}
totalCount={stats.total}
/>
{/* ── Failures panel ────────────────────────────────────────────── */}
{hasFailures && (
<FailuresPanel
failures={failures}
workflows={workflows}
onClear={() => setSelectedWorkflow(null)}
/>
)}
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<Card>
<CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle>Active Workflows</CardTitle>
<Power className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-semibold">
{stats.active}
<span className="text-sm font-normal text-muted-foreground"> / {stats.total}</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle>Runs (24h)</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-semibold">{stats.total24h}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle>Succeeded (24h)</CardTitle>
<CheckCircle2 className="h-4 w-4 text-success" />
</CardHeader>
<CardContent>
<div className="text-2xl font-semibold text-success">{stats.success24h}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle>Errors (24h)</CardTitle>
<AlertCircle className="h-4 w-4 text-error" />
</CardHeader>
<CardContent>
<div className="text-2xl font-semibold text-error">{stats.errors24h}</div>
</CardContent>
</Card>
{/* ── Anomalies panel ───────────────────────────────────────────── */}
{hasAnomalies && <AnomaliesPanel anomalies={anomalies} workflows={workflows} />}
{/* ── Main grid ─────────────────────────────────────────────────── */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-5">
<div className="lg:col-span-2">
<WorkflowsPanel
workflows={workflows}
lastRunByWorkflow={lastRunByWorkflow}
selected={selectedWorkflow}
onSelect={setSelectedWorkflow}
loading={loading}
/>
</div>
<div className="lg:col-span-3">
<ActivityPanel
executions={visibleExecutions}
workflows={workflows}
selected={selectedWorkflow}
onClearFilter={() => setSelectedWorkflow(null)}
loading={loading}
/>
</div>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base text-foreground">Workflows</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead>Name</TableHead>
<TableHead>Status</TableHead>
<TableHead>Last Run</TableHead>
<TableHead>Last Run Time</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{workflows.map((wf) => {
const lastRun = lastRunByWorkflow.get(wf.n8n_id);
return (
<TableRow
key={wf.id}
className="cursor-pointer"
onClick={() =>
setSelectedWorkflow(selectedWorkflow === wf.n8n_id ? null : wf.n8n_id)
}
>
<TableCell>
<StatusDot active={wf.active} />
</TableCell>
<TableCell className="font-medium">{wf.name}</TableCell>
<TableCell>
<Badge variant={wf.active ? 'success' : 'muted'}>
{wf.active ? 'Active' : 'Inactive'}
</Badge>
</TableCell>
<TableCell>
{lastRun ? (
<Badge variant={statusVariant(lastRun.status)}>{lastRun.status}</Badge>
) : (
<span className="text-sm text-muted-foreground">No runs yet</span>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{lastRun ? timeAgo(lastRun.started_at) : '—'}
</TableCell>
</TableRow>
);
})}
{workflows.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center text-sm text-muted-foreground">
No workflows synced yet — check that the poller workflow has run.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base text-foreground">
Recent Executions
{selectedWorkflow && (
<button
className="ml-2 text-xs font-normal text-muted-foreground underline"
onClick={() => setSelectedWorkflow(null)}
>
clear filter
</button>
)}
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Workflow</TableHead>
<TableHead>Status</TableHead>
<TableHead>Started</TableHead>
<TableHead>Error</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleExecutions.slice(0, 50).map((ex) => {
const wf = workflows.find((w) => w.n8n_id === ex.workflow_n8n_id);
return (
<TableRow key={ex.id}>
<TableCell className="font-medium">{wf?.name ?? ex.workflow_n8n_id}</TableCell>
<TableCell>
<Badge variant={statusVariant(ex.status)}>{ex.status}</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{timeAgo(ex.started_at)}
</TableCell>
<TableCell className="max-w-xs truncate text-sm text-error">
{ex.error_message || '—'}
</TableCell>
</TableRow>
);
})}
{visibleExecutions.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-center text-sm text-muted-foreground">
No executions synced yet.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
{/* ── Stats strip ───────────────────────────────────────────────── */}
<StatsStrip stats={stats} />
</main>
<footer className="mx-auto max-w-7xl px-6 py-6 text-center text-[10px] uppercase tracking-widest text-muted-foreground/60">
dashboard syncs via pocketbase realtime · pb: <code className="text-muted-foreground">{pb.baseURL}</code>
</footer>
</div>
);
}
// ────────────────────────────────────────────────────────────────────────────
// Header bits
// ────────────────────────────────────────────────────────────────────────────
function LiveIndicator({ live }: { live: boolean }) {
return (
<span className="inline-flex items-center gap-1.5">
<span className="relative flex h-1.5 w-1.5">
{live && <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-success opacity-75" />}
<span
className={cn(
'relative inline-flex h-1.5 w-1.5 rounded-full',
live ? 'bg-success' : 'bg-error'
)}
/>
</span>
<span className={cn('font-semibold uppercase tracking-wider', live ? 'text-success' : 'text-error')}>
{live ? 'live' : 'offline'}
</span>
</span>
);
}
// ────────────────────────────────────────────────────────────────────────────
// Health banner
// ────────────────────────────────────────────────────────────────────────────
function HealthBanner({
hasFailures,
errorCount,
failRate,
activeCount,
totalCount,
}: {
hasFailures: boolean;
errorCount: number;
failRate: number;
activeCount: number;
totalCount: number;
}) {
if (hasFailures) {
return (
<Card className="border-error/40 bg-error/5">
<CardContent className="flex flex-col gap-2 p-5 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-error/15 ring-1 ring-inset ring-error/30">
<AlertCircle className="h-5 w-5 text-error" />
</div>
<div>
<p className="text-base font-semibold text-error">
{errorCount} failure{errorCount === 1 ? '' : 's'} right now
</p>
<p className="text-xs text-muted-foreground">
{failRate.toFixed(1)}% fail rate in the last 24h · {activeCount} of {totalCount} workflows active
</p>
</div>
</div>
</CardContent>
</Card>
);
}
return (
<Card className="border-success/30 bg-success/5">
<CardContent className="flex items-center gap-3 p-5">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-success/15 ring-1 ring-inset ring-success/30">
<CheckCircle2 className="h-5 w-5 text-success" />
</div>
<div className="flex-1">
<p className="text-base font-semibold text-success">All systems healthy</p>
<p className="text-xs text-muted-foreground">
0 errors · {failRate.toFixed(1)}% fail rate (24h) · {activeCount} of {totalCount} workflows active
</p>
</div>
</CardContent>
</Card>
);
}
// ────────────────────────────────────────────────────────────────────────────
// Failures panel
// ────────────────────────────────────────────────────────────────────────────
function FailuresPanel({
failures,
workflows,
onClear,
}: {
failures: ExecutionRecord[];
workflows: WorkflowRecord[];
onClear: () => void;
}) {
return (
<Card className="border-error/30">
<CardHeader className="border-b border-border/60 pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<AlertCircle className="h-3.5 w-3.5 text-error" />
<CardTitle className="text-error">Recent failures</CardTitle>
<Badge variant="error">{failures.length}</Badge>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
<ul className="divide-y divide-border/60">
{failures.slice(0, 8).map((ex) => {
const wf = workflows.find((w) => w.n8n_id === ex.workflow_n8n_id);
return (
<li key={ex.id} className="px-4 py-3 transition-colors hover:bg-error/5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<StatusIcon status="error" />
<span className="truncate text-sm font-medium">{wf?.name ?? ex.workflow_n8n_id}</span>
<Badge variant="error">error</Badge>
<span className="text-xs text-muted-foreground">· {timeAgo(ex.started_at)}</span>
</div>
{ex.error_message && (
<p className="mt-1 truncate font-mono text-xs text-error/80" title={ex.error_message}>
{ex.error_message}
</p>
)}
</div>
<a
href={`${N8N_URL}/execution/${ex.n8n_execution_id}`}
target="_blank"
rel="noreferrer"
className="inline-flex shrink-0 items-center gap-1 rounded-md border border-border bg-muted/30 px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground transition-colors hover:border-accent/40 hover:text-accent"
>
n8n
<ExternalLink className="h-2.5 w-2.5" />
</a>
</div>
</li>
);
})}
</ul>
</CardContent>
</Card>
);
}
// ────────────────────────────────────────────────────────────────────────────
// Anomalies panel
// ────────────────────────────────────────────────────────────────────────────
function AnomaliesPanel({ anomalies, workflows }: { anomalies: Anomaly[]; workflows: WorkflowRecord[] }) {
return (
<Card className="border-warning/30">
<CardHeader className="border-b border-border/60 pb-3">
<div className="flex items-center gap-2">
<AlertTriangle className="h-3.5 w-3.5 text-warning" />
<CardTitle className="text-warning">Possibly off</CardTitle>
<Badge variant="warning">{anomalies.length}</Badge>
</div>
</CardHeader>
<CardContent className="p-0">
<ul className="divide-y divide-border/60">
{anomalies.map((a, i) => (
<li key={i} className="px-4 py-3 transition-colors hover:bg-warning/5">
{a.kind === 'stale' && (
<div className="flex items-center gap-2 text-sm">
<CircleDot className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-medium">{a.workflow.name}</span>
<span className="text-xs text-muted-foreground">·</span>
<span className="text-xs text-muted-foreground">
active but no run in {a.lastRun ? timeAgo(a.lastRun) : 'recorded history'}
</span>
</div>
)}
{a.kind === 'burning' && (
<div className="flex items-center gap-2 text-sm">
<AlertCircle className="h-3.5 w-3.5 text-error" />
<span className="font-medium">{a.workflow.name}</span>
<span className="text-xs text-muted-foreground">·</span>
<span className="font-tabular text-xs text-error">
{a.errorCount}/{a.totalCount} errors (100% fail rate)
</span>
</div>
)}
{a.kind === 'stuck' && (
<div className="flex items-center gap-2 text-sm">
<Radio className="h-3.5 w-3.5 animate-pulse text-pending" />
<span className="font-medium">
{workflows.find((w) => w.n8n_id === a.execution.workflow_n8n_id)?.name ?? a.execution.workflow_n8n_id}
</span>
<span className="text-xs text-muted-foreground">·</span>
<span className="text-xs text-pending">
"running" for {timeAgo(a.execution.started_at)} (unusually long)
</span>
</div>
)}
</li>
))}
</ul>
</CardContent>
</Card>
);
}
// ────────────────────────────────────────────────────────────────────────────
// Workflows panel
// ────────────────────────────────────────────────────────────────────────────
function WorkflowsPanel({
workflows,
lastRunByWorkflow,
selected,
onSelect,
loading,
}: {
workflows: WorkflowRecord[];
lastRunByWorkflow: Map<string, ExecutionRecord>;
selected: string | null;
onSelect: (id: string | null) => void;
loading: boolean;
}) {
return (
<Card>
<CardHeader className="border-b border-border/60 pb-3">
<div className="flex items-center justify-between">
<CardTitle>Workflows</CardTitle>
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
{workflows.length} total
</span>
</div>
</CardHeader>
<CardContent className="p-0">
{loading ? (
<p className="px-4 py-6 text-sm text-muted-foreground">Loading…</p>
) : workflows.length === 0 ? (
<p className="px-4 py-6 text-sm text-muted-foreground">
No workflows yet — the poller will sync them within 5 minutes.
</p>
) : (
<ul className="divide-y divide-border/60">
{workflows.map((wf) => {
const lastRun = lastRunByWorkflow.get(wf.n8n_id);
const isSelected = selected === wf.n8n_id;
return (
<li
key={wf.id}
onClick={() => onSelect(isSelected ? null : wf.n8n_id)}
className={cn(
'flex cursor-pointer items-center gap-3 px-4 py-2.5 transition-colors',
isSelected && 'bg-accent/10 ring-1 ring-inset ring-accent/30'
)}
>
<StatusDot active={wf.active} />
<span className="flex-1 truncate text-sm font-medium">{wf.name}</span>
{lastRun ? (
<Badge variant={statusVariant(lastRun.status)}>{lastRun.status}</Badge>
) : (
<Badge variant="muted">never</Badge>
)}
<span className="hidden w-20 text-right font-tabular text-[11px] text-muted-foreground sm:inline">
{lastRun ? timeAgo(lastRun.started_at) : '—'}
</span>
</li>
);
})}
</ul>
)}
</CardContent>
</Card>
);
}
// ────────────────────────────────────────────────────────────────────────────
// Activity panel
// ────────────────────────────────────────────────────────────────────────────
function ActivityPanel({
executions,
workflows,
selected,
onClearFilter,
loading,
}: {
executions: ExecutionRecord[];
workflows: WorkflowRecord[];
selected: string | null;
onClearFilter: () => void;
loading: boolean;
}) {
return (
<Card>
<CardHeader className="border-b border-border/60 pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<CardTitle>Activity</CardTitle>
{selected && (
<button
onClick={onClearFilter}
className="text-[10px] font-normal uppercase tracking-wider text-muted-foreground underline-offset-2 hover:text-accent hover:underline"
>
clear filter
</button>
)}
</div>
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
last {Math.min(executions.length, 50)} of {executions.length}
</span>
</div>
</CardHeader>
<CardContent className="p-0">
{loading ? (
<p className="px-4 py-6 text-sm text-muted-foreground">Loading…</p>
) : executions.length === 0 ? (
<p className="px-4 py-6 text-sm text-muted-foreground">
No executions synced yet. They appear here within 5 minutes of the poller's first run.
</p>
) : (
<ul className="divide-y divide-border/60">
{executions.slice(0, 50).map((ex) => {
const wf = workflows.find((w) => w.n8n_id === ex.workflow_n8n_id);
return (
<li key={ex.id} className="px-4 py-2.5 transition-colors hover:bg-muted/30">
<div className="flex items-start gap-3">
<StatusIcon status={ex.status} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm">{wf?.name ?? ex.workflow_n8n_id}</span>
<Badge variant={statusVariant(ex.status)}>{ex.status}</Badge>
</div>
{ex.error_message && ex.status === 'error' && (
<p className="mt-0.5 truncate font-mono text-[11px] text-error/80" title={ex.error_message}>
{ex.error_message}
</p>
)}
</div>
<span className="shrink-0 font-tabular text-[11px] text-muted-foreground">
{timeAgo(ex.started_at)}
</span>
</div>
</li>
);
})}
</ul>
)}
</CardContent>
</Card>
);
}
// ────────────────────────────────────────────────────────────────────────────
// Stats strip
// ────────────────────────────────────────────────────────────────────────────
type Stats = {
total: number;
active: number;
inactive: number;
errors24h: number;
success24h: number;
total24h: number;
failRate: number;
};
function StatsStrip({ stats }: { stats: Stats }) {
const items = [
{ label: 'Active', value: stats.active, sub: `of ${stats.total}`, color: 'text-success' },
{ label: 'Runs 24h', value: stats.total24h, sub: null, color: 'text-foreground' },
{ label: 'Succeeded', value: stats.success24h, sub: '24h', color: 'text-success' },
{ label: 'Errors', value: stats.errors24h, sub: '24h', color: stats.errors24h > 0 ? 'text-error' : 'text-foreground' },
{
label: 'Fail rate',
value: `${stats.failRate.toFixed(1)}%`,
sub: '24h',
color: stats.failRate > 5 ? 'text-error' : stats.failRate > 0 ? 'text-warning' : 'text-success',
},
];
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{items.map((it) => (
<div
key={it.label}
className="rounded-lg border border-border bg-card px-4 py-3 transition-colors hover:border-border/80"
>
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{it.label}
</p>
<p className={cn('mt-1 font-tabular text-2xl font-semibold', it.color)}>
{it.value}
{it.sub && <span className="ml-1 text-xs font-normal text-muted-foreground">{it.sub}</span>}
</p>
</div>
))}
</div>
);
}
// ────────────────────────────────────────────────────────────────────────────
// Error banner
// ────────────────────────────────────────────────────────────────────────────
function ErrorBanner({ message }: { message: string }) {
return (
<Card className="border-error/40 bg-error/5">
<CardContent className="flex items-center gap-3 p-4">
<Power className="h-4 w-4 text-error" />
<p className="text-sm text-error">{message}</p>
</CardContent>
</Card>
);
}

View file

@ -1,14 +1,19 @@
import { cn } from '@/lib/utils';
export function StatusDot({ active }: { active: boolean }) {
export function StatusDot({ active, pulsing = true }: { active: boolean; pulsing?: boolean }) {
return (
<span className="relative flex h-2.5 w-2.5">
{active && (
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-success opacity-60" />
<span className="relative flex h-2 w-2 shrink-0">
{active && pulsing && (
<span
className={cn(
'absolute inline-flex h-full w-full animate-ping rounded-full',
'bg-success opacity-75'
)}
/>
)}
<span
className={cn(
'relative inline-flex h-2.5 w-2.5 rounded-full',
'relative inline-flex h-2 w-2 rounded-full',
active ? 'bg-success' : 'bg-muted-foreground/40'
)}
/>

View file

@ -1,14 +1,16 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
type BadgeVariant = 'default' | 'success' | 'error' | 'pending' | 'muted';
type BadgeVariant = 'default' | 'success' | 'error' | 'pending' | 'warning' | 'muted' | 'info';
const variantClasses: Record<BadgeVariant, string> = {
default: 'bg-foreground text-background',
success: 'bg-success/10 text-success ring-1 ring-inset ring-success/30',
error: 'bg-error/10 text-error ring-1 ring-inset ring-error/30',
pending: 'bg-pending/10 text-pending ring-1 ring-inset ring-pending/30',
muted: 'bg-muted text-muted-foreground',
default: 'bg-foreground/10 text-foreground ring-1 ring-inset ring-foreground/20',
success: 'bg-success/15 text-success ring-1 ring-inset ring-success/30',
error: 'bg-error/15 text-error ring-1 ring-inset ring-error/30',
pending: 'bg-pending/15 text-pending ring-1 ring-inset ring-pending/30',
warning: 'bg-warning/15 text-warning ring-1 ring-inset ring-warning/30',
info: 'bg-info/15 text-info ring-1 ring-inset ring-info/30',
muted: 'bg-muted text-muted-foreground ring-1 ring-inset ring-border',
};
export function Badge({
@ -23,7 +25,7 @@ export function Badge({
return (
<span
className={cn(
'inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium',
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider',
variantClasses[variant],
className
)}

View file

@ -4,7 +4,10 @@ import { cn } from '@/lib/utils';
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('rounded-lg border border-border bg-card text-card-foreground shadow-sm', className)}
className={cn(
'rounded-lg border border-border bg-card text-card-foreground',
className
)}
{...props}
/>
);
@ -15,7 +18,12 @@ export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDiv
}
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
return <h3 className={cn('text-sm font-medium text-muted-foreground', className)} {...props} />;
return (
<h3
className={cn('text-xs font-semibold uppercase tracking-wider text-muted-foreground', className)}
{...props}
/>
);
}
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {

View file

@ -18,18 +18,21 @@ export function TableBody({ className, ...props }: React.HTMLAttributes<HTMLTabl
}
export function TableRow({ className, ...props }: React.HTMLAttributes<HTMLTableRowElement>) {
return <tr className={cn('hover:bg-muted/40 transition-colors', className)} {...props} />;
return <tr className={cn('transition-colors hover:bg-muted/30', className)} {...props} />;
}
export function TableHead({ className, ...props }: React.ThHTMLAttributes<HTMLTableCellElement>) {
return (
<th
className={cn('h-10 px-4 text-left align-middle font-medium text-muted-foreground', className)}
className={cn(
'h-9 px-3 text-left align-middle text-[10px] font-semibold uppercase tracking-wider text-muted-foreground',
className
)}
{...props}
/>
);
}
export function TableCell({ className, ...props }: React.TdHTMLAttributes<HTMLTableCellElement>) {
return <td className={cn('p-4 align-middle', className)} {...props} />;
return <td className={cn('p-3 align-middle', className)} {...props} />;
}

View file

@ -2,6 +2,28 @@
@tailwind components;
@tailwind utilities;
body {
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
@layer base {
html, body {
background-color: theme('colors.background');
color: theme('colors.foreground');
font-family: theme('fontFamily.sans');
-webkit-font-smoothing: antialiased;
}
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%);
}
::selection {
background-color: rgba(96, 165, 250, 0.3);
}
}
@layer utilities {
.font-tabular {
font-variant-numeric: tabular-nums;
}
.text-shadow-glow {
text-shadow: 0 0 12px currentColor;
}
}

View file

@ -5,6 +5,11 @@ import PocketBase from 'pocketbase';
const envUrl = import.meta.env.VITE_POCKETBASE_URL?.trim();
export const POCKETBASE_URL = envUrl && envUrl.length > 0 ? envUrl : 'http://127.0.0.1:8090';
// n8n base URL — used to deep-link to workflows and executions. Set via VITE_N8N_URL.
// Falls back to a sensible default for dev.
const n8nEnvUrl = import.meta.env.VITE_N8N_URL?.trim();
export const N8N_URL = n8nEnvUrl && n8nEnvUrl.length > 0 ? n8nEnvUrl : 'http://localhost:5678';
export const pb = new PocketBase(POCKETBASE_URL);
// Disable PocketBase's default auto-cancellation of pending requests on each new one.
@ -21,11 +26,13 @@ export type WorkflowRecord = {
updated: string;
};
export type ExecutionStatus = 'success' | 'error' | 'running' | 'waiting' | 'canceled';
export type ExecutionRecord = {
id: string;
n8n_execution_id: string;
workflow_n8n_id: string;
status: 'success' | 'error' | 'running' | 'waiting' | 'canceled';
status: ExecutionStatus;
started_at: string;
stopped_at: string;
error_message?: string;

10
dashboard/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_POCKETBASE_URL?: string;
readonly VITE_N8N_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View file

@ -5,26 +5,48 @@ export default {
theme: {
extend: {
colors: {
border: 'hsl(214.3 31.8% 91.4%)',
background: 'hsl(0 0% 100%)',
foreground: 'hsl(222.2 84% 4.9%)',
// Surfaces (dark by default — command center look)
border: '#27272a', // zinc-800
background: '#09090b', // zinc-950
foreground: '#fafafa', // zinc-50
muted: {
DEFAULT: 'hsl(210 40% 96.1%)',
foreground: 'hsl(215.4 16.3% 46.9%)',
DEFAULT: '#18181b', // zinc-900 (panels)
foreground: '#a1a1aa', // zinc-400 (secondary text)
},
card: {
DEFAULT: 'hsl(0 0% 100%)',
foreground: 'hsl(222.2 84% 4.9%)',
DEFAULT: '#0f0f12', // slight lift from background
foreground: '#fafafa',
},
success: '#16a34a',
error: '#dc2626',
pending: '#ca8a04',
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
},
borderRadius: {
lg: '0.5rem',
md: '0.375rem',
sm: '0.25rem',
},
fontFamily: {
mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'monospace'],
sans: ['ui-sans-serif', 'system-ui', '-apple-system', 'sans-serif'],
},
animation: {
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
},
},
},
plugins: [],

View file

@ -3,11 +3,11 @@
# In Coolify:
# 1. New Resource → Docker Compose
# 2. Point it at this file
# 3. Set the VITE_POCKETBASE_URL env var (in Coolify's env tab) to your real PB URL
# 3. Set the VITE_POCKETBASE_URL and VITE_N8N_URL env vars (in Coolify's env tab)
# 4. Deploy — Coolify injects Traefik labels automatically
#
# Standalone (docker compose up):
# 1. Copy .env.example to .env at this dir root and set VITE_POCKETBASE_URL
# 1. Copy .env.example to .env at this dir root and set the URLs
# 2. docker compose up --build
# 3. Open http://localhost:8080
@ -19,6 +19,7 @@ services:
args:
# Falls back to localhost if env var isn't set; Coolify should set this.
- VITE_POCKETBASE_URL=${VITE_POCKETBASE_URL:-http://127.0.0.1:8090}
- VITE_N8N_URL=${VITE_N8N_URL:-http://localhost:5678}
image: n8n-monitor-dashboard:latest
container_name: n8n-monitor-dashboard
restart: unless-stopped

View file

@ -3,7 +3,7 @@
"nodes": [
{
"parameters": {
"content": "## Setup\n1. Create an \"HTTP Header Auth\" credential named \"n8n API Key\" (header X-N8N-API-KEY = your n8n API key).\n2. Create an \"HTTP Header Auth\" credential named \"PocketBase Admin\" (header Authorization = \"Bearer <your PocketBase admin token>\").\n3. PocketBase base URL is pre-set to http://pocketbase-fftm1vu8twxfixup17v82xwe.169.239.180.130.sslip.io — no change needed.\n4. Collections already created in PocketBase.\n5. Activate this workflow when ready.",
"content": "## Setup\n1. Create an \"HTTP Header Auth\" credential named \"n8n API Key\" (header X-N8N-API-KEY = your n8n API key).\n2. Create an \"HTTP Header Auth\" credential named \"PocketBase Admin\" (header Authorization = \"Bearer <your PocketBase admin token>\").\n3. PocketBase base URL is pre-set to http://pocketbase-fftm1vu8twxfixup17v82xwe.169.239.180.130.sslip.io \u2014 no change needed.\n4. Collections already created in PocketBase.\n5. Activate this workflow when ready.",
"height": 200,
"width": 300,
"color": 4
@ -201,7 +201,7 @@
},
{
"name": "includeData",
"value": "false"
"value": "true"
}
]
},
@ -306,7 +306,7 @@
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ { \"status\": $(\"Split Executions Array\").item.json.status, \"started_at\": $(\"Split Executions Array\").item.json.startedAt, \"stopped_at\": $(\"Split Executions Array\").item.json.stoppedAt, \"workflow_n8n_id\": $(\"Split Executions Array\").item.json.workflowId } }}",
"jsonBody": "={{ { \"status\": $(\"Split Executions Array\").item.json.status, \"started_at\": $(\"Split Executions Array\").item.json.startedAt, \"stopped_at\": $(\"Split Executions Array\").item.json.stoppedAt, \"workflow_n8n_id\": $(\"Split Executions Array\").item.json.workflowId, \"error_message\": ((() => { try { return JSON.parse($('Split Executions Array').item.json.data || '{}')?.resultData?.error?.message || ''; } catch { return ''; } })()) } }}",
"options": {}
},
"id": "15dc5e8c-9d0f-4583-a0ce-dc43ea1b609c",
@ -339,7 +339,7 @@
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ { \"n8n_execution_id\": $(\"Split Executions Array\").item.json.id, \"workflow_n8n_id\": $(\"Split Executions Array\").item.json.workflowId, \"status\": $(\"Split Executions Array\").item.json.status, \"started_at\": $(\"Split Executions Array\").item.json.startedAt, \"stopped_at\": $(\"Split Executions Array\").item.json.stoppedAt } }}",
"jsonBody": "={{ { \"n8n_execution_id\": $(\"Split Executions Array\").item.json.id, \"workflow_n8n_id\": $(\"Split Executions Array\").item.json.workflowId, \"status\": $(\"Split Executions Array\").item.json.status, \"started_at\": $(\"Split Executions Array\").item.json.startedAt, \"stopped_at\": $(\"Split Executions Array\").item.json.stoppedAt, \"error_message\": ((() => { try { return JSON.parse($('Split Executions Array').item.json.data || '{}')?.resultData?.error?.message || ''; } catch { return ''; } })()) } }}",
"options": {}
},
"id": "acd3d95a-1e95-4312-88f2-16ba8978c7e2",