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.
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>) {
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
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: {
|
||||
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: [],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue