mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
feat(ui): full frontend redesign to the Sentry-style design system
Replace the legacy UI with the dark, mono-accent design language from design/23-sentry.html across every non-canvas surface. - PlatformShell: fixed 252px sidebar with grouped nav (Platform / Operate / Workspace), brand mark, user card, and a topbar with search (Cmd+K palette), env badge, upgrade, notifications, and theme toggle; mobile collapses to a top bar with horizontal nav - New shared component kit in shared/components/sentry.tsx: SPageHead, SStat, SCard, STable, SSection, SPill, Ticks, Seg, GhostBtn/QuietBtn/IconBtn, status tone helpers - New DashboardPage at / — stats row, per-node CPU chart with 1h/24h/7d ranges, resource usage tick strips, anomaly box, node summary table, recent deployments and project cards; real API data with demo-mode fixtures - Landing page moved to /landing and rebuilt: live host telemetry window, install strip, feature grid, template catalog, docs - Every page (Projects, Templates, Builds, Databases, HA, Operations, Activity, Security, Usage, Docs, People, Settings, Audit Logs, Webhooks, Admin, NodeDetail, ServiceDetail, auth, setup, not-found) migrated to the new kit and tokens - Nav active state resolves by longest matching href - Canvas internals intentionally untouched — separate pass Verified: tsc --noEmit, eslint, vite build, Playwright walkthrough of every route at desktop and mobile widths (demo + live session). Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
a05d225016
commit
24b9b1267e
42 files changed
+4077
-3018
No files matched your search
@@ -77,12 +77,12 @@ export function CardHeader({ title, description, icon, action, className = '', .
|
||||
<div className={`flex items-start justify-between gap-4 ${className}`} {...props}>
|
||||
<div className="flex items-start gap-3">
|
||||
{icon && (
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 ring-1 ring-white/5" style={{ background: 'rgba(255,255,255,0.07)' }}>
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 ring-1 ring-[var(--tint-05)]" style={{ background: 'var(--tint-07)' }}>
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] tracking-tight">{title}</h3>
|
||||
<h3 className="font-headline text-base font-semibold text-[var(--text-primary)] tracking-tight">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-0.5">{description}</p>
|
||||
)}
|
||||
|
||||
@@ -68,7 +68,7 @@ function gradientId(prefix: string, color: string, index = 0) {
|
||||
|
||||
export function LineChart({
|
||||
data,
|
||||
color = '#b4e34a',
|
||||
color = '#38bdf8',
|
||||
fillColor = 'transparent',
|
||||
height = 76,
|
||||
showDots = true,
|
||||
@@ -181,7 +181,7 @@ export function MultiLineChart({
|
||||
|
||||
export function LineMetricChart({
|
||||
data,
|
||||
color = '#b4e34a',
|
||||
color = '#38bdf8',
|
||||
fillOpacity = 0.15,
|
||||
height = 76,
|
||||
showArea = false,
|
||||
@@ -204,8 +204,8 @@ export function DualLineChart({
|
||||
data1,
|
||||
data2,
|
||||
height = 58,
|
||||
color1 = '#7ab8ff',
|
||||
color2 = '#f2c94c',
|
||||
color1 = 'var(--accent-primary)',
|
||||
color2 = 'var(--warning)',
|
||||
fillOpacity1 = 0.15,
|
||||
fillOpacity2 = 0.15,
|
||||
}: DualLineChartProps) {
|
||||
|
||||
@@ -134,6 +134,17 @@ function CommandPaletteContent({
|
||||
onClose();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'goto-dashboard',
|
||||
label: 'Go to Dashboard',
|
||||
icon: LayoutGrid,
|
||||
shortcut: 'H',
|
||||
category: 'Navigate',
|
||||
action: () => {
|
||||
onNavigate?.('dashboard');
|
||||
onClose();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'goto-canvas',
|
||||
label: 'Go to Canvas',
|
||||
@@ -205,7 +216,7 @@ function CommandPaletteContent({
|
||||
/>
|
||||
<div className="absolute left-1/2 top-[20%] -translate-x-1/2 w-full max-w-xl">
|
||||
<Command
|
||||
className="panel overflow-hidden"
|
||||
className="s-card !p-0 overflow-hidden shadow-2xl shadow-black/50"
|
||||
loop
|
||||
>
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-[var(--border-subtle)]">
|
||||
@@ -228,7 +239,7 @@ function CommandPaletteContent({
|
||||
|
||||
{Object.entries(groupedCommands).map(([category, items]) => (
|
||||
<Command.Group key={category} heading={category} className="mb-2">
|
||||
<div className="px-2 py-1.5 text-[10px] font-semibold uppercase tracking-widest text-[var(--text-muted)]">
|
||||
<div className="px-2 py-1.5 v-mono text-[10px] uppercase tracking-[0.14em] text-[var(--text-muted)]">
|
||||
{category}
|
||||
</div>
|
||||
{items.map((item) => {
|
||||
@@ -238,9 +249,9 @@ function CommandPaletteContent({
|
||||
key={item.id}
|
||||
value={item.label}
|
||||
onSelect={item.action}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-[var(--radius-md)] cursor-pointer text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] aria-selected:bg-[var(--accent-primary-soft)] aria-selected:text-[var(--accent-primary)] transition-colors"
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-[var(--radius-md)] cursor-pointer text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] aria-selected:bg-[var(--accent-primary-soft)] aria-selected:text-[var(--accent-primary)] transition-colors"
|
||||
>
|
||||
<Icon size={18} className="text-[var(--text-muted)]" />
|
||||
<span className="s-ibox !h-7 !w-7"><Icon /></span>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium">{item.label}</div>
|
||||
{item.description && (
|
||||
|
||||
@@ -8,15 +8,15 @@ import { Sparkles } from 'lucide-react';
|
||||
export function DemoRestricted({ feature }: { feature: string }) {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center p-8">
|
||||
<div className="panel max-w-md p-8 text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-[var(--accent-primary-soft)]">
|
||||
<Sparkles size={22} className="text-[var(--accent-primary)]" />
|
||||
<div className="s-card max-w-md text-center">
|
||||
<div className="s-ibox mx-auto mb-4 !h-10 !w-10">
|
||||
<Sparkles size={18} />
|
||||
</div>
|
||||
<p className="text-lg font-semibold text-[var(--text-primary)]">Not part of the demo</p>
|
||||
<p className="font-headline text-[17px] font-semibold text-[var(--text-primary)]">Not part of the demo</p>
|
||||
<p className="mt-2 text-sm text-[var(--text-secondary)]">
|
||||
{feature} needs a live Containr session. The demo covers sample projects, services, and platform views only.
|
||||
</p>
|
||||
<Link to="/projects?demo=1" className="v-btn mt-5 inline-flex">
|
||||
<Link to="/projects?demo=1" className="s-btn-accent mt-5 inline-flex">
|
||||
Back to demo projects
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -17,8 +17,8 @@ export function DonutChart({
|
||||
percentage,
|
||||
size = 160,
|
||||
thickness = 16,
|
||||
color = '#b4e34a',
|
||||
trackColor = 'rgba(255, 255, 255, 0.07)',
|
||||
color = '#38bdf8',
|
||||
trackColor = 'var(--tint-07)',
|
||||
segments = 28,
|
||||
gap = 0.048,
|
||||
animated = true,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type ReactNode } from 'react';
|
||||
import { ChevronRight, MoreHorizontal } from 'lucide-react';
|
||||
|
||||
interface EnhancedMetricCardProps {
|
||||
title: string;
|
||||
@@ -15,6 +16,42 @@ interface EnhancedMetricCardProps {
|
||||
horizontal?: boolean;
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
good: 'var(--success)',
|
||||
average: 'var(--warning)',
|
||||
warning: 'var(--warning)',
|
||||
};
|
||||
|
||||
function CardHead({ icon, title }: { icon: ReactNode; title: string }) {
|
||||
return (
|
||||
<div className="s-cardhead">
|
||||
<span className="s-ibox">{icon}</span>
|
||||
<span className="text-[11.5px] font-medium text-[var(--text-secondary)] whitespace-nowrap">{title}</span>
|
||||
<span className="ml-auto text-[var(--text-tertiary)]">
|
||||
<MoreHorizontal size={14} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardFoot({ label, onClick }: { label: string; onClick?: () => void }) {
|
||||
return (
|
||||
<div className="s-stat-foot mt-auto">
|
||||
<span
|
||||
onClick={onClick}
|
||||
className="cursor-pointer hover:text-[var(--text-secondary)] transition-colors"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{onClick && (
|
||||
<button className="s-icon-btn !w-[26px] !h-[26px]" onClick={onClick} title={label}>
|
||||
<ChevronRight size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EnhancedMetricCard({
|
||||
title,
|
||||
value,
|
||||
@@ -29,75 +66,23 @@ export function EnhancedMetricCard({
|
||||
animationDelay = 0,
|
||||
horizontal = false,
|
||||
}: EnhancedMetricCardProps) {
|
||||
const statusColors: Record<string, string> = {
|
||||
good: '#5ee6a0',
|
||||
average: '#f2c94c',
|
||||
warning: '#f2c94c',
|
||||
};
|
||||
|
||||
// Horizontal layout for cards like Active User
|
||||
if (horizontal) {
|
||||
return (
|
||||
<div
|
||||
className={`card ${className}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
padding: 0,
|
||||
overflow: 'hidden',
|
||||
animationDelay: `${animationDelay}s`,
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
padding: '20px 18px 18px 20px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
||||
<div className="card-icon">{icon}</div>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>{title}</span>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 36,
|
||||
fontWeight: 900,
|
||||
letterSpacing: '-1.5px',
|
||||
lineHeight: 1,
|
||||
color: '#e8e9f0',
|
||||
}}>
|
||||
{value}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}>{subtitle}</div>
|
||||
)}
|
||||
{details && <div style={{ marginTop: 12 }}>{details}</div>}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 'auto',
|
||||
paddingTop: 14,
|
||||
}}>
|
||||
<span
|
||||
style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}
|
||||
onClick={onClick}
|
||||
>
|
||||
Details
|
||||
</span>
|
||||
<div className="arrow-btn" onClick={onClick}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
<div className={`s-card flex flex-row overflow-hidden ${className}`} style={{ animationDelay: `${animationDelay}s` }}>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<CardHead icon={icon} title={title} />
|
||||
<div className="px-4">
|
||||
<div className="font-headline text-[32px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
|
||||
{value}
|
||||
</div>
|
||||
{subtitle && <div className="mt-1 text-[11.5px] text-[var(--text-tertiary)]">{subtitle}</div>}
|
||||
{details && <div className="mt-3">{details}</div>}
|
||||
</div>
|
||||
<div className="mt-4" />
|
||||
<CardFoot label="Details" onClick={onClick} />
|
||||
</div>
|
||||
{chart && (
|
||||
<div style={{
|
||||
width: '50%',
|
||||
padding: '16px 14px 46px 0',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
}}>
|
||||
<div className="flex w-1/2 items-end py-3 pr-3.5">
|
||||
{chart}
|
||||
</div>
|
||||
)}
|
||||
@@ -105,64 +90,25 @@ export function EnhancedMetricCard({
|
||||
);
|
||||
}
|
||||
|
||||
// Standard vertical card layout - matching self.html exactly
|
||||
return (
|
||||
<div
|
||||
className={`card ${className}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: 20,
|
||||
animationDelay: `${animationDelay}s`,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
||||
<div className="card-icon">{icon}</div>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>{title}</span>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
fontSize: 38,
|
||||
fontWeight: 900,
|
||||
letterSpacing: '-1.5px',
|
||||
lineHeight: 1,
|
||||
color: '#e8e9f0',
|
||||
}}>
|
||||
{value}
|
||||
</div>
|
||||
|
||||
{(subtitle || statusText) && (
|
||||
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}>
|
||||
{statusText && status && (
|
||||
<span style={{ color: statusColors[status], fontWeight: 700 }}>{statusText}</span>
|
||||
)}{' '}
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{chart && <div style={{ marginTop: 12, marginBottom: 6 }}>{chart}</div>}
|
||||
|
||||
{details && <div style={{ marginTop: 16 }}>{details}</div>}
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingTop: 4,
|
||||
marginTop: 'auto'
|
||||
}}>
|
||||
<span
|
||||
onClick={onClick}
|
||||
style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}
|
||||
>
|
||||
Details
|
||||
</span>
|
||||
<div className="arrow-btn" onClick={onClick}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
<div className={`s-card flex flex-col ${className}`} style={{ animationDelay: `${animationDelay}s` }}>
|
||||
<CardHead icon={icon} title={title} />
|
||||
<div className="px-4 pb-1">
|
||||
<div className="font-headline text-[32px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
|
||||
{value}
|
||||
</div>
|
||||
{(subtitle || statusText) && (
|
||||
<div className="mt-1.5 text-[11.5px] text-[var(--text-tertiary)]">
|
||||
{statusText && status && (
|
||||
<span className="font-semibold" style={{ color: statusColors[status] }}>{statusText}</span>
|
||||
)}{' '}
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
{chart && <div className="mt-3 mb-1">{chart}</div>}
|
||||
{details && <div className="mt-3">{details}</div>}
|
||||
</div>
|
||||
<CardFoot label="Details" onClick={onClick} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -186,118 +132,56 @@ export function CacheMetricCard({
|
||||
const nonCachePercent = Math.round((nonCacheMB / totalMB) * 100);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: 20,
|
||||
animationDelay: `${animationDelay}s`,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
||||
<div className="card-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#9295a4" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
<div className="s-card flex flex-col" style={{ animationDelay: `${animationDelay}s` }}>
|
||||
<CardHead
|
||||
icon={
|
||||
<svg viewBox="0 0 24 24" width="13.5" height="13.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
}
|
||||
title="Cache"
|
||||
/>
|
||||
<div className="px-4">
|
||||
<div className="font-headline text-[32px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
|
||||
{totalMB} <span className="text-[12px] font-medium text-[var(--text-tertiary)]">MB</span>
|
||||
</div>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>Cache</span>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
fontSize: 38,
|
||||
fontWeight: 900,
|
||||
letterSpacing: '-1.5px',
|
||||
lineHeight: 1,
|
||||
color: '#e8e9f0',
|
||||
}}>
|
||||
{totalMB} MB
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}>
|
||||
<span style={{ color: '#f2c94c', fontWeight: 700 }}>{Math.round(totalMB * 0.625)}MB Average</span> cached images and files
|
||||
</div>
|
||||
|
||||
{/* Segmented Bar */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, margin: '16px 0 15px', height: 32 }}>
|
||||
<div
|
||||
className="cache-seg"
|
||||
style={{
|
||||
width: `${cachePercent}%`,
|
||||
background: '#ff6b5b',
|
||||
borderRadius: '10px 4px 4px 10px'
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="cache-seg"
|
||||
style={{
|
||||
width: `${nonCachePercent}%`,
|
||||
background: '#8c6ef0',
|
||||
borderRadius: '5px'
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="cache-seg"
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'rgba(255,255,255,0.07)',
|
||||
borderRadius: '4px 10px 10px 4px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stats Row */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr auto 1fr auto 1fr',
|
||||
gap: 0,
|
||||
alignItems: 'stretch'
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 11, color: '#6b6e7d', marginBottom: 5 }}>
|
||||
<div className="stat-dot" style={{ background: '#ff6b5b' }} /> Cache
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4 }}>
|
||||
<span style={{ fontSize: 15, fontWeight: 800 }}>{cacheMB} MB</span>
|
||||
<span style={{ fontSize: 11, color: '#6b6e7d' }}>{cachePercent}%</span>
|
||||
</div>
|
||||
<div className="mt-1.5 text-[11.5px] text-[var(--text-tertiary)]">
|
||||
<span className="font-semibold text-[var(--warning)]">{Math.round(totalMB * 0.625)}MB average</span> cached images and files
|
||||
</div>
|
||||
<div style={{ width: 1, background: 'rgba(255,255,255,0.08)', margin: '0 16px' }} />
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 11, color: '#6b6e7d', marginBottom: 5 }}>
|
||||
<div className="stat-dot" style={{ background: '#8c6ef0' }} /> Non-Cache
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4 }}>
|
||||
<span style={{ fontSize: 15, fontWeight: 800 }}>{nonCacheMB} MB</span>
|
||||
<span style={{ fontSize: 11, color: '#6b6e7d' }}>{nonCachePercent}%</span>
|
||||
</div>
|
||||
|
||||
{/* Segmented bar */}
|
||||
<div className="my-4 flex h-8 items-center gap-1">
|
||||
<div className="h-full rounded-l-md" style={{ width: `${cachePercent}%`, background: 'var(--error)' }} />
|
||||
<div className="h-full" style={{ width: `${nonCachePercent}%`, background: 'var(--accent-tertiary)' }} />
|
||||
<div className="h-full flex-1 rounded-r-md bg-[var(--tint-07)]" />
|
||||
</div>
|
||||
<div style={{ width: 1, background: 'rgba(255,255,255,0.08)', margin: '0 16px' }} />
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: '#6b6e7d', marginBottom: 5 }}>Total</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 800 }}>{totalMB * 5} GB</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingTop: 16
|
||||
}}>
|
||||
<span
|
||||
onClick={onClick}
|
||||
style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}
|
||||
>
|
||||
Details
|
||||
</span>
|
||||
<div className="arrow-btn" onClick={onClick}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-[1fr_auto_1fr_auto_1fr] items-stretch">
|
||||
{[
|
||||
{ dot: 'var(--error)', label: 'Cache', value: `${cacheMB} MB`, pct: `${cachePercent}%` },
|
||||
{ dot: 'var(--accent-tertiary)', label: 'Non-Cache', value: `${nonCacheMB} MB`, pct: `${nonCachePercent}%` },
|
||||
{ dot: null, label: 'Total', value: `${totalMB * 5} GB`, pct: '' },
|
||||
].map((s, i) => (
|
||||
<div key={s.label} className="contents">
|
||||
{i > 0 && <div className="mx-4 w-px bg-[var(--border-subtle)]" />}
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-1.5 text-[10.5px] text-[var(--text-tertiary)]">
|
||||
{s.dot && <i className="h-1.5 w-1.5 rounded-full" style={{ background: s.dot }} />}
|
||||
{s.label}
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-[14px] font-bold text-[var(--text-primary)]">{s.value}</span>
|
||||
{s.pct && <span className="text-[10.5px] text-[var(--text-tertiary)]">{s.pct}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4" />
|
||||
<CardFoot label="Details" onClick={onClick} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -319,86 +203,47 @@ export function PerformanceMetricCard({
|
||||
animationDelay = 0,
|
||||
}: PerformanceMetricCardProps) {
|
||||
const status = percentage >= 85 ? 'Good' : percentage >= 70 ? 'Average' : 'Warning';
|
||||
const statusColor = percentage >= 85 ? '#5ee6a0' : percentage >= 70 ? '#f2c94c' : '#ff6f5c';
|
||||
const statusColor = percentage >= 85 ? 'var(--success)' : percentage >= 70 ? 'var(--warning)' : 'var(--error)';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: 20,
|
||||
animationDelay: `${animationDelay}s`,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
||||
<div className="card-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#9295a4" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="2" y1="12" x2="22" y2="12"/>
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
||||
<div className="s-card flex flex-col" style={{ animationDelay: `${animationDelay}s` }}>
|
||||
<CardHead
|
||||
icon={
|
||||
<svg viewBox="0 0 24 24" width="13.5" height="13.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="2" y1="12" x2="22" y2="12" />
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>Performance</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 16, flex: 1 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{
|
||||
fontSize: 36,
|
||||
fontWeight: 900,
|
||||
letterSpacing: '-1.5px',
|
||||
lineHeight: 1,
|
||||
color: '#e8e9f0',
|
||||
}}>
|
||||
}
|
||||
title="Performance"
|
||||
/>
|
||||
<div className="flex flex-1 items-start gap-4 px-4">
|
||||
<div className="flex-1">
|
||||
<div className="font-headline text-[32px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
|
||||
{percentage}%
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}>
|
||||
<span style={{ color: statusColor, fontWeight: 700 }}>{status}</span> Last scan on {new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
<div className="mt-1.5 text-[11.5px] text-[var(--text-tertiary)]">
|
||||
<span className="font-semibold" style={{ color: statusColor }}>{status}</span>
|
||||
{' '}Last scan {new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 8 }}>
|
||||
{/* Chart placeholder - will be replaced with actual chart */}
|
||||
<div style={{ width: 134, height: 58, background: 'rgba(255,255,255,0.03)', borderRadius: 8 }} />
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'flex-end' }}>
|
||||
<div className="speed-row" style={{ color: '#7ab8ff' }}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
|
||||
<line x1="12" y1="19" x2="12" y2="5"/>
|
||||
<polyline points="5 12 12 19 19 12"/>
|
||||
</svg>
|
||||
<span>{upSpeed}</span> Mbps
|
||||
</div>
|
||||
<div className="speed-row" style={{ color: 'var(--accent-primary)' }}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<polyline points="19 12 12 5 5 12"/>
|
||||
</svg>
|
||||
<span>{downSpeed}</span> Mbps
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<div className="h-[58px] w-[134px] rounded-lg bg-[var(--tint-03)]" />
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
{[{ v: upSpeed, up: false }, { v: downSpeed, up: true }].map((s, i) => (
|
||||
<div key={i} className="flex items-center gap-1 text-[11px] font-medium text-[var(--accent-primary)]">
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
|
||||
<line x1="12" y1={s.up ? 5 : 19} x2="12" y2={s.up ? 19 : 5} />
|
||||
{s.up ? <polyline points="19 12 12 5 5 12" /> : <polyline points="5 12 12 19 19 12" />}
|
||||
</svg>
|
||||
<span>{s.v}</span> Mbps
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingTop: 14
|
||||
}}>
|
||||
<span
|
||||
onClick={onClick}
|
||||
style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}
|
||||
>
|
||||
Check Speed
|
||||
</span>
|
||||
<div className="arrow-btn" onClick={onClick}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4" />
|
||||
<CardFoot label="Check Speed" onClick={onClick} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -61,9 +61,9 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--bg-void)] flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full bg-[var(--bg-primary)] border border-[var(--border-subtle)] rounded-lg p-6 space-y-4">
|
||||
<div className="s-card max-w-md w-full space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
|
||||
<h2 className="font-headline text-xl font-semibold text-[var(--text-primary)]">
|
||||
Something went wrong
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
@@ -72,24 +72,18 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
</div>
|
||||
|
||||
{import.meta.env.DEV && this.state.error && (
|
||||
<div className="bg-[var(--surface-muted)] border border-[var(--border-subtle)] rounded p-3">
|
||||
<p className="text-xs font-mono text-[var(--error)] break-all">
|
||||
<div className="s-inset">
|
||||
<p className="v-mono text-[11px] text-[var(--error)] break-all">
|
||||
{this.state.error.toString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={this.handleReset}
|
||||
className="flex-1 px-4 py-2 bg-[var(--accent-primary-soft)] text-[var(--text-primary)] rounded hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<button onClick={this.handleReset} className="s-btn-accent flex-1 justify-center">
|
||||
Try Again
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="flex-1 px-4 py-2 bg-[var(--surface-muted)] text-[var(--text-primary)] border border-[var(--border-subtle)] rounded hover:bg-[var(--surface-soft)] transition-colors"
|
||||
>
|
||||
<button onClick={() => window.location.reload()} className="s-btn-quiet flex-1 justify-center">
|
||||
Reload Page
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
|
||||
interface MetricCardProps {
|
||||
title: string;
|
||||
@@ -15,6 +16,12 @@ interface MetricCardProps {
|
||||
horizontal?: boolean;
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
good: 'var(--success)',
|
||||
average: 'var(--warning)',
|
||||
warning: 'var(--warning)',
|
||||
};
|
||||
|
||||
export function MetricCard({
|
||||
title,
|
||||
value,
|
||||
@@ -49,140 +56,64 @@ export function MetricCard({
|
||||
return () => observer.disconnect();
|
||||
}, [animationDelay]);
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
good: '#5ee6a0',
|
||||
average: '#f2c94c',
|
||||
warning: '#f2c94c',
|
||||
};
|
||||
const reveal = {
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible ? 'translateY(0)' : 'translateY(12px)',
|
||||
transition: 'all 0.5s ease',
|
||||
} as const;
|
||||
|
||||
const footer = (
|
||||
<div className="s-stat-foot">
|
||||
<span
|
||||
onClick={onClick}
|
||||
style={{ cursor: onClick ? 'pointer' : 'default' }}
|
||||
>
|
||||
{statusText && status ? (
|
||||
<b style={{ color: statusColors[status], fontWeight: 600 }}>{statusText}</b>
|
||||
) : null}{' '}
|
||||
{subtitle ?? 'Details'}
|
||||
</span>
|
||||
{onClick ? (
|
||||
<button className="s-icon-btn !h-6 !w-6" onClick={onClick} title="Details">
|
||||
<ChevronRight size={11} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Horizontal layout for cards like Active User
|
||||
if (horizontal) {
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={`panel ${className}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
padding: 0,
|
||||
overflow: 'hidden',
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible ? 'translateY(0)' : 'translateY(12px)',
|
||||
transition: 'all 0.5s ease',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
padding: '20px 18px 18px 20px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
||||
<div className="card-icon">{icon}</div>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>{title}</span>
|
||||
<div ref={cardRef} className={`s-stat ${className}`} style={{ ...reveal, display: 'flex' }}>
|
||||
<div className="flex-1 px-4 pt-4 pb-2 flex flex-col">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="s-ibox">{icon}</span>
|
||||
<span className="text-[11.5px] font-medium text-[var(--text-secondary)]">{title}</span>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 36,
|
||||
fontWeight: 900,
|
||||
letterSpacing: '-1.5px',
|
||||
lineHeight: 1,
|
||||
color: '#e8e9f0',
|
||||
}}>
|
||||
<div className="mt-4 font-headline text-[30px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
|
||||
{value}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}>{subtitle}</div>
|
||||
)}
|
||||
{details && <div style={{ marginTop: 12 }}>{details}</div>}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 'auto',
|
||||
paddingTop: 14,
|
||||
}}>
|
||||
<span
|
||||
style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}
|
||||
onClick={onClick}
|
||||
>
|
||||
Details
|
||||
</span>
|
||||
<div className="arrow-btn" onClick={onClick}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{details ? <div className="mt-3">{details}</div> : null}
|
||||
<div className="mt-auto">{footer}</div>
|
||||
</div>
|
||||
{chart && (
|
||||
<div style={{
|
||||
width: '50%',
|
||||
padding: '16px 14px 46px 0',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
}}>
|
||||
{chart}
|
||||
</div>
|
||||
)}
|
||||
{chart ? (
|
||||
<div className="w-1/2 flex items-end pr-3 pb-10 pt-3">{chart}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Standard vertical card layout - matching self.html exactly
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={`panel ${className}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: 20,
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible ? 'translateY(0)' : 'translateY(12px)',
|
||||
transition: 'all 0.5s ease',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
||||
<div className="card-icon">{icon}</div>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>{title}</span>
|
||||
<div ref={cardRef} className={`s-stat ${className}`} style={reveal}>
|
||||
<div className="flex items-center gap-2.5 px-4 pt-4">
|
||||
<span className="s-ibox">{icon}</span>
|
||||
<span className="text-[11.5px] font-medium text-[var(--text-secondary)]">{title}</span>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
fontSize: 38,
|
||||
fontWeight: 900,
|
||||
letterSpacing: '-1.5px',
|
||||
lineHeight: 1,
|
||||
color: '#e8e9f0',
|
||||
}}>
|
||||
<div className="px-4 mt-4 font-headline text-[32px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
|
||||
{value}
|
||||
</div>
|
||||
|
||||
{(subtitle || statusText) && (
|
||||
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}>
|
||||
{statusText && status && (
|
||||
<span style={{ color: statusColors[status], fontWeight: 700 }}>{statusText}</span>
|
||||
)}{' '}
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{chart && <div style={{ marginTop: 12, marginBottom: 6 }}>{chart}</div>}
|
||||
|
||||
{details && <div style={{ marginTop: 16 }}>{details}</div>}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: 4, marginTop: 'auto' }}>
|
||||
<span
|
||||
onClick={onClick}
|
||||
style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}
|
||||
>
|
||||
Details
|
||||
</span>
|
||||
<div className="arrow-btn" onClick={onClick}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{chart ? <div className="px-4 mt-3">{chart}</div> : null}
|
||||
{details ? <div className="px-4 mt-3">{details}</div> : null}
|
||||
<div className="mt-4">{footer}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Check, X, Loader2, Box, HelpCircle, type LucideIcon } from 'lucide-react';
|
||||
import type { PillTone } from './sentry-utils';
|
||||
|
||||
type ServiceStatus = 'running' | 'building' | 'failed' | 'stopped' | 'unknown';
|
||||
|
||||
@@ -12,43 +13,29 @@ interface StatusBadgeProps {
|
||||
|
||||
const statusConfig: Record<
|
||||
ServiceStatus,
|
||||
{ color: string; bg: string; Icon: LucideIcon; label: string; animate?: boolean; glow?: string }
|
||||
{ tone: PillTone; Icon: LucideIcon; label: string; animate?: boolean }
|
||||
> = {
|
||||
running: {
|
||||
color: 'var(--success)',
|
||||
bg: 'var(--success-soft)',
|
||||
Icon: Check,
|
||||
label: 'Active',
|
||||
animate: true,
|
||||
glow: 'rgba(61, 214, 140, 0.4)',
|
||||
},
|
||||
building: {
|
||||
color: 'var(--warning)',
|
||||
bg: 'var(--warning-soft)',
|
||||
Icon: Loader2,
|
||||
label: 'Building',
|
||||
animate: true,
|
||||
glow: 'rgba(255, 112, 67, 0.4)',
|
||||
},
|
||||
failed: {
|
||||
color: 'var(--error)',
|
||||
bg: 'var(--error-soft)',
|
||||
Icon: X,
|
||||
label: 'Failed',
|
||||
glow: 'rgba(255, 107, 91, 0.4)',
|
||||
},
|
||||
stopped: {
|
||||
color: 'var(--text-muted)',
|
||||
bg: 'var(--surface-muted)',
|
||||
Icon: Box,
|
||||
label: 'Stopped',
|
||||
},
|
||||
unknown: {
|
||||
color: 'var(--text-muted)',
|
||||
bg: 'var(--surface-muted)',
|
||||
Icon: HelpCircle,
|
||||
label: 'Unknown',
|
||||
},
|
||||
running: { tone: 'ok', Icon: Check, label: 'Active' },
|
||||
building: { tone: 'warn', Icon: Loader2, label: 'Building', animate: true },
|
||||
failed: { tone: 'err', Icon: X, label: 'Failed' },
|
||||
stopped: { tone: 'off', Icon: Box, label: 'Stopped' },
|
||||
unknown: { tone: 'off', Icon: HelpCircle, label: 'Unknown' },
|
||||
};
|
||||
|
||||
const TONE_CLS: Record<PillTone, string> = {
|
||||
ok: 'text-[var(--success)] bg-[var(--success-soft)] border-[color-mix(in_srgb,var(--success)_16%,transparent)]',
|
||||
warn: 'text-[var(--warning)] bg-[var(--warning-soft)] border-[color-mix(in_srgb,var(--warning)_16%,transparent)]',
|
||||
err: 'text-[var(--error)] bg-[var(--error-soft)] border-[color-mix(in_srgb,var(--error)_16%,transparent)]',
|
||||
info: 'text-[var(--info)] bg-[var(--info-soft)] border-[color-mix(in_srgb,var(--info)_16%,transparent)]',
|
||||
off: 'text-[var(--text-tertiary)] bg-[var(--tint-06)] border-[var(--border-subtle)]',
|
||||
};
|
||||
|
||||
const TONE_DOT: Record<PillTone, string> = {
|
||||
ok: 'var(--success)',
|
||||
warn: 'var(--warning)',
|
||||
err: 'var(--error)',
|
||||
info: 'var(--info)',
|
||||
off: 'var(--text-tertiary)',
|
||||
};
|
||||
|
||||
export function StatusBadge({
|
||||
@@ -62,36 +49,26 @@ export function StatusBadge({
|
||||
const { Icon } = config;
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-2 py-0.5 text-[10px] gap-1.5',
|
||||
md: 'px-2.5 py-1 text-xs gap-1.5',
|
||||
lg: 'px-3 py-1.5 text-sm gap-2',
|
||||
sm: 'px-2 py-[2px] text-[10px] gap-1.5',
|
||||
md: 'px-2 py-[3.5px] text-[10.5px] gap-1.5',
|
||||
lg: 'px-2.5 py-1 text-[11.5px] gap-1.5',
|
||||
};
|
||||
|
||||
const iconSizes = {
|
||||
sm: 10,
|
||||
md: 12,
|
||||
lg: 14,
|
||||
};
|
||||
const iconSizes = { sm: 10, md: 11, lg: 12 };
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`inline-flex items-center rounded-full font-bold uppercase tracking-wider ring-1 ring-inset transition-all duration-200 ${sizeClasses[size]} ${className}`}
|
||||
style={{
|
||||
background: config.bg,
|
||||
color: config.color,
|
||||
borderColor: `${config.color}20`,
|
||||
boxShadow: status === 'running' && pulse ? `0 0 12px ${config.glow}` : 'none',
|
||||
}}
|
||||
className={`inline-flex items-center rounded-[5px] border font-medium transition-colors ${sizeClasses[size]} ${TONE_CLS[config.tone]} ${className}`}
|
||||
>
|
||||
{status === 'running' && pulse && (
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full live-pulse"
|
||||
style={{ background: config.color, boxShadow: `0 0 6px ${config.color}` }}
|
||||
style={{ background: TONE_DOT[config.tone] }}
|
||||
/>
|
||||
)}
|
||||
<Icon
|
||||
size={iconSizes[size]}
|
||||
className={config.animate && status === 'building' ? 'animate-spin' : ''}
|
||||
className={config.animate ? 'animate-spin' : ''}
|
||||
/>
|
||||
{showLabel && <span>{config.label}</span>}
|
||||
</div>
|
||||
@@ -106,27 +83,19 @@ interface LiveIndicatorProps {
|
||||
|
||||
export function LiveIndicator({ isLive, label, size = 'md' }: LiveIndicatorProps) {
|
||||
const sizeClasses = {
|
||||
sm: 'px-2 py-0.5 text-[10px] gap-1.5',
|
||||
md: 'px-2.5 py-1 text-xs gap-2',
|
||||
lg: 'px-3 py-1.5 text-sm gap-2',
|
||||
sm: 'px-2 py-[2px] text-[10px] gap-1.5',
|
||||
md: 'px-2 py-[3.5px] text-[10.5px] gap-1.5',
|
||||
lg: 'px-2.5 py-1 text-[11.5px] gap-1.5',
|
||||
};
|
||||
const tone: PillTone = isLive ? 'ok' : 'off';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`inline-flex items-center rounded-full font-bold uppercase tracking-wider ring-1 ring-inset transition-all duration-300 ${sizeClasses[size]}`}
|
||||
style={{
|
||||
background: isLive ? 'var(--success-soft)' : 'var(--surface-muted)',
|
||||
color: isLive ? 'var(--success)' : 'var(--text-muted)',
|
||||
borderColor: isLive ? 'rgba(61, 214, 140, 0.2)' : 'rgba(255, 255, 255, 0.05)',
|
||||
boxShadow: isLive ? '0 0 12px rgba(61, 214, 140, 0.3)' : 'none',
|
||||
}}
|
||||
<div
|
||||
className={`inline-flex items-center rounded-[5px] border font-medium transition-colors ${sizeClasses[size]} ${TONE_CLS[tone]}`}
|
||||
>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${isLive ? 'live-pulse' : ''}`}
|
||||
style={{
|
||||
background: isLive ? 'var(--success)' : 'var(--text-muted)',
|
||||
boxShadow: isLive ? '0 0 6px var(--success)' : 'none',
|
||||
}}
|
||||
style={{ background: TONE_DOT[tone] }}
|
||||
/>
|
||||
{label || (isLive ? 'Active' : 'Stopped')}
|
||||
</div>
|
||||
|
||||
@@ -12,3 +12,4 @@ export { useToast } from '../hooks/use-toast';
|
||||
export type { ToastType, ToastContextValue } from '../hooks/use-toast';
|
||||
export * from './BrandWordmark';
|
||||
export * from './FilterBar';
|
||||
export * from './sentry';
|
||||
@@ -0,0 +1,18 @@
|
||||
/* Non-component helpers for the Sentry kit — kept out of sentry.tsx so the
|
||||
component file stays fast-refresh clean. */
|
||||
|
||||
export type PillTone = 'ok' | 'warn' | 'err' | 'off' | 'info';
|
||||
|
||||
export function statusTone(status: string): PillTone {
|
||||
const s = status.toLowerCase();
|
||||
if (['running', 'healthy', 'online', 'active', 'deployed', 'succeeded', 'success', 'live'].includes(s)) return 'ok';
|
||||
if (['degraded', 'warning', 'building', 'deploying', 'pending', 'scheduled', 'queued', 'sleeping'].includes(s)) return 'warn';
|
||||
if (['failed', 'critical', 'error', 'offline', 'stopped'].includes(s)) return s === 'stopped' || s === 'offline' ? 'off' : 'err';
|
||||
return 'info';
|
||||
}
|
||||
|
||||
/* CSS var color helper for charts. */
|
||||
export function cssVar(name: string, fallback = ''): string {
|
||||
if (typeof window === 'undefined') return fallback;
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { ChevronDown, MoreHorizontal } from 'lucide-react';
|
||||
import type { PillTone } from './sentry-utils';
|
||||
|
||||
export type { PillTone };
|
||||
|
||||
/* ============================================================
|
||||
Sentry kit — React bindings for the 23-sentry.html language.
|
||||
Every page composes these; no page invents its own chrome.
|
||||
============================================================ */
|
||||
|
||||
export function Ibox({ children }: { children: ReactNode }) {
|
||||
return <span className="s-ibox">{children}</span>;
|
||||
}
|
||||
|
||||
/* ---------- buttons ---------- */
|
||||
|
||||
type BtnProps = {
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
className?: string;
|
||||
type?: 'button' | 'submit';
|
||||
};
|
||||
|
||||
export function GhostBtn({ children, onClick, disabled, title, className = '', type = 'button' }: BtnProps) {
|
||||
return (
|
||||
<button type={type} className={`s-btn-accent ${className}`} onClick={onClick} disabled={disabled} title={title}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuietBtn({ children, onClick, disabled, title, className = '', type = 'button' }: BtnProps) {
|
||||
return (
|
||||
<button type={type} className={`s-btn-quiet ${className}`} onClick={onClick} disabled={disabled} title={title}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconBtn({ children, onClick, disabled, title, className = '', type = 'button' }: BtnProps) {
|
||||
return (
|
||||
<button type={type} className={`s-icon-btn ${className}`} onClick={onClick} disabled={disabled} title={title}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* Filter/sort chip with trailing chevron — the `.sel` in sentry. */
|
||||
export function SelectChip({ children, onClick, title, icon }: BtnProps & { icon?: ReactNode }) {
|
||||
return (
|
||||
<button className="s-chip" onClick={onClick} title={title}>
|
||||
{icon}
|
||||
{children}
|
||||
<ChevronDown size={10} style={{ opacity: 0.55 }} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* Segmented icon buttons — chart-type switchers. */
|
||||
export function Seg({ options, value, onChange }: {
|
||||
options: { key: string; icon: ReactNode; title?: string }[];
|
||||
value: string;
|
||||
onChange: (key: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<span className="s-seg">
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={o.key}
|
||||
className={o.key === value ? 'on' : ''}
|
||||
onClick={() => onChange(o.key)}
|
||||
title={o.title}
|
||||
>
|
||||
{o.icon}
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- stat card ---------- */
|
||||
|
||||
export function SStat({ icon, label, value, unit, foot, delta, extra, onMenu }: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
unit?: string;
|
||||
foot: ReactNode;
|
||||
delta?: { dir: 'up' | 'down'; text: string };
|
||||
extra?: ReactNode;
|
||||
onMenu?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="s-stat">
|
||||
<div className="flex items-center gap-2.5 px-4 pt-4">
|
||||
<Ibox>{icon}</Ibox>
|
||||
<span className="text-[11.5px] font-medium text-[var(--text-secondary)] whitespace-nowrap">{label}</span>
|
||||
<button
|
||||
className="ml-auto text-[var(--text-tertiary)] hover:text-[var(--text-primary)] leading-none"
|
||||
onClick={onMenu}
|
||||
title="More"
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5 px-4 mt-4">
|
||||
<span className="font-headline text-[34px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
|
||||
{value}
|
||||
</span>
|
||||
{unit && <span className="text-[12px] font-medium text-[var(--text-tertiary)]">{unit}</span>}
|
||||
</div>
|
||||
{extra && <div className="px-4 mt-3">{extra}</div>}
|
||||
<div className="s-stat-foot mt-3.5">
|
||||
<span>{foot}</span>
|
||||
{delta && <SDelta dir={delta.dir}>{delta.text}</SDelta>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SDelta({ dir, children }: { dir: 'up' | 'down'; children: ReactNode }) {
|
||||
return (
|
||||
<span className={`s-delta ${dir}`}>
|
||||
{dir === 'up' ? '▲' : '▼'}{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- card ---------- */
|
||||
|
||||
export function SCard({ icon, title, accent, trail, children, pad = true, className = '' }: {
|
||||
icon?: ReactNode;
|
||||
title?: ReactNode;
|
||||
accent?: ReactNode;
|
||||
trail?: ReactNode;
|
||||
children: ReactNode;
|
||||
pad?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={`s-card ${className}`}>
|
||||
{(title !== undefined || icon) && (
|
||||
<div className="s-cardhead">
|
||||
{icon && <Ibox>{icon}</Ibox>}
|
||||
<span className="s-t">{title}{accent && <> <span className="s-ta">{accent}</span></>}</span>
|
||||
{trail && <span className="s-trail">{trail}</span>}
|
||||
</div>
|
||||
)}
|
||||
<div className={pad ? 'px-4 pb-4' : ''}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- ticks / bars ---------- */
|
||||
|
||||
export function Ticks({ pct, count = 56, warn = false }: { pct: number; count?: number; warn?: boolean }) {
|
||||
const on = Math.round((Math.max(0, Math.min(100, pct)) / 100) * count);
|
||||
return (
|
||||
<span className="s-ticks" role="img" aria-label={`${pct}%`}>
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<i key={i} className={i < on ? (warn ? 'warn' : 'on') : ''} />
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* Ascending mini bar glyph used inside table metric cells. */
|
||||
export function MiniBars({ tone = 'var(--accent-primary)' }: { tone?: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-end gap-[1.5px] h-3" aria-hidden>
|
||||
{[9, 5, 12, 7].map((h, i) => (
|
||||
<i key={i} className="w-[2.5px] rounded-[0.5px]" style={{ height: h, background: tone }} />
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- status pill ---------- */
|
||||
|
||||
const PILL_CLS: Record<PillTone, string> = {
|
||||
ok: 'text-[var(--success)] bg-[var(--success-soft)] border-[color-mix(in_srgb,var(--success)_16%,transparent)]',
|
||||
warn: 'text-[var(--warning)] bg-[var(--warning-soft)] border-[color-mix(in_srgb,var(--warning)_16%,transparent)]',
|
||||
err: 'text-[var(--error)] bg-[var(--error-soft)] border-[color-mix(in_srgb,var(--error)_16%,transparent)]',
|
||||
info: 'text-[var(--info)] bg-[var(--info-soft)] border-[color-mix(in_srgb,var(--info)_16%,transparent)]',
|
||||
off: 'text-[var(--text-tertiary)] bg-[var(--tint-06)] border-[var(--border-subtle)]',
|
||||
};
|
||||
|
||||
export function SPill({ tone, children }: { tone: PillTone; children: ReactNode }) {
|
||||
return (
|
||||
<span className={`inline-block text-[10.5px] font-medium rounded-[5px] px-2 py-[3.5px] border ${PILL_CLS[tone]}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- inset callout ---------- */
|
||||
|
||||
export function SAnomaly({ icon, title, items }: { icon?: ReactNode; title: ReactNode; items: ReactNode[] }) {
|
||||
return (
|
||||
<div className="s-inset">
|
||||
<div className="flex items-center gap-2 pb-2 mb-2 border-b border-[var(--border-subtle)] text-[11.5px] text-[var(--text-secondary)]">
|
||||
{icon}
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
{items.map((it, i) => (
|
||||
<div key={i} className="s-anomaly-item">{it}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- data table (inset header bar, sortable) ---------- */
|
||||
|
||||
export type SCol<T> = {
|
||||
key: string;
|
||||
label: ReactNode;
|
||||
width?: string;
|
||||
sortable?: boolean;
|
||||
sortValue?: (row: T) => string | number;
|
||||
render: (row: T) => ReactNode;
|
||||
};
|
||||
|
||||
const CARET = (
|
||||
<svg viewBox="0 0 7 10" width="7" height="10" className="shrink-0">
|
||||
<path d="M3.5 .8 5.8 3.6H1.2zM3.5 9.2 1.2 6.4h4.6z" fill="currentColor" opacity="0.45" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export function STable<T extends { id?: string | number }>({ cols, rows, rowKey, selectable, onRowClick }: {
|
||||
cols: SCol<T>[];
|
||||
rows: T[];
|
||||
rowKey?: (row: T, i: number) => string | number;
|
||||
selectable?: boolean;
|
||||
onRowClick?: (row: T) => void;
|
||||
}) {
|
||||
const [sortKey, setSortKey] = useState<string | null>(null);
|
||||
const [sortDir, setSortDir] = useState<1 | -1>(1);
|
||||
const [checked, setChecked] = useState<Set<number>>(new Set());
|
||||
|
||||
const allCols: SCol<T>[] = selectable
|
||||
? [{ key: '$sel', label: '', width: '30px', render: () => null }, ...cols]
|
||||
: cols;
|
||||
const template = allCols.map((c) => c.width ?? '1fr').join(' ');
|
||||
|
||||
const sorted = [...rows];
|
||||
const sc = cols.find((c) => c.key === sortKey);
|
||||
if (sc?.sortValue) {
|
||||
sorted.sort((a, b) => {
|
||||
const x = sc.sortValue!(a); const y = sc.sortValue!(b);
|
||||
return (x > y ? 1 : x < y ? -1 : 0) * sortDir;
|
||||
});
|
||||
}
|
||||
|
||||
const toggleAll = () =>
|
||||
setChecked(checked.size === rows.length ? new Set() : new Set(rows.map((_, i) => i)));
|
||||
|
||||
return (
|
||||
<div className="s-dt pb-2.5">
|
||||
<div className="s-dt-head" style={{ gridTemplateColumns: template }}>
|
||||
{allCols.map((c) =>
|
||||
c.key === '$sel' ? (
|
||||
<span key="$sel" className="s-dt-h">
|
||||
<span className={`s-ck ${checked.size === rows.length && rows.length > 0 ? 'on' : ''}`} onClick={toggleAll} />
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
key={c.key}
|
||||
className={`s-dt-h ${c.sortable !== false ? 'sortable' : ''} ${sortKey === c.key ? 'on' : ''}`}
|
||||
onClick={() => {
|
||||
if (c.sortable === false) return;
|
||||
setSortDir(sortKey === c.key ? -sortDir : 1);
|
||||
setSortKey(c.key);
|
||||
}}
|
||||
>
|
||||
{c.label}{c.sortable !== false && CARET}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
{sorted.map((row, i) => {
|
||||
const key = rowKey ? rowKey(row, i) : row.id ?? i;
|
||||
const ri = rows.indexOf(row);
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`s-dt-row ${onRowClick ? 'clickable' : ''}`}
|
||||
style={{ gridTemplateColumns: template }}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
>
|
||||
{allCols.map((c) =>
|
||||
c.key === '$sel' ? (
|
||||
<span key="$sel">
|
||||
<span
|
||||
className={`s-ck ${checked.has(ri) ? 'on' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setChecked((prev) => {
|
||||
const n = new Set(prev);
|
||||
if (n.has(ri)) n.delete(ri); else n.add(ri);
|
||||
return n;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<span key={c.key} className="min-w-0">{c.render(row)}</span>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- page header (Welcome back … + actions) ---------- */
|
||||
|
||||
export function SPageHead({ title, titleAccent, sub, subAccent, actions }: {
|
||||
title: ReactNode;
|
||||
titleAccent?: ReactNode;
|
||||
sub?: ReactNode;
|
||||
subAccent?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-4 mb-5 flex-wrap">
|
||||
<div>
|
||||
<h1 className="font-headline text-[24px] font-bold tracking-[-0.02em] text-[var(--text-primary)]">
|
||||
{title}{titleAccent && <> <em className="not-italic text-[var(--accent-primary)]">{titleAccent}</em></>}
|
||||
</h1>
|
||||
{sub && (
|
||||
<div className="text-[12px] text-[var(--text-tertiary)] mt-[3px]">
|
||||
{sub}{subAccent && <> <b className="text-[var(--accent-primary)] font-semibold">{subAccent}</b></>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{actions && <div className="ml-auto flex items-center gap-2.5">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Section label — small caps with accent square. */
|
||||
export function SSection({ children, right }: { children: ReactNode; right?: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-3 mt-1">
|
||||
<div className="flex items-center gap-2 text-[10.5px] font-medium uppercase tracking-[0.1em] text-[var(--text-tertiary)]">
|
||||
<span className="w-[5px] h-[5px] rounded-[1px] bg-[var(--accent-primary)]" />
|
||||
{children}
|
||||
</div>
|
||||
{right && <span className="v-mono text-[10.5px] text-[var(--text-muted)]">{right}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Legend row for charts. */
|
||||
export function SLegend({ items }: { items: { color: string; label: string }[] }) {
|
||||
return (
|
||||
<div className="flex gap-5 px-4 pb-2.5 pt-0.5 text-[11.5px] font-medium text-[var(--text-secondary)]">
|
||||
{items.map((it) => (
|
||||
<span key={it.label}>
|
||||
<i className="inline-block w-[7px] h-[7px] rounded-[1.5px] mr-2" style={{ background: it.color }} />
|
||||
{it.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user