Infrastructure Monitor
Cloud operations console with live CPU/memory/latency charts, service status grid, deployments, incidents, region latency and a log stream.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/examples-devops.json02
Or copy the source below (4 files). Dependencies: lucide-react.
Source
components/niceui/pages/examples/InfrastructureMonitor.tsx
import { Activity, AlertTriangle, CheckCircle2, Cloud, Cpu, Database, Gauge, GitBranch, Server, Settings, Terminal, Zap } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { cn } from '@/lib/cn';
import { AreaChart } from './shared/charts';
import { AppShell, Avatar, Badge, Card, SectionTitle, TopBar, type NavItem } from './shared/shell';
import { useInterval } from './shared/hooks';
const nav: NavItem[] = [
{ id: 'overview', label: 'Overview', icon: Gauge },
{ id: 'services', label: 'Services', icon: Server, badge: 2 },
{ id: 'deployments', label: 'Deployments', icon: GitBranch },
{ id: 'incidents', label: 'Incidents', icon: AlertTriangle },
{ id: 'logs', label: 'Logs', icon: Terminal },
{ id: 'regions', label: 'Regions', icon: Cloud },
{ id: 'settings', label: 'Settings', icon: Settings },
];
const services = [
{ name: 'API Gateway', status: 'healthy', uptime: 99.98, color: 'emerald' as const },
{ name: 'Postgres Primary', status: 'healthy', uptime: 99.99, color: 'emerald' as const },
{ name: 'Redis Cache', status: 'healthy', uptime: 99.95, color: 'emerald' as const },
{ name: 'Auth Service', status: 'degraded', uptime: 98.21, color: 'amber' as const },
{ name: 'Webhooks', status: 'healthy', uptime: 99.87, color: 'emerald' as const },
{ name: 'Queue Worker', status: 'down', uptime: 92.14, color: 'rose' as const },
];
const deployments = [
{ hash: 'a1f9c2', branch: 'main', msg: 'feat: add webhook retries', who: 'MR', color: 'azure' as const, time: '2m ago', status: 'live' },
{ hash: '8e3b41', branch: 'main', msg: 'fix: race in queue worker', who: 'LV', color: 'rose' as const, time: '24m ago', status: 'live' },
{ hash: 'c4d8a0', branch: 'release/v2', msg: 'chore: bump deps', who: 'JS', color: 'emerald' as const, time: '1h ago', status: 'live' },
{ hash: '2b7e6f', branch: 'feat/auth', msg: 'refactor: token refresh', who: 'RD', color: 'amber' as const, time: '3h ago', status: 'reverted' },
];
const incidents = [
{ title: 'Queue worker backlog', sev: 'high', region: 'us-east-1', time: '12m ago', tone: 'error' as const },
{ title: 'Elevated auth latency', sev: 'medium', region: 'eu-west-2', time: '1h ago', tone: 'warning' as const },
{ title: 'DB failover completed', sev: 'low', region: 'us-west-2', time: '4h ago', tone: 'success' as const },
];
const regions = [
{ name: 'us-east-1', latency: 42, load: 64 },
{ name: 'us-west-2', latency: 58, load: 41 },
{ name: 'eu-west-2', latency: 89, load: 72 },
{ name: 'ap-southeast-1', latency: 134, load: 38 },
];
const SEED_LOGS = [
'INFO request_id=3f2a GET /api/v1/orders 200 12ms',
'INFO worker queue=emails processed=24 lag=0',
'WARN auth service latency=420ms threshold=300ms',
'INFO POST /api/v1/checkout 201 88ms',
'ERROR queue worker timeout job=send_invoice id=8842',
];
const statusTone = { healthy: 'success', degraded: 'warning', down: 'error' } as const;
function roll(n: number) {
return Array.from({ length: n }, (_, i) => ({ label: `${i}`, value: 30 + Math.random() * 50 }));
}
export function InfrastructureMonitor() {
const [cpu, setCpu] = useState(() => roll(28));
const [mem, setMem] = useState(() => roll(28));
const [lat, setLat] = useState(() => roll(28));
const [logs, setLogs] = useState(SEED_LOGS);
const [active, setActive] = useState('overview');
const logRef = useRef<HTMLDivElement>(null);
useInterval(() => {
setCpu((d) => [...d.slice(1), { label: '', value: 30 + Math.random() * 55 }]);
setMem((d) => [...d.slice(1), { label: '', value: 45 + Math.random() * 35 }]);
setLat((d) => [...d.slice(1), { label: '', value: 20 + Math.random() * 120 }]);
}, 1500);
useInterval(() => {
const pool = ['GET /api/v1/users 200 8ms', 'POST /api/v1/webhooks 202 4ms', 'WARN redis eviction keys=128', 'INFO deploy queued commit=a1f9c2', 'ERROR 5xx upstream gateway', 'GET /healthz 200 1ms'];
const lvl = ['INFO', 'WARN', 'ERROR'][Math.floor(Math.random() * 3)];
const line = `${lvl} ${pool[Math.floor(Math.random() * pool.length)]}`;
setLogs((l) => [...l.slice(-40), line]);
}, 2200);
useEffect(() => {
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
}, [logs]);
const liveCards = [
{ label: 'CPU usage', value: cpu[cpu.length - 1]?.value ?? 0, unit: '%', data: cpu, color: '#38bdf8', icon: Cpu },
{ label: 'Memory', value: mem[mem.length - 1]?.value ?? 0, unit: '%', data: mem, color: '#34d399', icon: Database },
{ label: 'P95 latency', value: lat[lat.length - 1]?.value ?? 0, unit: 'ms', data: lat, color: '#fbbf24', icon: Zap },
];
return (
<AppShell
brand={<><span className="grid h-8 w-8 place-items-center rounded-lg bg-brand-500/15 text-brand-300"><Activity className="h-4 w-4" /></span><span className="text-sm font-semibold text-ink-50">Datadog</span></>}
nav={nav}
active={active}
onNav={setActive}
topbar={<TopBar dateLabel="Live · last 5 min" notif={5} initials="OP" color="sky" />}
>
<div className="mb-4 flex items-center gap-2">
<span className="relative flex h-2.5 w-2.5"><span className="absolute inline-flex h-full w-full animate-pulse-ring rounded-full bg-emerald-500/70" /><span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-emerald-500" /></span>
<span className="text-sm font-medium text-ink-100">All systems operational</span>
<Badge tone="warning" className="ml-1">1 degraded</Badge>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{liveCards.map((c, i) => {
const Icon = c.icon;
return (
<Card key={c.label} delay={i * 70}>
<div className="flex items-center justify-between">
<span className="flex items-center gap-2 text-xs font-medium text-ink-400"><Icon className="h-4 w-4 text-ink-500" />{c.label}</span>
<span className="text-lg font-semibold text-ink-50 tabular-nums">{Math.round(c.value)}{c.unit}</span>
</div>
<div className="mt-2 h-16"><AreaChart data={c.data} height={64} color={c.color} format={(v) => `${Math.round(v)}${c.unit}`} /></div>
</Card>
);
})}
</div>
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-3">
<Card className="xl:col-span-2" delay={240}>
<SectionTitle title="Service status" sub="6 services · 1 degraded · 1 down" />
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{services.map((s, i) => (
<div key={s.name} className="flex items-center gap-3 rounded-xl bg-ink-850 p-3 animate-fade-up" style={{ animationDelay: `${240 + i * 60}ms` }}>
<span className={cn('grid h-9 w-9 place-items-center rounded-lg bg-gradient-to-br text-white', s.status === 'healthy' ? 'from-emerald-400 to-emerald-700' : s.status === 'degraded' ? 'from-amber-400 to-orange-600' : 'from-rose-400 to-rose-700')}>
{s.status === 'healthy' ? <CheckCircle2 className="h-4 w-4" /> : <AlertTriangle className="h-4 w-4" />}
</span>
<div className="min-w-0 flex-1"><div className="truncate text-sm text-ink-100">{s.name}</div><div className="text-xs text-ink-500">{s.uptime}% uptime</div></div>
<Badge tone={statusTone[s.status as 'healthy']}>{s.status}</Badge>
</div>
))}
</div>
</Card>
<Card delay={300}>
<SectionTitle title="Region latency" />
<div className="space-y-2">
{regions.map((r, i) => (
<div key={r.name} className="flex items-center gap-3 animate-fade-up" style={{ animationDelay: `${300 + i * 60}ms` }}>
<span className="font-mono text-xs text-ink-400">{r.name}</span>
<div className="flex-1"><div className="h-1.5 w-full rounded-full bg-white/5"><div className="h-full rounded-full transition-all duration-700" style={{ width: `${(r.latency / 200) * 100}%`, background: r.latency > 100 ? '#fb7185' : r.latency > 70 ? '#fbbf24' : '#34d399' }} /></div></div>
<span className="w-12 text-right text-sm tabular-nums text-ink-100">{r.latency}ms</span>
</div>
))}
</div>
</Card>
</div>
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-2">
<Card delay={380}>
<SectionTitle title="Deployments" sub="Recent commits" />
<div className="space-y-1">
{deployments.map((d, i) => (
<div key={d.hash} className="flex items-center gap-3 rounded-lg px-2 py-2.5 transition-colors hover:bg-white/[0.02] animate-fade-up" style={{ animationDelay: `${380 + i * 60}ms` }}>
<Avatar initials={d.who} color={d.color} size="sm" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-ink-100">{d.msg}</div>
<div className="flex items-center gap-2 text-xs text-ink-500"><GitBranch className="h-3 w-3" />{d.branch}<span className="font-mono">{d.hash}</span></div>
</div>
<Badge tone={d.status === 'live' ? 'success' : 'warning'}>{d.status}</Badge>
<span className="text-xs text-ink-500">{d.time}</span>
</div>
))}
</div>
</Card>
<Card delay={440}>
<SectionTitle title="Incidents" />
<div className="space-y-2">
{incidents.map((inc, i) => (
<div key={inc.title} className="flex items-center gap-3 rounded-xl bg-ink-850 p-3 animate-fade-up" style={{ animationDelay: `${440 + i * 60}ms` }}>
<Badge tone={inc.tone}>{inc.sev}</Badge>
<div className="min-w-0 flex-1"><div className="truncate text-sm text-ink-100">{inc.title}</div><div className="text-xs text-ink-500">{inc.region} · {inc.time}</div></div>
</div>
))}
</div>
</Card>
</div>
<Card className="mt-4 p-0" delay={500}>
<div className="flex items-center gap-2 border-b border-white/5 px-4 py-2.5"><Terminal className="h-4 w-4 text-ink-500" /><span className="text-sm font-medium text-ink-200">Log stream</span><span className="ml-auto flex items-center gap-1.5 text-xs text-emerald-400"><span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />live</span></div>
<div ref={logRef} className="h-52 overflow-y-auto bg-ink-950/60 p-3 font-mono text-xs leading-relaxed">
{logs.map((l, i) => (
<div key={i} className={cn('animate-fade-in', l.startsWith('ERROR') ? 'text-rose-400' : l.startsWith('WARN') ? 'text-amber-400' : 'text-ink-300')}>
<span className="text-ink-600">{String(i + 1).padStart(3, '0')} </span>{l}
</div>
))}
</div>
</Card>
</AppShell>
);
}components/niceui/pages/examples/shared/charts.tsx
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
import { cn } from '@/lib/cn';
import { easeOutExpo, useCountUp, useSize } from './hooks';
/* ----------------------------- count-up number ---------------------------- */
export function CountUp({
value,
prefix = '',
suffix = '',
decimals = 0,
duration = 900,
className,
}: {
value: number;
prefix?: string;
suffix?: string;
decimals?: number;
duration?: number;
className?: string;
}) {
const d = useCountUp(value, duration);
return (
<span className={className}>
{prefix}
{d.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })}
{suffix}
</span>
);
}
/* -------------------------------- sparkline ------------------------------- */
export function Sparkline({
data,
color = '#38bdf8',
fill = true,
width = 120,
height = 36,
className,
}: {
data: number[];
color?: string;
fill?: boolean;
width?: number;
height?: number;
className?: string;
}) {
const raw = useId().replace(/:/g, '');
const min = Math.min(...data);
const max = Math.max(...data);
const range = max - min || 1;
const pts = data.map((v, i) => [
(i / (data.length - 1)) * width,
height - 3 - ((v - min) / range) * (height - 6),
]);
const line = pts.map((p, i) => `${i ? 'L' : 'M'}${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(' ');
const [drawn, setDrawn] = useState(false);
useEffect(() => {
setDrawn(false);
const t = requestAnimationFrame(() => requestAnimationFrame(() => setDrawn(true)));
return () => cancelAnimationFrame(t);
}, [data]);
const ref = useRef<SVGPathElement>(null);
const [len, setLen] = useState(0);
useLayoutEffect(() => {
if (ref.current) setLen(ref.current.getTotalLength());
}, [data, width]);
return (
<svg className={className} width={width} height={height} viewBox={`0 0 ${width} ${height}`} fill="none">
{fill && (
<>
<defs>
<linearGradient id={`sp-${raw}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity="0.28" />
<stop offset="100%" stopColor={color} stopOpacity="0" />
</linearGradient>
</defs>
<path d={`${line} L ${width} ${height} L 0 ${height} Z`} fill={`url(#sp-${raw})`} />
</>
)}
<path
ref={ref}
d={line}
stroke={color}
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray={len}
strokeDashoffset={drawn ? 0 : len}
style={{ transition: 'stroke-dashoffset 900ms cubic-bezier(0.16,1,0.3,1)' }}
/>
</svg>
);
}
/* -------------------------------- area chart ------------------------------ */
export function AreaChart({
data,
height = 240,
color = '#38bdf8',
format = (v) => String(v),
}: {
data: { label: string; value: number }[];
height?: number;
color?: string;
format?: (v: number) => string;
}) {
const [ref, w] = useSize<HTMLDivElement>();
const raw = useId().replace(/:/g, '');
const width = w || 640;
const pad = { l: 6, r: 6, t: 14, b: 22 };
const iw = Math.max(1, width - pad.l - pad.r);
const ih = height - pad.t - pad.b;
const vals = data.map((d) => d.value);
const min = Math.min(...vals);
const max = Math.max(...vals);
const range = max - min || 1;
const pts = data.map((d, i) => ({
x: pad.l + (i / (data.length - 1)) * iw,
y: pad.t + ih - ((d.value - min) / range) * (ih - 8) - 4,
...d,
}));
const line = pts.map((p, i) => `${i ? 'L' : 'M'}${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(' ');
const area = `${line} L ${pts[pts.length - 1].x.toFixed(1)} ${(pad.t + ih).toFixed(1)} L ${pts[0].x.toFixed(1)} ${(pad.t + ih).toFixed(1)} Z`;
const [hover, setHover] = useState<number | null>(null);
const svgRef = useRef<SVGSVGElement>(null);
const [len, setLen] = useState(0);
const [drawn, setDrawn] = useState(false);
const pathRef = useRef<SVGPathElement>(null);
useLayoutEffect(() => {
if (pathRef.current) setLen(pathRef.current.getTotalLength());
}, [data, width]);
useEffect(() => {
setDrawn(false);
const t = requestAnimationFrame(() => requestAnimationFrame(() => setDrawn(true)));
return () => cancelAnimationFrame(t);
}, [data, width]);
const gridY = [0, 0.25, 0.5, 0.75, 1];
const move = (e: React.MouseEvent) => {
const el = svgRef.current;
if (!el) return;
const x = e.clientX - el.getBoundingClientRect().left - pad.l;
setHover(Math.max(0, Math.min(data.length - 1, Math.round((x / iw) * (data.length - 1)))));
};
const hp = hover != null ? pts[hover] : null;
return (
<div ref={ref} className="relative" onMouseMove={move} onMouseLeave={() => setHover(null)}>
<svg ref={svgRef} width={width} height={height} viewBox={`0 0 ${width} ${height}`} fill="none">
<defs>
<linearGradient id={`ar-${raw}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity="0.32" />
<stop offset="100%" stopColor={color} stopOpacity="0" />
</linearGradient>
</defs>
{gridY.map((g) => (
<line key={g} x1={pad.l} x2={width - pad.r} y1={pad.t + g * ih} y2={pad.t + g * ih} stroke="rgba(255,255,255,0.05)" strokeWidth="1" />
))}
<path d={area} fill={`url(#ar-${raw})`} />
<path
ref={pathRef}
d={line}
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray={len}
strokeDashoffset={drawn ? 0 : len}
style={{ transition: 'stroke-dashoffset 1000ms cubic-bezier(0.16,1,0.3,1)' }}
/>
{hp && (
<>
<line x1={hp.x} x2={hp.x} y1={pad.t} y2={pad.t + ih} stroke="rgba(255,255,255,0.18)" strokeWidth="1" strokeDasharray="3 3" />
<circle cx={hp.x} cy={hp.y} r="4.5" fill={color} stroke="#0a0a0c" strokeWidth="2" />
</>
)}
{pts.filter((_, i) => i % Math.ceil(data.length / 6) === 0).map((p) => (
<text key={p.x} x={p.x} y={height - 6} textAnchor="middle" className="fill-ink-500" style={{ fontSize: 10 }}>
{p.label}
</text>
))}
</svg>
{hp && (
<div
className="pointer-events-none absolute z-10 -translate-x-1/2 rounded-lg bg-ink-800 px-2.5 py-1.5 text-xs shadow-pop ring-1 ring-white/10"
style={{ left: Math.max(56, Math.min(width - 56, hp.x)), top: Math.max(0, hp.y - 48) }}
>
<div className="font-medium text-ink-50">{format(hp.value)}</div>
<div className="text-[10px] text-ink-400">{hp.label}</div>
</div>
)}
</div>
);
}
/* -------------------------------- bar chart ------------------------------- */
export function BarChart({
series,
groups,
height = 220,
format = (v) => String(v),
}: {
series: { key: string; label: string; color: string }[];
groups: { label: string; values: Record<string, number> }[];
height?: number;
format?: (v: number) => string;
}) {
const max = Math.max(1, ...groups.flatMap((g) => series.map((s) => g.values[s.key] ?? 0)));
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(false);
const t = requestAnimationFrame(() => requestAnimationFrame(() => setMounted(true)));
return () => cancelAnimationFrame(t);
}, [groups]);
const [hover, setHover] = useState<number | null>(null);
const ih = height - 28;
return (
<div>
<div className="relative" style={{ height }}>
<div className="absolute inset-x-0 top-0 bottom-7 flex flex-col justify-between">
{[1, 0.66, 0.33, 0].map((g) => (
<div key={g} className="border-t border-white/5" />
))}
</div>
<div className="absolute inset-x-0 top-0 flex items-end justify-around gap-2 px-1" style={{ height: ih }} onMouseLeave={() => setHover(null)}>
{groups.map((g, gi) => (
<div key={g.label} className="group flex h-full flex-1 items-end justify-center gap-1" onMouseEnter={() => setHover(gi)}>
{series.map((s) => {
const h = ((g.values[s.key] ?? 0) / max) * ih;
return (
<div
key={s.key}
className={cn('w-full max-w-[18px] rounded-t-md transition-all duration-700 ease-smooth', hover === gi ? 'opacity-100' : 'opacity-80')}
style={{ height: mounted ? h : 0, background: `linear-gradient(to top, ${s.color}, ${s.color}cc)`, transitionDelay: `${gi * 45}ms` }}
/>
);
})}
</div>
))}
</div>
<div className="absolute inset-x-0 bottom-0 flex justify-around px-1">
{groups.map((g) => (
<span key={g.label} className="flex-1 text-center text-[10px] text-ink-500">{g.label}</span>
))}
</div>
</div>
{hover != null && (
<div className="mt-1 text-xs text-ink-400">
{groups[hover].label}: {series.map((s) => `${s.label} ${format(groups[hover].values[s.key] ?? 0)}`).join(' · ')}
</div>
)}
<div className="mt-2 flex items-center gap-4">
{series.map((s) => (
<span key={s.key} className="flex items-center gap-1.5 text-xs text-ink-400">
<span className="h-2 w-2 rounded-sm" style={{ background: s.color }} /> {s.label}
</span>
))}
</div>
</div>
);
}
/* ------------------------------- donut chart ------------------------------ */
export function DonutChart({
data,
size = 168,
thickness = 18,
center,
}: {
data: { label: string; value: number; color: string }[];
size?: number;
thickness?: number;
center?: React.ReactNode;
}) {
const total = data.reduce((s, d) => s + d.value, 0) || 1;
const r = (size - thickness) / 2;
const circ = 2 * Math.PI * r;
const [mounted, setMounted] = useState(false);
useEffect(() => {
const t = requestAnimationFrame(() => requestAnimationFrame(() => setMounted(true)));
return () => cancelAnimationFrame(t);
}, []);
let acc = 0;
return (
<div className="relative inline-flex items-center justify-center" style={{ width: size, height: size }}>
<svg width={size} height={size} className="-rotate-90">
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="rgba(255,255,255,0.05)" strokeWidth={thickness} />
{data.map((d, i) => {
const frac = d.value / total;
const seg = frac * circ;
const offset = -acc * circ;
acc += frac;
return (
<circle
key={d.label}
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke={d.color}
strokeWidth={thickness}
strokeLinecap="round"
strokeDasharray={`${mounted ? seg : 0} ${circ}`}
strokeDashoffset={offset}
style={{ transition: `stroke-dasharray 900ms cubic-bezier(0.16,1,0.3,1) ${i * 110}ms` }}
/>
);
})}
</svg>
{center && <div className="absolute inset-0 flex flex-col items-center justify-center text-center">{center}</div>}
</div>
);
}components/niceui/pages/examples/shared/hooks.ts
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
export const easeOutExpo = (t: number) => (t >= 1 ? 1 : 1 - Math.pow(2, -10 * t));
/** Observe an element's width for responsive SVG charts. */
export function useSize<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [width, setWidth] = useState(0);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const ro = new ResizeObserver((entries) => setWidth(entries[0].contentRect.width));
ro.observe(el);
setWidth(el.clientWidth);
return () => ro.disconnect();
}, []);
return [ref, width] as const;
}
/** Animate a number from its previous value to the next with rAF + easeOutExpo. */
export function useCountUp(value: number, duration = 900) {
const [display, setDisplay] = useState(0);
const fromRef = useRef(0);
const rafRef = useRef<number | undefined>(undefined);
useEffect(() => {
const from = fromRef.current;
const start = performance.now();
const tick = (now: number) => {
const t = Math.min(1, (now - start) / duration);
const next = from + (value - from) * easeOutExpo(t);
setDisplay(next);
fromRef.current = next;
if (t < 1) rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
fromRef.current = value;
};
}, [value, duration]);
return display;
}
/** setInterval with a ref'd callback; pass null to pause. */
export function useInterval(callback: () => void, ms: number | null) {
const saved = useRef(callback);
useEffect(() => {
saved.current = callback;
}, [callback]);
useEffect(() => {
if (ms === null) return;
const id = setInterval(() => saved.current(), ms);
return () => clearInterval(id);
}, [ms]);
}components/niceui/pages/examples/shared/shell.tsx
import { useEffect, useState, type ComponentType, type ReactNode } from 'react';
import { Bell, Search } from 'lucide-react';
import { cn } from '@/lib/cn';
export type NavItem = { id: string; label: string; icon: ComponentType<{ className?: string }>; badge?: number };
export const avatarColors: Record<string, string> = {
azure: 'from-brand-400 to-brand-700',
emerald: 'from-emerald-400 to-teal-600',
amber: 'from-amber-400 to-orange-600',
rose: 'from-rose-400 to-rose-700',
sky: 'from-sky-400 to-sky-700',
teal: 'from-teal-400 to-emerald-600',
};
export function Avatar({ initials, color = 'azure', size = 'md', ring }: { initials: string; color?: keyof typeof avatarColors | string; size?: 'sm' | 'md' | 'lg'; ring?: boolean }) {
const s = size === 'lg' ? 'h-11 w-11 text-base' : size === 'sm' ? 'h-7 w-7 text-[11px]' : 'h-9 w-9 text-xs';
return (
<span
className={cn('inline-flex shrink-0 items-center justify-center rounded-full bg-gradient-to-br font-semibold text-white', avatarColors[color] ?? avatarColors.azure, s, ring && 'ring-2 ring-ink-950')}
>
{initials}
</span>
);
}
export function AppShell({
brand,
nav,
active,
onNav,
topbar,
children,
tools,
}: {
brand: ReactNode;
nav: NavItem[];
active: string;
onNav: (id: string) => void;
topbar: ReactNode;
tools?: ReactNode;
children: ReactNode;
}) {
return (
<div className="flex min-h-full">
{/* sidebar */}
<aside className="sticky top-0 hidden h-screen w-60 shrink-0 flex-col border-r border-white/5 bg-ink-950/60 p-3 backdrop-blur lg:flex">
<div className="flex h-12 items-center gap-2.5 px-2">{brand}</div>
<div className="mt-2 flex items-center gap-2 rounded-xl bg-ink-900 px-3 py-2 text-ink-500 ring-1 ring-white/5">
<Search className="h-4 w-4" />
<span className="text-xs">Search…</span>
<kbd className="ml-auto rounded bg-white/10 px-1.5 py-px font-mono text-[10px] text-ink-400">⌘K</kbd>
</div>
<nav className="mt-3 flex-1 space-y-0.5 overflow-y-auto">
{nav.map((n) => {
const on = n.id === active;
const Icon = n.icon;
return (
<button
key={n.id}
onClick={() => onNav(n.id)}
className={cn(
'focus-ring group flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors',
on ? 'bg-white/[0.06] font-medium text-ink-50' : 'text-ink-400 hover:bg-white/[0.03] hover:text-ink-100',
)}
>
<Icon className={cn('h-4 w-4 shrink-0 transition-colors', on ? 'text-brand-400' : 'text-ink-500 group-hover:text-ink-300')} />
{n.label}
{n.badge ? <span className="ml-auto rounded-full bg-brand-500/15 px-1.5 text-[10px] font-semibold text-brand-300">{n.badge}</span> : null}
{on && <span className="ml-auto h-1.5 w-1.5 rounded-full bg-brand-400" />}
</button>
);
})}
</nav>
{tools}
</aside>
{/* mobile top nav scroll */}
<div className="flex min-w-0 flex-1 flex-col">
<header className="sticky top-0 z-20 flex h-14 items-center gap-3 border-b border-white/5 bg-ink-950/80 px-4 backdrop-blur-xl sm:px-6">
<div className="flex items-center gap-2 lg:hidden">{brand}</div>
<div className="relative hidden flex-1 sm:block">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-ink-500" />
<input
placeholder="Search…"
className="focus-ring h-9 w-full max-w-xs rounded-lg bg-ink-900 pl-9 pr-3 text-sm text-ink-100 placeholder:text-ink-500 ring-1 ring-white/5 transition focus:ring-brand-500/40"
/>
</div>
<div className="ml-auto flex items-center gap-2">{topbar}</div>
</header>
{/* mobile icon rail */}
<div className="flex gap-1 overflow-x-auto border-b border-white/5 bg-ink-950/60 px-3 py-2 lg:hidden">
{nav.map((n) => {
const Icon = n.icon;
const on = n.id === active;
return (
<button
key={n.id}
onClick={() => onNav(n.id)}
className={cn('flex shrink-0 items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs transition-colors', on ? 'bg-white/[0.06] text-ink-50' : 'text-ink-400')}
>
<Icon className="h-3.5 w-3.5" />
{n.label}
</button>
);
})}
</div>
<main className="min-w-0 flex-1 p-4 sm:p-6">{children}</main>
</div>
</div>
);
}
export function TopBar({ dateLabel, onDate, notif = 3, initials = 'MK', color = 'azure' }: { dateLabel: string; onDate?: () => void; notif?: number; initials?: string; color?: keyof typeof avatarColors | string }) {
return (
<>
<button onClick={onDate} className="focus-ring hidden h-9 items-center gap-2 rounded-lg bg-ink-900 px-3 text-sm text-ink-300 ring-1 ring-white/5 transition hover:bg-ink-850 hover:text-ink-50 active:scale-95 sm:flex">
<span className="text-ink-500">Range</span>
<span className="font-medium text-ink-100">{dateLabel}</span>
</button>
<button className="focus-ring relative flex h-9 w-9 items-center justify-center rounded-lg text-ink-300 ring-1 ring-white/5 transition hover:bg-ink-900 hover:text-ink-50 active:scale-90">
<Bell className="h-4 w-4" />
{notif > 0 && (
<>
<span className="absolute right-1.5 top-1.5 h-2 w-2 rounded-full bg-rose-500" />
<span className="absolute right-1.5 top-1.5 h-2 w-2 animate-pulse-ring rounded-full bg-rose-500/70" />
</>
)}
</button>
<Avatar initials={initials} color={color} />
</>
);
}
export function Delta({ value, className }: { value: number; className?: string }) {
const up = value >= 0;
return (
<span className={cn('inline-flex items-center gap-0.5 text-xs font-medium', up ? 'text-emerald-400' : 'text-rose-400', className)}>
{up ? '▲' : '▼'} {Math.abs(value).toFixed(1)}%
</span>
);
}
export function Badge({ tone = 'neutral', children, className }: { tone?: 'neutral' | 'success' | 'warning' | 'error' | 'brand' | 'sky'; children: ReactNode; className?: string }) {
const tones: Record<string, string> = {
neutral: 'bg-white/5 text-ink-300 ring-white/10',
success: 'bg-emerald-500/10 text-emerald-300 ring-emerald-500/30',
warning: 'bg-amber-500/10 text-amber-300 ring-amber-500/30',
error: 'bg-rose-500/10 text-rose-300 ring-rose-500/30',
brand: 'bg-brand-500/10 text-brand-300 ring-brand-500/30',
sky: 'bg-sky-500/10 text-sky-300 ring-sky-500/30',
};
return <span className={cn('inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ring-1', tones[tone], className)}>{children}</span>;
}
export function Card({ className, children, style, delay }: { className?: string; children: ReactNode; style?: React.CSSProperties; delay?: number }) {
return (
<div className={cn('surface animate-fade-up p-5', className)} style={{ animationDelay: delay != null ? `${delay}ms` : undefined, ...style }}>
{children}
</div>
);
}
export function ProgressBar({ value, color = '#38bdf8', className }: { value: number; color?: string; className?: string }) {
const [w, setW] = useState(false);
useEffect(() => {
const t = requestAnimationFrame(() => requestAnimationFrame(() => setW(true)));
return () => cancelAnimationFrame(t);
}, [value]);
return (
<div className={cn('h-1.5 w-full overflow-hidden rounded-full bg-white/5', className)}>
<div className="h-full rounded-full transition-[width] duration-700 ease-smooth" style={{ width: w ? `${value}%` : 0, background: color }} />
</div>
);
}
export function SectionTitle({ title, action, sub }: { title: string; sub?: string; action?: ReactNode }) {
return (
<div className="mb-3 flex items-end justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-ink-50">{title}</h3>
{sub && <p className="text-xs text-ink-500">{sub}</p>}
</div>
{action}
</div>
);
}