Initial commit: n8n workflow monitor dashboard

- Vite + React + Tailwind SPA reading from PocketBase
- Realtime subscriptions on workflows + executions collections
- Coolify-ready Dockerfile (multi-stage node build → nginx serve)
- PocketBase URL configurable via VITE_POCKETBASE_URL build arg
- Schema import (workflows, executions) included
This commit is contained in:
David 2026-08-23 15:47:51 +02:00
commit c3d42e54bb
26 changed files with 3559 additions and 0 deletions

4
.env.example Normal file
View file

@ -0,0 +1,4 @@
# PocketBase URL the dashboard reads from. Injected at Docker build time.
# 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

20
.gitignore vendored Normal file
View file

@ -0,0 +1,20 @@
# Build artifacts
dashboard/dist/
dashboard/node_modules/
# Env
.env
.env.local
.env.*.local
dashboard/.env
dashboard/.env.local
dashboard/.env.*.local
# OS / editor noise
.DS_Store
*.log
.vscode/
.idea/
# Docker
dashboard/.docker-cache/

38
README.md Normal file
View file

@ -0,0 +1,38 @@
# n8n Workflow Monitor
Read-only dashboard: n8n → PocketBase → shadcn-style React dashboard.
## What's in here
- `n8n Monitoring Dashboard - Poller` — already created in your n8n instance (draft, not yet activated). Polls `/api/v1/workflows` and `/api/v1/executions` every 5 minutes and upserts into PocketBase.
- `pocketbase_schema.json` — importable PocketBase collections (`workflows`, `executions`).
- `dashboard/` — the React + Tailwind + shadcn-style dashboard, reads from PocketBase with realtime updates.
## Setup
### 1. PocketBase
1. In your PocketBase Admin UI, go to **Settings → Import collections** and upload `pocketbase_schema.json`. This creates `workflows` and `executions` with public read access and admin-only write access.
2. Create an **API token / auth for a superuser (or an app-level API key)** the poller workflow can use to write — the poller authenticates with a plain Bearer token in the `PocketBase Admin` credential (see below).
### 2. n8n poller workflow
The workflow is already created: **n8n Monitoring Dashboard - Poller** (in your personal project). Before activating it:
1. Create credential **"n8n API Key"** (type: HTTP Header Auth) — header `X-N8N-API-KEY`, value: an n8n API key from Settings → API.
2. Create credential **"PocketBase Admin"** (type: HTTP Header Auth) — header `Authorization`, value: `Bearer <your PocketBase superuser token>`.
3. Every HTTP node in the workflow uses `https://pocketbase.apphub.co.za` as a guessed placeholder base URL — find/replace with your real PocketBase URL if different.
4. Activate the workflow.
### 3. Dashboard
```bash
cd dashboard
npm install
```
Edit `src/lib/pocketbase.ts` and set `POCKETBASE_URL` to your real PocketBase instance.
```bash
npm run dev
```
Open the printed local URL. For production, `npm run build` and serve the `dist/` folder (e.g. behind the same reverse proxy/Cloudflare Tunnel setup you use for your other self-hosted tools).
## Notes
- The dashboard is **read-only** — it only ever calls PocketBase's list/realtime endpoints, never n8n directly, and never writes.
- All writes to PocketBase happen from the poller workflow only, authenticated as admin — the dashboard's PocketBase access can stay on the public read rules set in the schema.
- Realtime: the dashboard subscribes to PocketBase's realtime feed, so it updates within moments of the poller's next 5-minute sync — no page refresh needed.

11
dashboard/.dockerignore Normal file
View file

@ -0,0 +1,11 @@
node_modules
dist
.git
.env
.env.local
.env.*.local
*.log
.DS_Store
README.md
.vscode
.idea

5
dashboard/.env.example Normal file
View file

@ -0,0 +1,5 @@
# PocketBase URL the dashboard reads from.
# In dev: leave at the default (http://127.0.0.1:8090) and run PB locally.
# 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

9
dashboard/.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
node_modules
dist
.env
.env.local
.env.*.local
*.log
.DS_Store
.vscode
.idea

33
dashboard/Dockerfile Normal file
View file

@ -0,0 +1,33 @@
# syntax=docker/dockerfile:1.7
# ---------- build stage ----------
FROM node:20-alpine AS build
WORKDIR /app
# Install deps in their own layer so source changes don't bust the cache.
COPY package.json package-lock.json* ./
RUN npm ci
# PB URL is baked at build time — Vite reads it via import.meta.env.VITE_*.
# Set this as a build arg in Coolify (or docker compose).
ARG VITE_POCKETBASE_URL
ENV VITE_POCKETBASE_URL=$VITE_POCKETBASE_URL
COPY . .
RUN npm run build
# ---------- runtime stage ----------
FROM nginx:1.27-alpine
# Replace default site config with our SPA-friendly one.
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/conf.d/
# Static bundle from the build stage.
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
# Coolify/Traefik will route to this port; the page itself is served by nginx.
CMD ["nginx", "-g", "daemon off;"]

12
dashboard/index.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>n8n Workflow Monitor</title>
</head>
<body class="bg-background text-foreground">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

