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:
parent
edab0c37b6
commit
cd1a922976
14 changed files with 756 additions and 248 deletions
|
|
@ -2,3 +2,6 @@
|
||||||
# Coolify: set in the service's Environment Variables tab.
|
# Coolify: set in the service's Environment Variables tab.
|
||||||
# Standalone docker compose: copy this to .env and edit.
|
# Standalone docker compose: copy this to .env and edit.
|
||||||
VITE_POCKETBASE_URL=http://127.0.0.1:8090
|
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
|
||||||
|
|
|
||||||
|
|
@ -3,3 +3,7 @@
|
||||||
# In prod (Coolify / Cloudflare Tunnel / reverse proxy): set to your public PB URL.
|
# In prod (Coolify / Cloudflare Tunnel / reverse proxy): set to your public PB URL.
|
||||||
# Example: VITE_POCKETBASE_URL=https://pocketbase.yourdomain.com
|
# Example: VITE_POCKETBASE_URL=https://pocketbase.yourdomain.com
|
||||||
VITE_POCKETBASE_URL=http://127.0.0.1:8090
|
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
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,10 @@ RUN npm ci
|
||||||
ARG VITE_POCKETBASE_URL
|
ARG VITE_POCKETBASE_URL
|
||||||
ENV VITE_POCKETBASE_URL=$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 . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,28 @@
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { StatusDot } from '@/components/status-dot';
|
import { StatusDot } from '@/components/status-dot';
|
||||||
import {
|
import { cn } from '@/lib/utils';
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@/components/ui/table';
|
|
||||||
import { AlertCircle, CheckCircle2, Clock, Power } from 'lucide-react';
|
|
||||||
|
|
||||||
function statusVariant(status: ExecutionRecord['status']) {
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
switch (status) {
|
// Helpers
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function timeAgo(iso: string) {
|
function timeAgo(iso?: string) {
|
||||||
if (!iso) return '—';
|
if (!iso) return '—';
|
||||||
const diffMs = Date.now() - new Date(iso).getTime();
|
const diffMs = Date.now() - new Date(iso).getTime();
|
||||||
const mins = Math.round(diffMs / 60000);
|
const mins = Math.round(diffMs / 60000);
|
||||||
|
|
@ -38,11 +33,56 @@ function timeAgo(iso: string) {
|
||||||
return `${Math.round(hrs / 24)}d ago`;
|
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() {
|
export default function App() {
|
||||||
const [workflows, setWorkflows] = useState<WorkflowRecord[]>([]);
|
const [workflows, setWorkflows] = useState<WorkflowRecord[]>([]);
|
||||||
const [executions, setExecutions] = useState<ExecutionRecord[]>([]);
|
const [executions, setExecutions] = useState<ExecutionRecord[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [lastSync, setLastSync] = useState<Date | null>(null);
|
||||||
const [selectedWorkflow, setSelectedWorkflow] = useState<string | null>(null);
|
const [selectedWorkflow, setSelectedWorkflow] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -55,21 +95,21 @@ export default function App() {
|
||||||
pb.collection('workflows').getFullList<WorkflowRecord>({ sort: 'name' }),
|
pb.collection('workflows').getFullList<WorkflowRecord>({ sort: 'name' }),
|
||||||
pb.collection('executions').getFullList<ExecutionRecord>({
|
pb.collection('executions').getFullList<ExecutionRecord>({
|
||||||
sort: '-started_at',
|
sort: '-started_at',
|
||||||
perPage: 100,
|
perPage: 200,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
setWorkflows(wf);
|
setWorkflows(wf);
|
||||||
setExecutions(ex);
|
setExecutions(ex);
|
||||||
|
setLastSync(new Date());
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
} catch (e) {
|
} 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);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
load();
|
load();
|
||||||
|
|
||||||
// Realtime subscriptions so the dashboard updates without polling.
|
|
||||||
pb.collection('workflows')
|
pb.collection('workflows')
|
||||||
.subscribe<WorkflowRecord>('*', (e) => {
|
.subscribe<WorkflowRecord>('*', (e) => {
|
||||||
setWorkflows((prev) => {
|
setWorkflows((prev) => {
|
||||||
|
|
@ -77,6 +117,7 @@ export default function App() {
|
||||||
if (e.action === 'delete') return others;
|
if (e.action === 'delete') return others;
|
||||||
return [...others, e.record].sort((a, b) => a.name.localeCompare(b.name));
|
return [...others, e.record].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
});
|
});
|
||||||
|
setLastSync(new Date());
|
||||||
})
|
})
|
||||||
.then((unsub) => (unsubWorkflows = unsub));
|
.then((unsub) => (unsubWorkflows = unsub));
|
||||||
|
|
||||||
|
|
@ -89,6 +130,7 @@ export default function App() {
|
||||||
.sort((a, b) => (b.started_at || '').localeCompare(a.started_at || ''))
|
.sort((a, b) => (b.started_at || '').localeCompare(a.started_at || ''))
|
||||||
.slice(0, 200);
|
.slice(0, 200);
|
||||||
});
|
});
|
||||||
|
setLastSync(new Date());
|
||||||
})
|
})
|
||||||
.then((unsub) => (unsubExecutions = unsub));
|
.then((unsub) => (unsubExecutions = unsub));
|
||||||
|
|
||||||
|
|
@ -98,22 +140,7 @@ export default function App() {
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const stats = useMemo(() => {
|
// ── Computed ────────────────────────────────────────────────────────────────
|
||||||
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]);
|
|
||||||
|
|
||||||
const lastRunByWorkflow = useMemo(() => {
|
const lastRunByWorkflow = useMemo(() => {
|
||||||
const map = new Map<string, ExecutionRecord>();
|
const map = new Map<string, ExecutionRecord>();
|
||||||
|
|
@ -124,187 +151,577 @@ export default function App() {
|
||||||
return map;
|
return map;
|
||||||
}, [executions]);
|
}, [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
|
const visibleExecutions = selectedWorkflow
|
||||||
? executions.filter((e) => e.workflow_n8n_id === selectedWorkflow)
|
? executions.filter((e) => e.workflow_n8n_id === selectedWorkflow)
|
||||||
: executions;
|
: executions;
|
||||||
|
|
||||||
|
const pbOk = !error;
|
||||||
|
const hasFailures = failures.length > 0;
|
||||||
|
const hasAnomalies = anomalies.length > 0;
|
||||||
|
|
||||||
|
// ── Render ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen text-foreground">
|
||||||
<header className="border-b border-border px-6 py-4">
|
{/* ── Sticky header ───────────────────────────────────────────────── */}
|
||||||
<h1 className="text-lg font-semibold">n8n Workflow Monitor</h1>
|
<header className="sticky top-0 z-20 border-b border-border bg-background/85 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
<p className="text-sm text-muted-foreground">Read-only status feed, synced from PocketBase</p>
|
<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 COMMAND 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>
|
</header>
|
||||||
|
|
||||||
<main className="mx-auto max-w-6xl space-y-6 p-6">
|
<main className="mx-auto max-w-7xl space-y-4 px-6 py-6">
|
||||||
{error && (
|
{error && <ErrorBanner message={error} />}
|
||||||
<Card className="border-error/40">
|
|
||||||
<CardContent className="flex items-center gap-2 py-4 text-error">
|
{/* ── Health banner ─────────────────────────────────────────────── */}
|
||||||
<AlertCircle className="h-4 w-4" />
|
<HealthBanner
|
||||||
<span className="text-sm">{error}</span>
|
hasFailures={hasFailures}
|
||||||
</CardContent>
|
errorCount={failures.length}
|
||||||
</Card>
|
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">
|
{/* ── Anomalies panel ───────────────────────────────────────────── */}
|
||||||
<Card>
|
{hasAnomalies && <AnomaliesPanel anomalies={anomalies} workflows={workflows} />}
|
||||||
<CardHeader className="flex-row items-center justify-between space-y-0">
|
|
||||||
<CardTitle>Active Workflows</CardTitle>
|
{/* ── Main grid ─────────────────────────────────────────────────── */}
|
||||||
<Power className="h-4 w-4 text-muted-foreground" />
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-5">
|
||||||
</CardHeader>
|
<div className="lg:col-span-2">
|
||||||
<CardContent>
|
<WorkflowsPanel
|
||||||
<div className="text-2xl font-semibold">
|
workflows={workflows}
|
||||||
{stats.active}
|
lastRunByWorkflow={lastRunByWorkflow}
|
||||||
<span className="text-sm font-normal text-muted-foreground"> / {stats.total}</span>
|
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>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
{/* ── Stats strip ───────────────────────────────────────────────── */}
|
||||||
<CardHeader>
|
<StatsStrip stats={stats} />
|
||||||
<CardTitle className="text-base text-foreground">Workflows</CardTitle>
|
</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>
|
</CardHeader>
|
||||||
<CardContent>
|
<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 ? (
|
{loading ? (
|
||||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<Table>
|
<ul className="divide-y divide-border/60">
|
||||||
<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) => {
|
{workflows.map((wf) => {
|
||||||
const lastRun = lastRunByWorkflow.get(wf.n8n_id);
|
const lastRun = lastRunByWorkflow.get(wf.n8n_id);
|
||||||
|
const isSelected = selected === wf.n8n_id;
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<li
|
||||||
key={wf.id}
|
key={wf.id}
|
||||||
className="cursor-pointer"
|
onClick={() => onSelect(isSelected ? null : wf.n8n_id)}
|
||||||
onClick={() =>
|
className={cn(
|
||||||
setSelectedWorkflow(selectedWorkflow === wf.n8n_id ? null : wf.n8n_id)
|
'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'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<TableCell>
|
|
||||||
<StatusDot active={wf.active} />
|
<StatusDot active={wf.active} />
|
||||||
</TableCell>
|
<span className="flex-1 truncate text-sm font-medium">{wf.name}</span>
|
||||||
<TableCell className="font-medium">{wf.name}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={wf.active ? 'success' : 'muted'}>
|
|
||||||
{wf.active ? 'Active' : 'Inactive'}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{lastRun ? (
|
{lastRun ? (
|
||||||
<Badge variant={statusVariant(lastRun.status)}>{lastRun.status}</Badge>
|
<Badge variant={statusVariant(lastRun.status)}>{lastRun.status}</Badge>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm text-muted-foreground">No runs yet</span>
|
<Badge variant="muted">never</Badge>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
<span className="hidden w-20 text-right font-tabular text-[11px] text-muted-foreground sm:inline">
|
||||||
<TableCell className="text-sm text-muted-foreground">
|
|
||||||
{lastRun ? timeAgo(lastRun.started_at) : '—'}
|
{lastRun ? timeAgo(lastRun.started_at) : '—'}
|
||||||
</TableCell>
|
</span>
|
||||||
</TableRow>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{workflows.length === 0 && (
|
</ul>
|
||||||
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Activity panel
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ActivityPanel({
|
||||||
|
executions,
|
||||||
|
workflows,
|
||||||
|
selected,
|
||||||
|
onClearFilter,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
executions: ExecutionRecord[];
|
||||||
|
workflows: WorkflowRecord[];
|
||||||
|
selected: string | null;
|
||||||
|
onClearFilter: () => void;
|
||||||
|
loading: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="border-b border-border/60 pb-3">
|
||||||
<CardTitle className="text-base text-foreground">
|
<div className="flex items-center justify-between">
|
||||||
Recent Executions
|
<div className="flex items-center gap-2">
|
||||||
{selectedWorkflow && (
|
<CardTitle>Activity</CardTitle>
|
||||||
|
{selected && (
|
||||||
<button
|
<button
|
||||||
className="ml-2 text-xs font-normal text-muted-foreground underline"
|
onClick={onClearFilter}
|
||||||
onClick={() => setSelectedWorkflow(null)}
|
className="text-[10px] font-normal uppercase tracking-wider text-muted-foreground underline-offset-2 hover:text-accent hover:underline"
|
||||||
>
|
>
|
||||||
clear filter
|
clear filter
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</CardTitle>
|
</div>
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||||
|
last {Math.min(executions.length, 50)} of {executions.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-0">
|
||||||
<Table>
|
{loading ? (
|
||||||
<TableHeader>
|
<p className="px-4 py-6 text-sm text-muted-foreground">Loading…</p>
|
||||||
<TableRow>
|
) : executions.length === 0 ? (
|
||||||
<TableHead>Workflow</TableHead>
|
<p className="px-4 py-6 text-sm text-muted-foreground">
|
||||||
<TableHead>Status</TableHead>
|
No executions synced yet. They appear here within 5 minutes of the poller's first run.
|
||||||
<TableHead>Started</TableHead>
|
</p>
|
||||||
<TableHead>Error</TableHead>
|
) : (
|
||||||
</TableRow>
|
<ul className="divide-y divide-border/60">
|
||||||
</TableHeader>
|
{executions.slice(0, 50).map((ex) => {
|
||||||
<TableBody>
|
|
||||||
{visibleExecutions.slice(0, 50).map((ex) => {
|
|
||||||
const wf = workflows.find((w) => w.n8n_id === ex.workflow_n8n_id);
|
const wf = workflows.find((w) => w.n8n_id === ex.workflow_n8n_id);
|
||||||
return (
|
return (
|
||||||
<TableRow key={ex.id}>
|
<li key={ex.id} className="px-4 py-2.5 transition-colors hover:bg-muted/30">
|
||||||
<TableCell className="font-medium">{wf?.name ?? ex.workflow_n8n_id}</TableCell>
|
<div className="flex items-start gap-3">
|
||||||
<TableCell>
|
<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>
|
<Badge variant={statusVariant(ex.status)}>{ex.status}</Badge>
|
||||||
</TableCell>
|
</div>
|
||||||
<TableCell className="text-sm text-muted-foreground">
|
{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)}
|
{timeAgo(ex.started_at)}
|
||||||
</TableCell>
|
</span>
|
||||||
<TableCell className="max-w-xs truncate text-sm text-error">
|
</div>
|
||||||
{ex.error_message || '—'}
|
</li>
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{visibleExecutions.length === 0 && (
|
</ul>
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={4} className="text-center text-sm text-muted-foreground">
|
|
||||||
No executions synced yet.
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</main>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 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>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,19 @@
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export function StatusDot({ active }: { active: boolean }) {
|
export function StatusDot({ active, pulsing = true }: { active: boolean; pulsing?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<span className="relative flex h-2.5 w-2.5">
|
<span className="relative flex h-2 w-2 shrink-0">
|
||||||
{active && (
|
{active && pulsing && (
|
||||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-success opacity-60" />
|
<span
|
||||||
|
className={cn(
|
||||||
|
'absolute inline-flex h-full w-full animate-ping rounded-full',
|
||||||
|
'bg-success opacity-75'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<span
|
<span
|
||||||
className={cn(
|
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'
|
active ? 'bg-success' : 'bg-muted-foreground/40'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
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> = {
|
const variantClasses: Record<BadgeVariant, string> = {
|
||||||
default: 'bg-foreground text-background',
|
default: 'bg-foreground/10 text-foreground ring-1 ring-inset ring-foreground/20',
|
||||||
success: 'bg-success/10 text-success ring-1 ring-inset ring-success/30',
|
success: 'bg-success/15 text-success ring-1 ring-inset ring-success/30',
|
||||||
error: 'bg-error/10 text-error ring-1 ring-inset ring-error/30',
|
error: 'bg-error/15 text-error ring-1 ring-inset ring-error/30',
|
||||||
pending: 'bg-pending/10 text-pending ring-1 ring-inset ring-pending/30',
|
pending: 'bg-pending/15 text-pending ring-1 ring-inset ring-pending/30',
|
||||||
muted: 'bg-muted text-muted-foreground',
|
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({
|
export function Badge({
|
||||||
|
|
@ -23,7 +25,7 @@ export function Badge({
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
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],
|
variantClasses[variant],
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,10 @@ import { cn } from '@/lib/utils';
|
||||||
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -15,7 +18,12 @@ export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDiv
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
|
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>) {
|
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
|
|
||||||
|
|
@ -18,18 +18,21 @@ export function TableBody({ className, ...props }: React.HTMLAttributes<HTMLTabl
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TableRow({ className, ...props }: React.HTMLAttributes<HTMLTableRowElement>) {
|
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>) {
|
export function TableHead({ className, ...props }: React.ThHTMLAttributes<HTMLTableCellElement>) {
|
||||||
return (
|
return (
|
||||||
<th
|
<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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TableCell({ className, ...props }: React.TdHTMLAttributes<HTMLTableCellElement>) {
|
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} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,28 @@
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
body {
|
@layer base {
|
||||||
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,11 @@ import PocketBase from 'pocketbase';
|
||||||
const envUrl = import.meta.env.VITE_POCKETBASE_URL?.trim();
|
const envUrl = import.meta.env.VITE_POCKETBASE_URL?.trim();
|
||||||
export const POCKETBASE_URL = envUrl && envUrl.length > 0 ? envUrl : 'http://127.0.0.1:8090';
|
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);
|
export const pb = new PocketBase(POCKETBASE_URL);
|
||||||
|
|
||||||
// Disable PocketBase's default auto-cancellation of pending requests on each new one.
|
// Disable PocketBase's default auto-cancellation of pending requests on each new one.
|
||||||
|
|
@ -21,11 +26,13 @@ export type WorkflowRecord = {
|
||||||
updated: string;
|
updated: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ExecutionStatus = 'success' | 'error' | 'running' | 'waiting' | 'canceled';
|
||||||
|
|
||||||
export type ExecutionRecord = {
|
export type ExecutionRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
n8n_execution_id: string;
|
n8n_execution_id: string;
|
||||||
workflow_n8n_id: string;
|
workflow_n8n_id: string;
|
||||||
status: 'success' | 'error' | 'running' | 'waiting' | 'canceled';
|
status: ExecutionStatus;
|
||||||
started_at: string;
|
started_at: string;
|
||||||
stopped_at: string;
|
stopped_at: string;
|
||||||
error_message?: string;
|
error_message?: string;
|
||||||
|
|
|
||||||
10
dashboard/src/vite-env.d.ts
vendored
Normal file
10
dashboard/src/vite-env.d.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -5,26 +5,48 @@ export default {
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
border: 'hsl(214.3 31.8% 91.4%)',
|
// Surfaces (dark by default — command center look)
|
||||||
background: 'hsl(0 0% 100%)',
|
border: '#27272a', // zinc-800
|
||||||
foreground: 'hsl(222.2 84% 4.9%)',
|
background: '#09090b', // zinc-950
|
||||||
|
foreground: '#fafafa', // zinc-50
|
||||||
muted: {
|
muted: {
|
||||||
DEFAULT: 'hsl(210 40% 96.1%)',
|
DEFAULT: '#18181b', // zinc-900 (panels)
|
||||||
foreground: 'hsl(215.4 16.3% 46.9%)',
|
foreground: '#a1a1aa', // zinc-400 (secondary text)
|
||||||
},
|
},
|
||||||
card: {
|
card: {
|
||||||
DEFAULT: 'hsl(0 0% 100%)',
|
DEFAULT: '#0f0f12', // slight lift from background
|
||||||
foreground: 'hsl(222.2 84% 4.9%)',
|
foreground: '#fafafa',
|
||||||
},
|
},
|
||||||
success: '#16a34a',
|
popover: {
|
||||||
error: '#dc2626',
|
DEFAULT: '#18181b',
|
||||||
pending: '#ca8a04',
|
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: {
|
borderRadius: {
|
||||||
lg: '0.5rem',
|
lg: '0.5rem',
|
||||||
md: '0.375rem',
|
md: '0.375rem',
|
||||||
sm: '0.25rem',
|
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: [],
|
plugins: [],
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,11 @@
|
||||||
# In Coolify:
|
# In Coolify:
|
||||||
# 1. New Resource → Docker Compose
|
# 1. New Resource → Docker Compose
|
||||||
# 2. Point it at this file
|
# 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
|
# 4. Deploy — Coolify injects Traefik labels automatically
|
||||||
#
|
#
|
||||||
# Standalone (docker compose up):
|
# 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
|
# 2. docker compose up --build
|
||||||
# 3. Open http://localhost:8080
|
# 3. Open http://localhost:8080
|
||||||
|
|
||||||
|
|
@ -19,6 +19,7 @@ services:
|
||||||
args:
|
args:
|
||||||
# Falls back to localhost if env var isn't set; Coolify should set this.
|
# 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_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
|
image: n8n-monitor-dashboard:latest
|
||||||
container_name: n8n-monitor-dashboard
|
container_name: n8n-monitor-dashboard
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
"nodes": [
|
"nodes": [
|
||||||
{
|
{
|
||||||
"parameters": {
|
"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,
|
"height": 200,
|
||||||
"width": 300,
|
"width": 300,
|
||||||
"color": 4
|
"color": 4
|
||||||
|
|
@ -201,7 +201,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "includeData",
|
"name": "includeData",
|
||||||
"value": "false"
|
"value": "true"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
@ -306,7 +306,7 @@
|
||||||
"genericAuthType": "httpHeaderAuth",
|
"genericAuthType": "httpHeaderAuth",
|
||||||
"sendBody": true,
|
"sendBody": true,
|
||||||
"specifyBody": "json",
|
"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": {}
|
"options": {}
|
||||||
},
|
},
|
||||||
"id": "15dc5e8c-9d0f-4583-a0ce-dc43ea1b609c",
|
"id": "15dc5e8c-9d0f-4583-a0ce-dc43ea1b609c",
|
||||||
|
|
@ -339,7 +339,7 @@
|
||||||
"genericAuthType": "httpHeaderAuth",
|
"genericAuthType": "httpHeaderAuth",
|
||||||
"sendBody": true,
|
"sendBody": true,
|
||||||
"specifyBody": "json",
|
"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": {}
|
"options": {}
|
||||||
},
|
},
|
||||||
"id": "acd3d95a-1e95-4312-88f2-16ba8978c7e2",
|
"id": "acd3d95a-1e95-4312-88f2-16ba8978c7e2",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue