mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 08:57:25 +00:00
8d9e6574cc
* feat(appearance): add Calm/Signature visual style, readability mode, and chart palette Turn the "too intense / italic headers hurt / the security graph fights my eyes" feedback into a token-driven Visual style with Calm as the new default and Signature one click back to the prior look. - Heading family routes through a `.font-heading` utility driven by `--font-heading`/`--heading-style`: operational headings render upright in the interface face under Calm and italic Instrument Serif under Signature. Base rule sets family + style only, so each call site keeps its own weight/tracking and Signature stays a true no-op; the Calm lift is a `[data-headings="clean"]` descendant rule. Brand lockup, empty-state heroes, and onboarding stay serif. - Severity charts resolve through `--sev-*` tokens with Muted, Heat, and Signature palettes; FindingsByType routes its series through the severity ramp plus a neutral so no brand-cyan sits next to rose. The risk trend flattens its gradient under Muted/Heat/reduced and keeps the gradient under Signature. - Appearance settings gain Visual style cards, a Security visualization palette, a Readability master toggle, a Motion & effects group, and a "Reset to default" button (restores the Calm axes, disabled while readability is on). Contrast moves under Readability and Ambient glow under Motion & effects. A card is selected only while the stored sub-axes match its preset, so a custom combination de-selects both. - The topbar Theme quick-switch swaps the interface/data font pickers for a Visual style switch and a Readability toggle (text size kept); its footer Settings link jumps straight to Appearance. - Readability is a sticky master that forces the calm resolution and a contrast lift at apply time without mutating the stored sub-axes. - New users default to Calm; any pre-existing persisted appearance state keeps the Signature look. The pre-paint script mirrors the store. - SegmentedControl gains a `disabled` prop and a nullable value (no active segment for a custom combination, with a roving-tabindex keyboard anchor). Adds unit/component coverage for the store, migration, chart shape logic, the disabled control, the reset/de-selection, and the quick-switch. * fix(appearance): migrate Blueprint serif headings and surface readability locks - Migrate the two operational Blueprint headings (catalog tile name, drift-policy option title) from font-serif italic to the .font-heading utility; the first pass only covered font-display, so Calm still left these italic. font-serif and font-display both resolve to the same display face, so this is the same fix. - Lock the Visual style cards under Readability (parity with the topbar switch and the on-screen guidance to turn Readability off to choose a style by hand). - Lock the Border brightness slider under Readability and show its forced +0.03 readout, since Readability overrides the stored value; dragging it previously appeared to do nothing. - Correct the Appearance docs sentence for the topbar quick switch (it listed fonts; the quick switch now carries visual style, readability, and text size).
198 lines
6.6 KiB
TypeScript
198 lines
6.6 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { Bell } from 'lucide-react';
|
|
type FleetHealth = 'healthy' | 'degraded' | 'critical';
|
|
|
|
interface FleetMastheadProps {
|
|
nodeCount: number;
|
|
onlineCount: number;
|
|
criticalCount: number;
|
|
totalCpuPercent: number;
|
|
worstCpu: { name: string; percent: number } | null;
|
|
totalMemUsed: number;
|
|
totalMemTotal: number;
|
|
activeContainers: number;
|
|
totalContainers: number;
|
|
lastSyncAt: number | null;
|
|
loading: boolean;
|
|
}
|
|
|
|
function formatBytes(bytes: number): string {
|
|
if (!bytes || bytes <= 0) return '0 GiB';
|
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GiB`;
|
|
}
|
|
|
|
function formatAgo(ms: number): string {
|
|
const clamped = Math.max(0, ms);
|
|
if (clamped < 60_000) return `${Math.round(clamped / 1000)}s`;
|
|
if (clamped < 3_600_000) return `${Math.round(clamped / 60_000)}m`;
|
|
return `${Math.round(clamped / 3_600_000)}h`;
|
|
}
|
|
|
|
function useTicker(intervalMs: number): number {
|
|
const [now, setNow] = useState(() => Date.now());
|
|
useEffect(() => {
|
|
const id = setInterval(() => setNow(Date.now()), intervalMs);
|
|
return () => clearInterval(id);
|
|
}, [intervalMs]);
|
|
return now;
|
|
}
|
|
|
|
const healthConfig: Record<FleetHealth, { label: string; dotClass: string; textClass: string; railClass: string; tintClass: string }> = {
|
|
healthy: {
|
|
label: 'The fleet',
|
|
dotClass: 'bg-success shadow-[0_0_0_3px_color-mix(in_oklch,var(--success)_20%,transparent)]',
|
|
textClass: 'text-stat-value',
|
|
railClass: 'bg-brand',
|
|
tintClass: 'from-brand/[0.06] via-transparent to-transparent',
|
|
},
|
|
degraded: {
|
|
label: 'The fleet',
|
|
dotClass: 'bg-warning shadow-[0_0_0_3px_color-mix(in_oklch,var(--warning)_22%,transparent)]',
|
|
textClass: 'text-warning',
|
|
railClass: 'bg-warning',
|
|
tintClass: 'from-warning/[0.06] via-transparent to-transparent',
|
|
},
|
|
critical: {
|
|
label: 'The fleet',
|
|
dotClass: 'bg-destructive shadow-[0_0_0_3px_color-mix(in_oklch,var(--destructive)_24%,transparent)]',
|
|
textClass: 'text-destructive',
|
|
railClass: 'bg-destructive',
|
|
tintClass: 'from-destructive/[0.06] via-transparent to-transparent',
|
|
},
|
|
};
|
|
|
|
export function FleetMasthead({
|
|
nodeCount,
|
|
onlineCount,
|
|
criticalCount,
|
|
totalCpuPercent,
|
|
worstCpu,
|
|
totalMemUsed,
|
|
totalMemTotal,
|
|
activeContainers,
|
|
totalContainers,
|
|
lastSyncAt,
|
|
loading,
|
|
}: FleetMastheadProps) {
|
|
const level: FleetHealth = useMemo(() => {
|
|
if (criticalCount > 0) return 'critical';
|
|
if (onlineCount < nodeCount) return 'degraded';
|
|
return 'healthy';
|
|
}, [criticalCount, onlineCount, nodeCount]);
|
|
const config = healthConfig[level];
|
|
const now = useTicker(1000);
|
|
|
|
const offlineCount = Math.max(0, nodeCount - onlineCount);
|
|
const reasons: string[] = [];
|
|
if (offlineCount > 0) reasons.push(`${offlineCount} offline`);
|
|
if (criticalCount > 0) reasons.push(`${criticalCount} critical`);
|
|
|
|
const lastSyncLabel = loading
|
|
? 'syncing…'
|
|
: lastSyncAt
|
|
? `last sync ${formatAgo(now - lastSyncAt)}`
|
|
: 'no sync yet';
|
|
|
|
const metaLine = `${nodeCount} ${nodeCount === 1 ? 'node' : 'nodes'} · ${onlineCount} online · ${lastSyncLabel}`;
|
|
const reasonsLine = reasons.join(' · ');
|
|
|
|
const cpuTone = totalCpuPercent >= 80 ? 'warn' : 'value';
|
|
const memPercent = totalMemTotal > 0 ? (totalMemUsed / totalMemTotal) * 100 : 0;
|
|
|
|
return (
|
|
<div className="relative overflow-hidden rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel transition-colors mb-4">
|
|
<div className={`pointer-events-none absolute inset-0 bg-gradient-to-r ${config.tintClass}`} />
|
|
<div className={`absolute inset-y-0 left-0 w-[3px] ${config.railClass}`} />
|
|
<div className="relative grid grid-cols-[auto_1fr_auto] items-center gap-6 py-5 pl-7 pr-6">
|
|
<div className="flex items-center gap-4">
|
|
<span
|
|
aria-hidden="true"
|
|
className={`h-2.5 w-2.5 rounded-full ${config.dotClass} ${level === 'healthy' ? '' : 'animate-[pulse_2.4s_ease-in-out_infinite]'}`}
|
|
/>
|
|
<div className="flex flex-col gap-1">
|
|
<span className={`font-heading text-3xl leading-none tracking-tight ${config.textClass}`}>
|
|
{config.label}
|
|
</span>
|
|
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle">
|
|
{metaLine}
|
|
</span>
|
|
{reasonsLine ? (
|
|
<span className="font-mono text-[11px] text-stat-subtitle/90">
|
|
{reasonsLine}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="hidden items-stretch justify-end gap-0 md:flex">
|
|
<StatTile
|
|
label="CPU"
|
|
value={`${totalCpuPercent.toFixed(0)}%`}
|
|
sub={worstCpu ? `peak ${worstCpu.name} ${worstCpu.percent.toFixed(0)}%` : undefined}
|
|
tone={cpuTone}
|
|
/>
|
|
<StatTile
|
|
label="MEM"
|
|
value={formatBytes(totalMemUsed)}
|
|
sub={totalMemTotal > 0 ? `of ${formatBytes(totalMemTotal)} · ${memPercent.toFixed(0)}%` : undefined}
|
|
tone="value"
|
|
divider
|
|
/>
|
|
<StatTile
|
|
label="CONTAINERS"
|
|
value={`${activeContainers}`}
|
|
sub={`of ${totalContainers} total`}
|
|
tone="value"
|
|
divider
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 pl-4">
|
|
<Bell
|
|
className={`h-3.5 w-3.5 ${criticalCount > 0 ? 'text-destructive' : 'text-stat-icon'}`}
|
|
strokeWidth={1.5}
|
|
/>
|
|
<span
|
|
className={`font-mono text-sm tabular-nums ${criticalCount > 0 ? 'text-destructive' : 'text-stat-subtitle'}`}
|
|
>
|
|
{criticalCount}
|
|
</span>
|
|
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle">
|
|
{criticalCount === 1 ? 'alert' : 'alerts'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatTile({
|
|
label,
|
|
value,
|
|
sub,
|
|
tone,
|
|
divider,
|
|
}: {
|
|
label: string;
|
|
value: string;
|
|
sub?: string;
|
|
tone: 'value' | 'warn';
|
|
divider?: boolean;
|
|
}) {
|
|
return (
|
|
<div className={`flex flex-col gap-1 px-5 ${divider ? 'border-l border-border/60' : ''}`}>
|
|
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">
|
|
{label}
|
|
</span>
|
|
<span
|
|
className={`font-mono tabular-nums text-xl leading-none ${tone === 'warn' ? 'text-warning' : 'text-stat-value'}`}
|
|
>
|
|
{value}
|
|
</span>
|
|
{sub ? (
|
|
<span className="font-mono text-[10px] text-stat-subtitle/80">{sub}</span>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|