48
dashboard/nginx.conf Normal file
View file

@ -0,0 +1,48 @@
server {
listen 80;
listen [::]:80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# ---- security headers ----
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
# ---- SPA fallback (no-op today, but keeps future client routes working) ----
location / {
try_files $uri $uri/ /index.html;
}
# ---- Vite emits content-hashed filenames under /assets — safe to cache forever ----
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# ---- index.html must always revalidate so deploys pick up immediately ----
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# ---- gzip ----
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_comp_level 6;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/xml
application/xml+rss
application/wasm;
}

2723
dashboard/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

29
dashboard/package.json Normal file
View file

@ -0,0 +1,29 @@
{
"name": "n8n-monitor-dashboard",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"clsx": "^2.1.1",
"lucide-react": "^0.462.0",
"pocketbase": "^0.25.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwind-merge": "^2.5.4"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.15",
"typescript": "^5.6.3",
"vite": "^5.4.11"
}
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

310
dashboard/src/App.tsx Normal file
View file

@ -0,0 +1,310 @@
import { useEffect, useMemo, useState } from 'react';
import { pb, WorkflowRecord, ExecutionRecord } 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';
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;
}
}
function timeAgo(iso: string) {
if (!iso) return '—';
const diffMs = Date.now() - new Date(iso).getTime();
const mins = Math.round(diffMs / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.round(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.round(hrs / 24)}d ago`;
}
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 [selectedWorkflow, setSelectedWorkflow] = useState<string | null>(null);
useEffect(() => {
let unsubWorkflows: (() => void) | undefined;
let unsubExecutions: (() => void) | undefined;
async function load() {
try {
const [wf, ex] = await Promise.all([
pb.collection('workflows').getFullList<WorkflowRecord>({ sort: 'name' }),
pb.collection('executions').getFullList<ExecutionRecord>({
sort: '-started_at',
perPage: 100,
}),
]);
setWorkflows(wf);
setExecutions(ex);
setLoading(false);
} catch (e) {
setError('Could not reach PocketBase. Check the URL in src/lib/pocketbase.ts and that the collections exist.');
setLoading(false);
}
}
load();
// Realtime subscriptions so the dashboard updates without polling.
pb.collection('workflows')
.subscribe<WorkflowRecord>('*', (e) => {
setWorkflows((prev) => {
const others = prev.filter((w) => w.id !== e.record.id);
if (e.action === 'delete') return others;
return [...others, e.record].sort((a, b) => a.name.localeCompare(b.name));
});
})
.then((unsub) => (unsubWorkflows = unsub));
pb.collection('executions')
.subscribe<ExecutionRecord>('*', (e) => {
setExecutions((prev) => {
const others = prev.filter((x) => x.id !== e.record.id);
if (e.action === 'delete') return others;
return [e.record, ...others]
.sort((a, b) => (b.started_at || '').localeCompare(a.started_at || ''))
.slice(0, 200);
});
})
.then((unsub) => (unsubExecutions = unsub));
return () => {
unsubWorkflows?.();
unsubExecutions?.();
};
}, []);
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]);
const lastRunByWorkflow = useMemo(() => {
const map = new Map<string, ExecutionRecord>();
for (const e of executions) {
const existing = map.get(e.workflow_n8n_id);
if (!existing || e.started_at > existing.started_at) map.set(e.workflow_n8n_id, e);
}
return map;
}, [executions]);
const visibleExecutions = selectedWorkflow
? executions.filter((e) => e.workflow_n8n_id === selectedWorkflow)
: executions;
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>
</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>
)}
<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>
</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>
</main>
</div>
);
}

View file

@ -0,0 +1,17 @@
import { cn } from '@/lib/utils';
export function StatusDot({ active }: { active: 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={cn(
'relative inline-flex h-2.5 w-2.5 rounded-full',
active ? 'bg-success' : 'bg-muted-foreground/40'
)}
/>
</span>
);
}

View file

@ -0,0 +1,34 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
type BadgeVariant = 'default' | 'success' | 'error' | 'pending' | 'muted';
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',
};
export function Badge({
className,
variant = 'default',
children,
}: {
className?: string;
variant?: BadgeVariant;
children: React.ReactNode;
}) {
return (
<span
className={cn(
'inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium',
variantClasses[variant],
className
)}
>
{children}
</span>
);
}

View file

@ -0,0 +1,23 @@
import * as React from 'react';
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)}
{...props}
/>
);
}
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('flex flex-col space-y-1 p-4', className)} {...props} />;
}
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
return <h3 className={cn('text-sm font-medium text-muted-foreground', className)} {...props} />;
}
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('p-4 pt-0', className)} {...props} />;
}

View file

@ -0,0 +1,35 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export function Table({ className, ...props }: React.TableHTMLAttributes<HTMLTableElement>) {
return (
<div className="w-full overflow-auto rounded-lg border border-border">
<table className={cn('w-full caption-bottom text-sm', className)} {...props} />
</div>
);
}
export function TableHeader({ className, ...props }: React.HTMLAttributes<HTMLTableSectionElement>) {
return <thead className={cn('bg-muted/50', className)} {...props} />;
}
export function TableBody({ className, ...props }: React.HTMLAttributes<HTMLTableSectionElement>) {
return <tbody className={cn('divide-y divide-border', className)} {...props} />;
}
export function TableRow({ className, ...props }: React.HTMLAttributes<HTMLTableRowElement>) {
return <tr className={cn('hover:bg-muted/40 transition-colors', 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)}
{...props}
/>
);
}
export function TableCell({ className, ...props }: React.TdHTMLAttributes<HTMLTableCellElement>) {
return <td className={cn('p-4 align-middle', className)} {...props} />;
}

7
dashboard/src/index.css Normal file
View file

@ -0,0 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
}

View file

@ -0,0 +1,34 @@
import PocketBase from 'pocketbase';
// PocketBase URL — set via VITE_POCKETBASE_URL at build time (Coolify: env var on the service).
// In dev, falls back to localhost:8090; in prod, the URL you deploy against.
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 pb = new PocketBase(POCKETBASE_URL);
// Disable PocketBase's default auto-cancellation of pending requests on each new one.
// Realtime + getFullList() can race under StrictMode in dev; this keeps both responses alive.
pb.autoCancellation(false);
export type WorkflowRecord = {
id: string;
n8n_id: string;
name: string;
active: boolean;
n8n_updated_at: string;
created: string;
updated: string;
};
export type ExecutionRecord = {
id: string;
n8n_execution_id: string;
workflow_n8n_id: string;
status: 'success' | 'error' | 'running' | 'waiting' | 'canceled';
started_at: string;
stopped_at: string;
error_message?: string;
created: string;
updated: string;
};

View file

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

10
dashboard/src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View file

@ -0,0 +1,31 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: 'class',
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
border: 'hsl(214.3 31.8% 91.4%)',
background: 'hsl(0 0% 100%)',
foreground: 'hsl(222.2 84% 4.9%)',
muted: {
DEFAULT: 'hsl(210 40% 96.1%)',
foreground: 'hsl(215.4 16.3% 46.9%)',
},
card: {
DEFAULT: 'hsl(0 0% 100%)',
foreground: 'hsl(222.2 84% 4.9%)',
},
success: '#16a34a',
error: '#dc2626',
pending: '#ca8a04',
},
borderRadius: {
lg: '0.5rem',
md: '0.375rem',
sm: '0.25rem',
},
},
},
plugins: [],
};

18
dashboard/tsconfig.json Normal file
View file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"baseUrl": ".",
"paths": { "@/*": ["src/*"] }
},
"include": ["src"]
}

27
dashboard/vite.config.ts Normal file
View file

@ -0,0 +1,27 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Vite dev server port. User already runs other apps on 3000, so we default to 5173 (Vite's standard).
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
server: {
port: 5173,
strictPort: false,
},
preview: {
port: 5173,
},
build: {
outDir: 'dist',
sourcemap: true,
},
});

28
docker-compose.yml Normal file
View file

@ -0,0 +1,28 @@
# Coolify-ready compose for the n8n Workflow Monitor dashboard.
#
# 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
# 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
# 2. docker compose up --build
# 3. Open http://localhost:8080
services:
n8n-monitor-dashboard:
build:
context: ./dashboard
dockerfile: Dockerfile
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}
image: n8n-monitor-dashboard:latest
container_name: n8n-monitor-dashboard
restart: unless-stopped
# Map container port 80 → host 8080. Coolify will override routing via Traefik labels.
# If you're using Coolify, you can delete this ports block — Coolify sets its own.
ports:
- "8080:80"

41
pocketbase_schema.json Normal file
View file

@ -0,0 +1,41 @@
[
{
"name": "workflows",
"type": "base",
"listRule": "",
"viewRule": "",
"createRule": null,
"updateRule": null,
"deleteRule": null,
"fields": [
{ "name": "n8n_id", "type": "text", "required": true },
{ "name": "name", "type": "text", "required": true },
{ "name": "active", "type": "bool", "required": false },
{ "name": "n8n_updated_at", "type": "date", "required": false }
],
"indexes": [
"CREATE UNIQUE INDEX idx_workflows_n8n_id ON workflows (n8n_id)"
]
},
{
"name": "executions",
"type": "base",
"listRule": "",
"viewRule": "",
"createRule": null,
"updateRule": null,
"deleteRule": null,
"fields": [
{ "name": "n8n_execution_id", "type": "text", "required": true },
{ "name": "workflow_n8n_id", "type": "text", "required": true },
{ "name": "status", "type": "select", "required": false, "maxSelect": 1, "values": ["success", "error", "running", "waiting", "canceled"] },
{ "name": "started_at", "type": "date", "required": false },
{ "name": "stopped_at", "type": "date", "required": false },
{ "name": "error_message", "type": "text", "required": false }
],
"indexes": [
"CREATE UNIQUE INDEX idx_executions_n8n_execution_id ON executions (n8n_execution_id)",
"CREATE INDEX idx_executions_workflow_n8n_id ON executions (workflow_n8n_id)"
]
}
]