Modernize dashboard: glassmorphism, gradient stat cards, refined palette
Visual upgrade — keeps same data flow + layout structure, just elevates the look: Header: - Glassmorphism: sticky + bg-background/70 + backdrop-blur-xl - Brand mark: zap icon in accent-tinted gradient box with glow - Title: 'Command Center' (was 'n8n Workflow Monitor') - Animated live dot with glow-success on the indicator Stat cards: - Gradient: from-card to-transparent - Icon chip in top-right (per-stat: Power, Activity, CheckCircle, AlertCircle, Gauge) - Bigger numbers (3xl), tracking-tight - Color glow classes (glow-success, glow-error, glow-warning) when relevant - Sub-text under the number for context - Border-strong on hover Anomalies: - Severity-tinted icon chips (error/pending/warning soft backgrounds) - New 'All clear' state with success glow when zero anomalies - Each row has hover bg-muted/40 - n8n link in a pill button (border + bg-muted/50) Workflows + Activity: - Reusable Panel wrapper with icon + title + meta badge - Empty states with icon + message + hint - Status pills: rings + uppercase tracking-wider (looks more 'operator console') - Scrollable lists (max-h-96) with thin custom scrollbar - Error rows: subtle red hover bg (error-soft/40) Palette refinements: - Background: deeper (#08080a) - Border: rgba white 8% (more subtle) - Status: success/emerald, error/rose, warning/amber, pending/yellow - 'Soft' color tokens for translucent backgrounds CSS: - Subtle radial gradient bg (accent top-left, error bottom-right) - Fixed background-attachment for nice scroll feel - Custom thin scrollbar - glow-* utility classes - fadeIn keyframe Bundle: 223 KB JS / 69 KB gzipped, 16 KB CSS
This commit is contained in:
parent
c21db54ea2
commit
bac00f617d
3 changed files with 549 additions and 238 deletions
|
|
@ -1,14 +1,20 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
CircleDot,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
Gauge,
|
||||
Inbox,
|
||||
PauseCircle,
|
||||
Power,
|
||||
Radio,
|
||||
Sparkles,
|
||||
TrendingUp,
|
||||
XCircle,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { pb, N8N_URL, type WorkflowRecord, type ExecutionRecord, type ExecutionStatus } from '@/lib/pocketbase';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
|
@ -20,8 +26,10 @@ import { cn } from '@/lib/utils';
|
|||
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';
|
||||
const secs = Math.round(diffMs / 1000);
|
||||
if (secs < 5) return 'just now';
|
||||
if (secs < 60) return `${secs}s ago`;
|
||||
const mins = Math.round(secs / 60);
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.round(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
|
|
@ -31,15 +39,16 @@ function timeAgo(iso?: string) {
|
|||
function statusBadgeClass(status: ExecutionStatus) {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return 'bg-success/10 text-success';
|
||||
return 'bg-success-soft text-success ring-1 ring-inset ring-success/20';
|
||||
case 'error':
|
||||
return 'bg-error/10 text-error';
|
||||
return 'bg-error-soft text-error ring-1 ring-inset ring-error/25';
|
||||
case 'running':
|
||||
return 'bg-pending-soft text-pending ring-1 ring-inset ring-pending/25';
|
||||
case 'waiting':
|
||||
return 'bg-pending/10 text-pending';
|
||||
return 'bg-pending-soft text-pending ring-1 ring-inset ring-pending/25';
|
||||
case 'canceled':
|
||||
default:
|
||||
return 'bg-muted text-muted-foreground';
|
||||
return 'bg-muted text-muted-foreground ring-1 ring-inset ring-border';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -95,7 +104,7 @@ export default function App() {
|
|||
setExecutions(ex);
|
||||
setLastSync(new Date());
|
||||
setLoading(false);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
setError('Could not reach PocketBase. Check VITE_POCKETBASE_URL and that the collections exist.');
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -133,8 +142,6 @@ export default function App() {
|
|||
};
|
||||
}, []);
|
||||
|
||||
// ── Computed ────────────────────────────────────────────────────────────────
|
||||
|
||||
const lastRunByWorkflow = useMemo(() => {
|
||||
const map = new Map<string, ExecutionRecord>();
|
||||
for (const e of executions) {
|
||||
|
|
@ -208,247 +215,118 @@ export default function App() {
|
|||
return out;
|
||||
}, [workflows, executions, lastRunByWorkflow]);
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="min-h-screen text-foreground">
|
||||
<main className="mx-auto max-w-6xl px-8 py-8">
|
||||
{/* ── Header ──────────────────────────────────────────────────── */}
|
||||
<div className="mb-5 flex items-baseline justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">n8n Workflow Monitor</h1>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">Read-only · synced via PocketBase</p>
|
||||
{/* ── Header (sticky, glassmorphism) ──────────────────────────────── */}
|
||||
<header className="sticky top-0 z-20 border-b border-border bg-background/70 backdrop-blur-xl">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between gap-4 px-6 py-3.5 lg:px-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 rounded-lg bg-accent/30 blur-md" />
|
||||
<div className="relative flex h-9 w-9 items-center justify-center rounded-lg bg-gradient-to-br from-accent/25 to-accent/5 ring-1 ring-inset ring-accent/30">
|
||||
<Zap className="h-4 w-4 text-accent" strokeWidth={2.5} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-[15px] font-semibold leading-tight tracking-tight">Command Center</h1>
|
||||
<p className="text-[11px] leading-tight text-muted-foreground">n8n workflow monitor</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[11px] text-muted-foreground">
|
||||
<LiveIndicator live={!error} />
|
||||
<span className="font-tabular">
|
||||
{loading ? 'loading…' : `last sync ${lastSync ? timeAgo(lastSync.toISOString()) : '—'}`}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-tabular text-[11px] text-muted-foreground">
|
||||
{loading ? 'loading…' : error ? 'offline' : `last sync: ${lastSync ? timeAgo(lastSync.toISOString()) : '—'}`}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-7xl space-y-6 px-6 py-8 lg:px-8">
|
||||
{error && (
|
||||
<div className="mb-5 rounded-lg border border-error/40 bg-error/5 px-4 py-3 text-sm text-error">
|
||||
<div className="rounded-xl border border-error/30 bg-error-soft px-4 py-3 text-sm text-error">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Stats strip ────────────────────────────────────────────── */}
|
||||
<div className="mb-5 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
<StatCard
|
||||
icon={<Power className="h-3.5 w-3.5" strokeWidth={2.5} />}
|
||||
label="Active"
|
||||
value={
|
||||
<>
|
||||
{stats.active}
|
||||
<span className="text-xs font-normal text-muted-foreground"> of {stats.total}</span>
|
||||
<span className="ml-1 text-sm font-normal text-muted-foreground">/ {stats.total}</span>
|
||||
</>
|
||||
}
|
||||
valueClass="text-success"
|
||||
glowClass="glow-success"
|
||||
sub="workflows running"
|
||||
/>
|
||||
<StatCard label="Runs 24h" value={stats.total24h} />
|
||||
<StatCard
|
||||
icon={<Activity className="h-3.5 w-3.5" strokeWidth={2.5} />}
|
||||
label="Runs 24h"
|
||||
value={stats.total24h}
|
||||
sub="executions recorded"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<CheckCircle2 className="h-3.5 w-3.5" strokeWidth={2.5} />}
|
||||
label="Succeeded"
|
||||
value={
|
||||
<>
|
||||
{stats.success24h}
|
||||
<span className="text-xs font-normal text-muted-foreground"> 24h</span>
|
||||
<span className="ml-1 text-sm font-normal text-muted-foreground">/ 24h</span>
|
||||
</>
|
||||
}
|
||||
valueClass="text-success"
|
||||
glowClass="glow-success"
|
||||
sub="successful runs"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<AlertCircle className="h-3.5 w-3.5" strokeWidth={2.5} />}
|
||||
label="Errors"
|
||||
value={
|
||||
<>
|
||||
{stats.errors24h}
|
||||
<span className="text-xs font-normal text-muted-foreground"> 24h</span>
|
||||
<span className="ml-1 text-sm font-normal text-muted-foreground">/ 24h</span>
|
||||
</>
|
||||
}
|
||||
valueClass={stats.errors24h > 0 ? 'text-error' : ''}
|
||||
glowClass={stats.errors24h > 0 ? 'glow-error' : ''}
|
||||
sub="failed executions"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Gauge className="h-3.5 w-3.5" strokeWidth={2.5} />}
|
||||
label="Fail rate"
|
||||
value={
|
||||
<>
|
||||
{stats.failRate.toFixed(1)}%
|
||||
<span className="text-xs font-normal text-muted-foreground"> 24h</span>
|
||||
{stats.failRate.toFixed(1)}
|
||||
<span className="ml-0.5 text-base font-normal text-muted-foreground">%</span>
|
||||
</>
|
||||
}
|
||||
valueClass={stats.failRate > 5 ? 'text-error' : stats.failRate > 0 ? 'text-warning' : 'text-success'}
|
||||
valueClass={
|
||||
stats.failRate > 5
|
||||
? 'text-error'
|
||||
: stats.failRate > 0
|
||||
? 'text-warning'
|
||||
: 'text-success'
|
||||
}
|
||||
glowClass={stats.failRate > 5 ? 'glow-error' : stats.failRate > 0 ? 'glow-warning' : ''}
|
||||
sub="rolling 24h window"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Anomalies (Needs attention) ────────────────────────────── */}
|
||||
{anomalies.length > 0 && (
|
||||
<div className="mb-5 rounded-lg border border-border bg-card">
|
||||
<div className="border-b border-border px-4 py-3">
|
||||
<h2 className="text-sm font-semibold">⚠ Needs attention</h2>
|
||||
</div>
|
||||
<ul className="divide-y divide-border">
|
||||
{anomalies.map((a, i) => (
|
||||
<li key={i} className="flex items-center gap-2 px-4 py-2.5 text-[13px]">
|
||||
{a.kind === 'burning' && (
|
||||
<>
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-error" />
|
||||
<span className="font-medium">{a.workflow.name}</span>
|
||||
<span className="text-[11px] text-muted-foreground">·</span>
|
||||
<span className="text-xs text-error">
|
||||
last {a.errorCount} of {a.totalCount} runs failed
|
||||
</span>
|
||||
<a
|
||||
href={`${N8N_URL}/execution/${a.workflow.n8n_id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ml-auto inline-flex items-center gap-1 text-[10px] uppercase tracking-wider text-muted-foreground hover:text-accent"
|
||||
>
|
||||
n8n <ExternalLink className="h-2.5 w-2.5" />
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{a.kind === 'stuck' && (
|
||||
<>
|
||||
<Radio className="h-3.5 w-3.5 shrink-0 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-[11px] text-muted-foreground">·</span>
|
||||
<span className="text-xs text-pending">
|
||||
"running" for {timeAgo(a.execution.started_at)} (unusually long)
|
||||
</span>
|
||||
<a
|
||||
href={`${N8N_URL}/execution/${a.execution.n8n_execution_id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ml-auto inline-flex items-center gap-1 text-[10px] uppercase tracking-wider text-muted-foreground hover:text-accent"
|
||||
>
|
||||
n8n <ExternalLink className="h-2.5 w-2.5" />
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{a.kind === 'stale' && (
|
||||
<>
|
||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-warning" />
|
||||
<span className="font-medium">{a.workflow.name}</span>
|
||||
<span className="text-[11px] text-muted-foreground">·</span>
|
||||
<span className="text-xs text-warning">
|
||||
active but hasn't run in{' '}
|
||||
{a.lastRun ? timeAgo(a.lastRun) : 'recorded history'}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<AnomaliesPanel anomalies={anomalies} workflows={workflows} />
|
||||
|
||||
{/* ── 2-col: Workflows + Activity ────────────────────────────── */}
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{/* Workflows */}
|
||||
<div className="rounded-lg border border-border bg-card">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<h2 className="text-sm font-semibold">Workflows</h2>
|
||||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
{workflows.length} total
|
||||
</span>
|
||||
</div>
|
||||
<ul className="divide-y divide-border">
|
||||
{workflows.length === 0 ? (
|
||||
<li className="px-4 py-6 text-sm text-muted-foreground">
|
||||
{loading ? 'Loading…' : 'No workflows yet — the poller will sync them within 5 minutes.'}
|
||||
</li>
|
||||
) : (
|
||||
workflows.map((wf) => {
|
||||
const lastRun = lastRunByWorkflow.get(wf.n8n_id);
|
||||
return (
|
||||
<li key={wf.id} className="flex items-center gap-2.5 px-4 py-2.5">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block h-2 w-2 shrink-0 rounded-full',
|
||||
wf.active ? 'bg-success' : 'bg-muted-foreground/40'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'flex-1 truncate text-[13px] font-medium',
|
||||
!wf.active && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{wf.name}
|
||||
</span>
|
||||
{lastRun ? (
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full px-2 py-0.5 text-[11px] font-medium',
|
||||
statusBadgeClass(lastRun.status)
|
||||
)}
|
||||
>
|
||||
{lastRun.status}
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||
never
|
||||
</span>
|
||||
)}
|
||||
<span className="font-tabular w-14 text-right text-[11px] text-muted-foreground">
|
||||
{lastRun ? timeAgo(lastRun.started_at) : '—'}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Activity */}
|
||||
<div className="rounded-lg border border-border bg-card">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<h2 className="text-sm font-semibold">Activity</h2>
|
||||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
last {Math.min(executions.length, 50)} of {executions.length}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="divide-y divide-border">
|
||||
{executions.length === 0 ? (
|
||||
<li className="px-4 py-6 text-sm text-muted-foreground">
|
||||
{loading ? 'Loading…' : 'No executions yet. They appear here within 5 minutes of the poller running.'}
|
||||
</li>
|
||||
) : (
|
||||
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">
|
||||
<div className="flex items-start gap-2">
|
||||
<StatusGlyph status={ex.status} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-[13px]">{wf?.name ?? ex.workflow_n8n_id}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full px-2 py-0.5 text-[11px] font-medium',
|
||||
statusBadgeClass(ex.status)
|
||||
)}
|
||||
>
|
||||
{ex.status}
|
||||
</span>
|
||||
</div>
|
||||
{ex.status === 'error' && ex.error_message && (
|
||||
<p
|
||||
className="mt-0.5 truncate font-mono text-[11px] text-error/80"
|
||||
title={ex.error_message}
|
||||
>
|
||||
{ex.error_message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="font-tabular shrink-0 text-[11px] text-muted-foreground">
|
||||
{timeAgo(ex.started_at)}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
<WorkflowsPanel
|
||||
workflows={workflows}
|
||||
lastRunByWorkflow={lastRunByWorkflow}
|
||||
loading={loading}
|
||||
/>
|
||||
<ActivityPanel executions={executions} workflows={workflows} loading={loading} />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
|
@ -456,24 +334,421 @@ export default function App() {
|
|||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Stat card
|
||||
// Live indicator (pulsing dot)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function LiveIndicator({ live }: { live: boolean }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="relative flex h-2 w-2">
|
||||
{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-2 w-2 rounded-full',
|
||||
live ? 'bg-success glow-success' : 'bg-error'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-semibold uppercase tracking-widest',
|
||||
live ? 'text-success' : 'text-error'
|
||||
)}
|
||||
>
|
||||
{live ? 'live' : 'offline'}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Stat card (icon + label + big number + sub + color glow)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function StatCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
valueClass,
|
||||
glowClass,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
sub: string;
|
||||
valueClass?: string;
|
||||
glowClass?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card px-4 py-3.5">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
<div
|
||||
className={cn(
|
||||
'group relative overflow-hidden rounded-xl border border-border bg-gradient-to-br from-card to-transparent p-4 transition-all hover:border-border-strong',
|
||||
glowClass
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{label}
|
||||
</p>
|
||||
<span className="rounded-md bg-muted p-1 text-muted-foreground transition-colors group-hover:text-foreground">
|
||||
{icon}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
'mt-2 font-tabular text-3xl font-semibold leading-none tracking-tight',
|
||||
valueClass
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
<p className={cn('mt-1 font-tabular text-2xl font-semibold', valueClass)}>{value}</p>
|
||||
<p className="mt-1.5 text-[10px] text-muted-foreground">{sub}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Anomalies (Needs attention) — tinted by severity
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function AnomaliesPanel({ anomalies, workflows }: { anomalies: Anomaly[]; workflows: WorkflowRecord[] }) {
|
||||
if (anomalies.length === 0) {
|
||||
return (
|
||||
<Panel
|
||||
icon={<Sparkles className="h-3.5 w-3.5 text-success" />}
|
||||
title="All clear"
|
||||
meta="no anomalies"
|
||||
accent="success"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2 px-4 py-10 text-center">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-success-soft ring-1 ring-inset ring-success/20">
|
||||
<CheckCircle2 className="h-5 w-5 text-success" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-success">All systems healthy</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
No stale, burning, or stuck workflows detected.
|
||||
</p>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel
|
||||
icon={<AlertTriangle className="h-3.5 w-3.5 text-warning" />}
|
||||
title="Needs attention"
|
||||
meta={`${anomalies.length} ${anomalies.length === 1 ? 'anomaly' : 'anomalies'}`}
|
||||
accent="warning"
|
||||
>
|
||||
<ul className="divide-y divide-border">
|
||||
{anomalies.map((a, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="group flex items-center gap-3 px-4 py-3 transition-colors hover:bg-muted/40"
|
||||
>
|
||||
{a.kind === 'burning' && (
|
||||
<AnomalyRow
|
||||
icon={
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-error-soft ring-1 ring-inset ring-error/25">
|
||||
<AlertCircle className="h-3.5 w-3.5 text-error" strokeWidth={2.5} />
|
||||
</div>
|
||||
}
|
||||
name={a.workflow.name}
|
||||
detail={
|
||||
<span className="text-error">
|
||||
last {a.errorCount} of {a.totalCount} runs failed (100% fail rate)
|
||||
</span>
|
||||
}
|
||||
href={`${N8N_URL}/execution/${a.workflow.n8n_id}`}
|
||||
/>
|
||||
)}
|
||||
{a.kind === 'stuck' && (
|
||||
<AnomalyRow
|
||||
icon={
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-pending-soft ring-1 ring-inset ring-pending/25">
|
||||
<Radio className="h-3.5 w-3.5 animate-pulse text-pending" strokeWidth={2.5} />
|
||||
</div>
|
||||
}
|
||||
name={
|
||||
workflows.find((w) => w.n8n_id === a.execution.workflow_n8n_id)?.name ??
|
||||
a.execution.workflow_n8n_id
|
||||
}
|
||||
detail={
|
||||
<span className="text-pending">
|
||||
"running" for {timeAgo(a.execution.started_at)} (unusually long)
|
||||
</span>
|
||||
}
|
||||
href={`${N8N_URL}/execution/${a.execution.n8n_execution_id}`}
|
||||
/>
|
||||
)}
|
||||
{a.kind === 'stale' && (
|
||||
<AnomalyRow
|
||||
icon={
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-warning-soft ring-1 ring-inset ring-warning/25">
|
||||
<Clock className="h-3.5 w-3.5 text-warning" strokeWidth={2.5} />
|
||||
</div>
|
||||
}
|
||||
name={a.workflow.name}
|
||||
detail={
|
||||
<span className="text-warning">
|
||||
active but hasn't run in {a.lastRun ? timeAgo(a.lastRun) : 'recorded history'}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function AnomalyRow({
|
||||
icon,
|
||||
name,
|
||||
detail,
|
||||
href,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
name: string;
|
||||
detail: React.ReactNode;
|
||||
href?: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{icon}
|
||||
<span className="truncate text-[13px] font-medium">{name}</span>
|
||||
<span className="truncate text-xs">{detail}</span>
|
||||
{href && (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ml-auto inline-flex shrink-0 items-center gap-1 rounded-md border border-border bg-muted/50 px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground transition-colors hover:border-accent/40 hover:bg-accent/10 hover:text-accent"
|
||||
>
|
||||
n8n
|
||||
<ExternalLink className="h-2.5 w-2.5" />
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Workflows panel
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function WorkflowsPanel({
|
||||
workflows,
|
||||
lastRunByWorkflow,
|
||||
loading,
|
||||
}: {
|
||||
workflows: WorkflowRecord[];
|
||||
lastRunByWorkflow: Map<string, ExecutionRecord>;
|
||||
loading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Panel
|
||||
icon={<Zap className="h-3.5 w-3.5 text-accent" />}
|
||||
title="Workflows"
|
||||
meta={`${workflows.length} total`}
|
||||
>
|
||||
{workflows.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Inbox className="h-5 w-5" />}
|
||||
message={loading ? 'Loading…' : 'No workflows yet'}
|
||||
hint={loading ? undefined : 'The poller will sync them within 5 minutes.'}
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y divide-border scrollbar-thin max-h-[480px] overflow-y-auto">
|
||||
{workflows.map((wf) => {
|
||||
const lastRun = lastRunByWorkflow.get(wf.n8n_id);
|
||||
return (
|
||||
<li
|
||||
key={wf.id}
|
||||
className="group flex items-center gap-3 px-4 py-2.5 transition-colors hover:bg-muted/30"
|
||||
>
|
||||
<span className="relative flex h-2 w-2 shrink-0">
|
||||
{wf.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 w-2 rounded-full',
|
||||
wf.active ? 'bg-success' : 'bg-muted-foreground/40'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'flex-1 truncate text-[13px] font-medium transition-colors',
|
||||
!wf.active && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{wf.name}
|
||||
</span>
|
||||
{lastRun ? (
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider',
|
||||
statusBadgeClass(lastRun.status)
|
||||
)}
|
||||
>
|
||||
{lastRun.status}
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground ring-1 ring-inset ring-border">
|
||||
never
|
||||
</span>
|
||||
)}
|
||||
<span className="font-tabular w-14 text-right text-[11px] text-muted-foreground">
|
||||
{lastRun ? timeAgo(lastRun.started_at) : '—'}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Activity panel
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function ActivityPanel({
|
||||
executions,
|
||||
workflows,
|
||||
loading,
|
||||
}: {
|
||||
executions: ExecutionRecord[];
|
||||
workflows: WorkflowRecord[];
|
||||
loading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Panel
|
||||
icon={<Activity className="h-3.5 w-3.5 text-accent" />}
|
||||
title="Activity"
|
||||
meta={`last ${Math.min(executions.length, 50)} of ${executions.length}`}
|
||||
>
|
||||
{executions.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<TrendingUp className="h-5 w-5" />}
|
||||
message={loading ? 'Loading…' : 'No executions yet'}
|
||||
hint={loading ? undefined : 'They appear here within 5 minutes of the poller running.'}
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y divide-border scrollbar-thin max-h-[480px] overflow-y-auto">
|
||||
{executions.slice(0, 50).map((ex) => {
|
||||
const wf = workflows.find((w) => w.n8n_id === ex.workflow_n8n_id);
|
||||
return (
|
||||
<li
|
||||
key={ex.id}
|
||||
className={cn(
|
||||
'group flex items-start gap-3 px-4 py-2.5 transition-colors',
|
||||
ex.status === 'error' ? 'hover:bg-error-soft/40' : 'hover:bg-muted/30'
|
||||
)}
|
||||
>
|
||||
<StatusGlyph status={ex.status} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-[13px]">{wf?.name ?? ex.workflow_n8n_id}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider',
|
||||
statusBadgeClass(ex.status)
|
||||
)}
|
||||
>
|
||||
{ex.status}
|
||||
</span>
|
||||
</div>
|
||||
{ex.status === 'error' && ex.error_message && (
|
||||
<p
|
||||
className="mt-0.5 truncate font-mono text-[11px] text-error/80"
|
||||
title={ex.error_message}
|
||||
>
|
||||
{ex.error_message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="font-tabular shrink-0 text-[11px] text-muted-foreground">
|
||||
{timeAgo(ex.started_at)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Reusable panel wrapper
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function Panel({
|
||||
icon,
|
||||
title,
|
||||
meta,
|
||||
accent,
|
||||
children,
|
||||
}: {
|
||||
icon?: React.ReactNode;
|
||||
title: string;
|
||||
meta?: string;
|
||||
accent?: 'success' | 'warning' | 'error';
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="overflow-hidden rounded-xl border border-border bg-card/60 backdrop-blur-sm">
|
||||
<header className="flex items-center justify-between border-b border-border/60 px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon}
|
||||
<h2 className="text-[13px] font-semibold tracking-tight">{title}</h2>
|
||||
</div>
|
||||
{meta && (
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-muted-foreground',
|
||||
accent === 'success' && 'bg-success-soft text-success',
|
||||
accent === 'warning' && 'bg-warning-soft text-warning',
|
||||
accent === 'error' && 'bg-error-soft text-error'
|
||||
)}
|
||||
>
|
||||
{meta}
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Empty state
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function EmptyState({
|
||||
icon,
|
||||
message,
|
||||
hint,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
message: string;
|
||||
hint?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 px-4 py-10 text-center">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
{icon}
|
||||
</div>
|
||||
<p className="text-sm font-medium">{message}</p>
|
||||
{hint && <p className="text-[11px] text-muted-foreground">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,11 +8,14 @@
|
|||
color: theme('colors.foreground');
|
||||
font-family: theme('fontFamily.sans');
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
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%);
|
||||
/* Subtle radial gradient — adds depth without being noisy */
|
||||
background-image:
|
||||
radial-gradient(ellipse 80% 50% at 20% 0%, rgba(96, 165, 250, 0.06), transparent 50%),
|
||||
radial-gradient(ellipse 60% 40% at 80% 100%, rgba(239, 68, 68, 0.04), transparent 50%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
::selection {
|
||||
background-color: rgba(96, 165, 250, 0.3);
|
||||
|
|
@ -23,7 +26,26 @@
|
|||
.font-tabular {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.text-shadow-glow {
|
||||
text-shadow: 0 0 12px currentColor;
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
/* Custom scrollbar — subtle, dark-friendly */
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
/* Subtle glow for status elements */
|
||||
.glow-success { box-shadow: 0 0 24px -8px rgba(34, 197, 94, 0.4); }
|
||||
.glow-error { box-shadow: 0 0 24px -8px rgba(239, 68, 68, 0.5); }
|
||||
.glow-warning { box-shadow: 0 0 24px -8px rgba(245, 158, 11, 0.4); }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,47 +5,61 @@ export default {
|
|||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Surfaces (dark by default — command center look)
|
||||
border: '#27272a', // zinc-800
|
||||
background: '#09090b', // zinc-950
|
||||
foreground: '#fafafa', // zinc-50
|
||||
// Surfaces — refined for a premium dark look
|
||||
border: 'rgba(255, 255, 255, 0.08)',
|
||||
'border-strong': 'rgba(255, 255, 255, 0.14)',
|
||||
background: '#08080a',
|
||||
foreground: '#fafafa',
|
||||
muted: {
|
||||
DEFAULT: '#18181b', // zinc-900 (panels)
|
||||
foreground: '#a1a1aa', // zinc-400 (secondary text)
|
||||
DEFAULT: 'rgba(255, 255, 255, 0.04)',
|
||||
foreground: '#a1a1aa',
|
||||
},
|
||||
card: {
|
||||
DEFAULT: '#0f0f12', // slight lift from background
|
||||
DEFAULT: 'rgba(255, 255, 255, 0.025)',
|
||||
foreground: '#fafafa',
|
||||
},
|
||||
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
|
||||
// Status — slightly desaturated for refinement
|
||||
success: '#22c55e',
|
||||
'success-soft': 'rgba(34, 197, 94, 0.12)',
|
||||
error: '#f43f5e',
|
||||
'error-soft': 'rgba(244, 63, 94, 0.12)',
|
||||
warning: '#f59e0b',
|
||||
'warning-soft': 'rgba(245, 158, 11, 0.12)',
|
||||
pending: '#eab308',
|
||||
'pending-soft': 'rgba(234, 179, 8, 0.12)',
|
||||
info: '#60a5fa',
|
||||
accent: '#60a5fa',
|
||||
},
|
||||
borderRadius: {
|
||||
lg: '0.5rem',
|
||||
md: '0.375rem',
|
||||
sm: '0.25rem',
|
||||
xl: '0.75rem',
|
||||
'2xl': '1rem',
|
||||
},
|
||||
fontFamily: {
|
||||
mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'monospace'],
|
||||
sans: ['ui-sans-serif', 'system-ui', '-apple-system', 'sans-serif'],
|
||||
sans: [
|
||||
'ui-sans-serif',
|
||||
'system-ui',
|
||||
'-apple-system',
|
||||
'BlinkMacSystemFont',
|
||||
'"Segoe UI"',
|
||||
'Roboto',
|
||||
'"Helvetica Neue"',
|
||||
'Arial',
|
||||
'sans-serif',
|
||||
],
|
||||
},
|
||||
backdropBlur: {
|
||||
xs: '2px',
|
||||
},
|
||||
animation: {
|
||||
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
'fade-in': 'fadeIn 0.3s ease-out',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0', transform: 'translateY(4px)' },
|
||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue