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:
commit
c3d42e54bb
26 changed files with 3559 additions and 0 deletions
4
.env.example
Normal file
4
.env.example
Normal 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
20
.gitignore
vendored
Normal 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
38
README.md
Normal 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
11
dashboard/.dockerignore
Normal 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
5
dashboard/.env.example
Normal 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
9
dashboard/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
*.log
|
||||
.DS_Store
|
||||
.vscode
|
||||
.idea
|
||||
33
dashboard/Dockerfile
Normal file
33
dashboard/Dockerfile
Normal 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
12
dashboard/index.html
Normal 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
48
dashboard/nginx.conf
Normal 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
2723
dashboard/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
29
dashboard/package.json
Normal file
29
dashboard/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
dashboard/postcss.config.js
Normal file
6
dashboard/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
310
dashboard/src/App.tsx
Normal file
310
dashboard/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
dashboard/src/components/status-dot.tsx
Normal file
17
dashboard/src/components/status-dot.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
dashboard/src/components/ui/badge.tsx
Normal file
34
dashboard/src/components/ui/badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
dashboard/src/components/ui/card.tsx
Normal file
23
dashboard/src/components/ui/card.tsx
Normal 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} />;
|
||||
}
|
||||
35
dashboard/src/components/ui/table.tsx
Normal file
35
dashboard/src/components/ui/table.tsx
Normal 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
7
dashboard/src/index.css
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
34
dashboard/src/lib/pocketbase.ts
Normal file
34
dashboard/src/lib/pocketbase.ts
Normal 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;
|
||||
};
|
||||
6
dashboard/src/lib/utils.ts
Normal file
6
dashboard/src/lib/utils.ts
Normal 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
10
dashboard/src/main.tsx
Normal 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>
|
||||
);
|
||||
31
dashboard/tailwind.config.js
Normal file
31
dashboard/tailwind.config.js
Normal 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
18
dashboard/tsconfig.json
Normal 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
27
dashboard/vite.config.ts
Normal 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
28
docker-compose.yml
Normal 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
41
pocketbase_schema.json
Normal 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)"
|
||||
]
|
||||
}
|
||||
]
|
||||
Loading…
Reference in a new issue