mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 03:06:54 +00:00
feat(ui): redesign global logs as cockpit surface (#699)
Rework the Logs view into the cockpit language: a PageMasthead strip with cyan rail, pulsing live dot, italic state word, and tracked-mono kicker; a SignalRail of EVENTS/MIN (with rolling 60s sparkline), ERRORS, WARNINGS, and CONTAINERS tiles; a segmented filter strip; a day-banded feed with severity dots and whole-row tint on ERROR/WARN; and a floating glass chip strip for pause/resume, clear, and download. Decouple the live log stream from the Developer Mode toggle so SSE runs by default for everyone, with polling as an invisible fallback when EventSource is unavailable. Drop the Standard Log Polling Rate setting and the global_logs_refresh key it persisted; Developer Mode still gates Real-Time Metrics and Debug Diagnostics and nothing else. Introduces the shared PageMasthead and SignalRail primitives for reuse across other cockpit pages.
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type MastheadTone = 'live' | 'idle' | 'warn' | 'error';
|
||||
|
||||
export interface MastheadMetadataItem {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: 'value' | 'warn' | 'error' | 'subtitle';
|
||||
}
|
||||
|
||||
export interface PageMastheadProps {
|
||||
kicker: string;
|
||||
state: string;
|
||||
tone: MastheadTone;
|
||||
pulsing?: boolean;
|
||||
metadata?: MastheadMetadataItem[];
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const toneConfig: Record<MastheadTone, {
|
||||
dotClass: string;
|
||||
stateTextClass: string;
|
||||
tintClass: string;
|
||||
}> = {
|
||||
live: {
|
||||
dotClass: 'bg-brand shadow-[0_0_0_3px_color-mix(in_oklch,var(--brand)_22%,transparent)]',
|
||||
stateTextClass: 'text-stat-value',
|
||||
tintClass: 'from-brand/[0.06] via-transparent to-transparent',
|
||||
},
|
||||
idle: {
|
||||
dotClass: 'bg-stat-subtitle',
|
||||
stateTextClass: 'text-stat-title',
|
||||
tintClass: 'from-transparent via-transparent to-transparent',
|
||||
},
|
||||
warn: {
|
||||
dotClass: 'bg-warning shadow-[0_0_0_3px_color-mix(in_oklch,var(--warning)_22%,transparent)]',
|
||||
stateTextClass: 'text-warning',
|
||||
tintClass: 'from-warning/[0.06] via-transparent to-transparent',
|
||||
},
|
||||
error: {
|
||||
dotClass: 'bg-destructive shadow-[0_0_0_3px_color-mix(in_oklch,var(--destructive)_24%,transparent)]',
|
||||
stateTextClass: 'text-destructive',
|
||||
tintClass: 'from-destructive/[0.06] via-transparent to-transparent',
|
||||
},
|
||||
};
|
||||
|
||||
const metadataToneClass: Record<NonNullable<MastheadMetadataItem['tone']>, string> = {
|
||||
value: 'text-stat-value',
|
||||
warn: 'text-warning',
|
||||
error: 'text-destructive',
|
||||
subtitle: 'text-stat-subtitle',
|
||||
};
|
||||
|
||||
export function PageMasthead({
|
||||
kicker,
|
||||
state,
|
||||
tone,
|
||||
pulsing = false,
|
||||
metadata,
|
||||
children,
|
||||
className,
|
||||
}: PageMastheadProps) {
|
||||
const config = toneConfig[tone];
|
||||
const shouldPulse = pulsing && (tone === 'live' || tone === 'warn');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative shrink-0 overflow-hidden border-b border-card-border bg-card',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={cn('pointer-events-none absolute inset-0 bg-gradient-to-r', config.tintClass)} />
|
||||
<div className="absolute inset-y-0 left-0 w-[3px] bg-brand" />
|
||||
<div className="relative grid grid-cols-[1fr_auto] items-center gap-6 py-4 pl-7 pr-6">
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'h-2.5 w-2.5 shrink-0 rounded-full',
|
||||
config.dotClass,
|
||||
shouldPulse && 'animate-[pulse_2.4s_ease-in-out_infinite]',
|
||||
)}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
{kicker}
|
||||
</span>
|
||||
<span className={cn('font-display italic text-2xl leading-none tracking-tight', config.stateTextClass)}>
|
||||
{state}
|
||||
</span>
|
||||
</div>
|
||||
{children ? <div className="ml-2 min-w-0">{children}</div> : null}
|
||||
</div>
|
||||
|
||||
{metadata && metadata.length > 0 ? (
|
||||
<div className="hidden items-stretch justify-end gap-0 md:flex">
|
||||
{metadata.map((item, idx) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className={cn(
|
||||
'flex flex-col gap-1 px-5',
|
||||
idx > 0 && 'border-l border-border/60',
|
||||
)}
|
||||
>
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">
|
||||
{item.label}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono tabular-nums text-lg leading-none',
|
||||
metadataToneClass[item.tone ?? 'value'],
|
||||
)}
|
||||
>
|
||||
{item.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Sparkline } from '@/components/ui/sparkline';
|
||||
|
||||
export type SignalTone = 'value' | 'warn' | 'error' | 'subtitle';
|
||||
|
||||
export interface SignalTile {
|
||||
kicker: string;
|
||||
value: string;
|
||||
tone?: SignalTone;
|
||||
/** Optional series for a 64x20 sparkline stroked in brand cyan. */
|
||||
spark?: number[];
|
||||
}
|
||||
|
||||
interface SignalRailProps {
|
||||
tiles: SignalTile[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const toneClass: Record<SignalTone, string> = {
|
||||
value: 'text-stat-value',
|
||||
warn: 'text-warning',
|
||||
error: 'text-destructive',
|
||||
subtitle: 'text-stat-subtitle',
|
||||
};
|
||||
|
||||
export function SignalRail({ tiles, className }: SignalRailProps) {
|
||||
if (tiles.length === 0) return null;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0 grid border-b border-card-border bg-card',
|
||||
className,
|
||||
)}
|
||||
style={{ gridTemplateColumns: `repeat(${tiles.length}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{tiles.map((tile, idx) => (
|
||||
<div
|
||||
key={tile.kicker}
|
||||
className={cn(
|
||||
'flex items-center justify-between gap-4 px-5 py-[var(--density-tile-y)]',
|
||||
idx > 0 && 'border-l border-card-border',
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">
|
||||
{tile.kicker}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono tabular-nums tracking-tight text-2xl leading-none',
|
||||
toneClass[tile.tone ?? 'value'],
|
||||
)}
|
||||
>
|
||||
{tile.value}
|
||||
</span>
|
||||
</div>
|
||||
{tile.spark && tile.spark.length > 1 ? (
|
||||
<div className="h-5 w-16 shrink-0 opacity-90">
|
||||
<Sparkline
|
||||
points={tile.spark}
|
||||
stroke="var(--brand)"
|
||||
fill="var(--brand)"
|
||||
strokeWidth={1.25}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user