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:
David 2026-08-24 16:13:39 +02:00
parent c21db54ea2
commit bac00f617d
3 changed files with 549 additions and 238 deletions

View file

@ -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">
&quot;running&quot; 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&apos;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">
&quot;running&quot; 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&apos;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>
);
}

View file

@ -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); }
}

View file

@ -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)' },
},
},
},
},