diff --git a/.env.example b/.env.example index 09a3840..d5c2ba1 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/dashboard/.env.example b/dashboard/.env.example index e97a286..d617d5d 100644 --- a/dashboard/.env.example +++ b/dashboard/.env.example @@ -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 diff --git a/dashboard/Dockerfile b/dashboard/Dockerfile index e9932be..69921fc 100644 --- a/dashboard/Dockerfile +++ b/dashboard/Dockerfile @@ -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 diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index be7a1c3..c911509 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -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 ; + case 'error': + return ; + case 'running': + return ; + case 'waiting': + return ; + case 'canceled': + return ; + } +} + +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([]); const [executions, setExecutions] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [lastSync, setLastSync] = useState(null); const [selectedWorkflow, setSelectedWorkflow] = useState(null); useEffect(() => { @@ -55,21 +95,21 @@ export default function App() { pb.collection('workflows').getFullList({ sort: 'name' }), pb.collection('executions').getFullList({ 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('*', (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(); @@ -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(() => { + 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(); + 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 ( -
-
-

n8n Workflow Monitor

-

Read-only status feed, synced from PocketBase

+
+ {/* ── Sticky header ───────────────────────────────────────────────── */} +
+
+
+
+ +
+
+

N8N COMMAND CENTER

+

+ Read-only status feed +

+
+
+
+ + · + + {lastSync ? `last sync ${timeAgo(lastSync.toISOString())}` : 'loading…'} + + · + {workflows.length} workflows tracked +
+
-
- {error && ( - - - - {error} - - +
+ {error && } + + {/* ── Health banner ─────────────────────────────────────────────── */} + + + {/* ── Failures panel ────────────────────────────────────────────── */} + {hasFailures && ( + setSelectedWorkflow(null)} + /> )} -
- - - Active Workflows - - - -
- {stats.active} - / {stats.total} -
-
-
- - - Runs (24h) - - - -
{stats.total24h}
-
-
- - - Succeeded (24h) - - - -
{stats.success24h}
-
-
- - - Errors (24h) - - - -
{stats.errors24h}
-
-
+ {/* ── Anomalies panel ───────────────────────────────────────────── */} + {hasAnomalies && } + + {/* ── Main grid ─────────────────────────────────────────────────── */} +
+
+ +
+
+ setSelectedWorkflow(null)} + loading={loading} + /> +
- - - Workflows - - - {loading ? ( -

Loading…

- ) : ( - - - - - Name - Status - Last Run - Last Run Time - - - - {workflows.map((wf) => { - const lastRun = lastRunByWorkflow.get(wf.n8n_id); - return ( - - setSelectedWorkflow(selectedWorkflow === wf.n8n_id ? null : wf.n8n_id) - } - > - - - - {wf.name} - - - {wf.active ? 'Active' : 'Inactive'} - - - - {lastRun ? ( - {lastRun.status} - ) : ( - No runs yet - )} - - - {lastRun ? timeAgo(lastRun.started_at) : '—'} - - - ); - })} - {workflows.length === 0 && ( - - - No workflows synced yet — check that the poller workflow has run. - - - )} - -
- )} -
-
- - - - - Recent Executions - {selectedWorkflow && ( - - )} - - - - - - - Workflow - Status - Started - Error - - - - {visibleExecutions.slice(0, 50).map((ex) => { - const wf = workflows.find((w) => w.n8n_id === ex.workflow_n8n_id); - return ( - - {wf?.name ?? ex.workflow_n8n_id} - - {ex.status} - - - {timeAgo(ex.started_at)} - - - {ex.error_message || '—'} - - - ); - })} - {visibleExecutions.length === 0 && ( - - - No executions synced yet. - - - )} - -
-
-
+ {/* ── Stats strip ───────────────────────────────────────────────── */} +
+ +
+ dashboard syncs via pocketbase realtime · pb: {pb.baseURL} +
); } + +// ──────────────────────────────────────────────────────────────────────────── +// Header bits +// ──────────────────────────────────────────────────────────────────────────── + +function LiveIndicator({ live }: { live: boolean }) { + return ( + + + {live && } + + + + {live ? 'live' : 'offline'} + + + ); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Health banner +// ──────────────────────────────────────────────────────────────────────────── + +function HealthBanner({ + hasFailures, + errorCount, + failRate, + activeCount, + totalCount, +}: { + hasFailures: boolean; + errorCount: number; + failRate: number; + activeCount: number; + totalCount: number; +}) { + if (hasFailures) { + return ( + + +
+
+ +
+
+

+ {errorCount} failure{errorCount === 1 ? '' : 's'} right now +

+

+ {failRate.toFixed(1)}% fail rate in the last 24h · {activeCount} of {totalCount} workflows active +

+
+
+
+
+ ); + } + + return ( + + +
+ +
+
+

All systems healthy

+

+ 0 errors · {failRate.toFixed(1)}% fail rate (24h) · {activeCount} of {totalCount} workflows active +

+
+
+
+ ); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Failures panel +// ──────────────────────────────────────────────────────────────────────────── + +function FailuresPanel({ + failures, + workflows, + onClear, +}: { + failures: ExecutionRecord[]; + workflows: WorkflowRecord[]; + onClear: () => void; +}) { + return ( + + +
+
+ + Recent failures + {failures.length} +
+
+
+ +
    + {failures.slice(0, 8).map((ex) => { + const wf = workflows.find((w) => w.n8n_id === ex.workflow_n8n_id); + return ( +
  • +
    +
    +
    + + {wf?.name ?? ex.workflow_n8n_id} + error + · {timeAgo(ex.started_at)} +
    + {ex.error_message && ( +

    + {ex.error_message} +

    + )} +
    + + n8n + + +
    +
  • + ); + })} +
+
+
+ ); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Anomalies panel +// ──────────────────────────────────────────────────────────────────────────── + +function AnomaliesPanel({ anomalies, workflows }: { anomalies: Anomaly[]; workflows: WorkflowRecord[] }) { + return ( + + +
+ + Possibly off + {anomalies.length} +
+
+ +
    + {anomalies.map((a, i) => ( +
  • + {a.kind === 'stale' && ( +
    + + {a.workflow.name} + · + + active but no run in {a.lastRun ? timeAgo(a.lastRun) : 'recorded history'} + +
    + )} + {a.kind === 'burning' && ( +
    + + {a.workflow.name} + · + + {a.errorCount}/{a.totalCount} errors (100% fail rate) + +
    + )} + {a.kind === 'stuck' && ( +
    + + + {workflows.find((w) => w.n8n_id === a.execution.workflow_n8n_id)?.name ?? a.execution.workflow_n8n_id} + + · + + "running" for {timeAgo(a.execution.started_at)} (unusually long) + +
    + )} +
  • + ))} +
+
+
+ ); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Workflows panel +// ──────────────────────────────────────────────────────────────────────────── + +function WorkflowsPanel({ + workflows, + lastRunByWorkflow, + selected, + onSelect, + loading, +}: { + workflows: WorkflowRecord[]; + lastRunByWorkflow: Map; + selected: string | null; + onSelect: (id: string | null) => void; + loading: boolean; +}) { + return ( + + +
+ Workflows + + {workflows.length} total + +
+
+ + {loading ? ( +

Loading…

+ ) : workflows.length === 0 ? ( +

+ No workflows yet — the poller will sync them within 5 minutes. +

+ ) : ( +
    + {workflows.map((wf) => { + const lastRun = lastRunByWorkflow.get(wf.n8n_id); + const isSelected = selected === wf.n8n_id; + return ( +
  • 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' + )} + > + + {wf.name} + {lastRun ? ( + {lastRun.status} + ) : ( + never + )} + + {lastRun ? timeAgo(lastRun.started_at) : '—'} + +
  • + ); + })} +
+ )} +
+
+ ); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Activity panel +// ──────────────────────────────────────────────────────────────────────────── + +function ActivityPanel({ + executions, + workflows, + selected, + onClearFilter, + loading, +}: { + executions: ExecutionRecord[]; + workflows: WorkflowRecord[]; + selected: string | null; + onClearFilter: () => void; + loading: boolean; +}) { + return ( + + +
+
+ Activity + {selected && ( + + )} +
+ + last {Math.min(executions.length, 50)} of {executions.length} + +
+
+ + {loading ? ( +

Loading…

+ ) : executions.length === 0 ? ( +

+ No executions synced yet. They appear here within 5 minutes of the poller's first run. +

+ ) : ( +
    + {executions.slice(0, 50).map((ex) => { + const wf = workflows.find((w) => w.n8n_id === ex.workflow_n8n_id); + return ( +
  • +
    + +
    +
    + {wf?.name ?? ex.workflow_n8n_id} + {ex.status} +
    + {ex.error_message && ex.status === 'error' && ( +

    + {ex.error_message} +

    + )} +
    + + {timeAgo(ex.started_at)} + +
    +
  • + ); + })} +
+ )} +
+
+ ); +} + +// ──────────────────────────────────────────────────────────────────────────── +// 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 ( +
+ {items.map((it) => ( +
+

+ {it.label} +

+

+ {it.value} + {it.sub && {it.sub}} +

+
+ ))} +
+ ); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Error banner +// ──────────────────────────────────────────────────────────────────────────── + +function ErrorBanner({ message }: { message: string }) { + return ( + + + +

{message}

+
+
+ ); +} diff --git a/dashboard/src/components/status-dot.tsx b/dashboard/src/components/status-dot.tsx index 61c0477..f67cc2c 100644 --- a/dashboard/src/components/status-dot.tsx +++ b/dashboard/src/components/status-dot.tsx @@ -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 ( - - {active && ( - + + {active && pulsing && ( + )} diff --git a/dashboard/src/components/ui/badge.tsx b/dashboard/src/components/ui/badge.tsx index 0c20e68..268c327 100644 --- a/dashboard/src/components/ui/badge.tsx +++ b/dashboard/src/components/ui/badge.tsx @@ -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 = { - 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 ( ) { return (
); @@ -15,7 +18,12 @@ export function CardHeader({ className, ...props }: React.HTMLAttributes) { - return

; + return ( +

+ ); } export function CardContent({ className, ...props }: React.HTMLAttributes) { diff --git a/dashboard/src/components/ui/table.tsx b/dashboard/src/components/ui/table.tsx index 0f57620..42eba39 100644 --- a/dashboard/src/components/ui/table.tsx +++ b/dashboard/src/components/ui/table.tsx @@ -18,18 +18,21 @@ export function TableBody({ className, ...props }: React.HTMLAttributes) { - return ; + return ; } export function TableHead({ className, ...props }: React.ThHTMLAttributes) { return ( ); } export function TableCell({ className, ...props }: React.TdHTMLAttributes) { - return ; + return ; } diff --git a/dashboard/src/index.css b/dashboard/src/index.css index 6b638d1..3a87ac2 100644 --- a/dashboard/src/index.css +++ b/dashboard/src/index.css @@ -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; + } } diff --git a/dashboard/src/lib/pocketbase.ts b/dashboard/src/lib/pocketbase.ts index b938842..b058ce5 100644 --- a/dashboard/src/lib/pocketbase.ts +++ b/dashboard/src/lib/pocketbase.ts @@ -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; diff --git a/dashboard/src/vite-env.d.ts b/dashboard/src/vite-env.d.ts new file mode 100644 index 0000000..0adb362 --- /dev/null +++ b/dashboard/src/vite-env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + readonly VITE_POCKETBASE_URL?: string; + readonly VITE_N8N_URL?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/dashboard/tailwind.config.js b/dashboard/tailwind.config.js index 1ff96be..31982df 100644 --- a/dashboard/tailwind.config.js +++ b/dashboard/tailwind.config.js @@ -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: [], diff --git a/docker-compose.yml b/docker-compose.yml index fb6191f..1b457a4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/poller.n8n.json b/poller.n8n.json index c7fdf26..10af5a1 100644 --- a/poller.n8n.json +++ b/poller.n8n.json @@ -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 \").\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 \").\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",