From 1f389d3d5db2aaea412c9cdca7b1a4346853ed0a Mon Sep 17 00:00:00 2001 From: Daniel Gordon Date: Tue, 21 Jul 2026 17:59:49 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat(web):=20host-stats=20style=20framework?= =?UTF-8?q?=20=E2=80=94=20selectable=20sidebar=20server-load=20variants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scaffolding for the server-load readout redesign: a sidebarHostStatsStyle client setting, a Server load style picker in Settings -> Features, a variant registry under components/host-stats/ (classic extracted from Sidebar.tsx + 7 placeholder variants to be filled in by design agents), and useHostStatsWithHistory providing a rolling 2-minute sample window for sparkline/trend styles. Co-Authored-By: Claude Fable 5 --- .../settings/DesktopClientSettings.test.ts | 1 + apps/web/src/components/Sidebar.tsx | 31 +++-------- .../components/host-stats/VariantBadge.tsx | 16 ++++++ .../src/components/host-stats/VariantBars.tsx | 16 ++++++ .../components/host-stats/VariantClassic.tsx | 19 +++++++ .../host-stats/VariantEqualizer.tsx | 16 ++++++ .../components/host-stats/VariantRings.tsx | 16 ++++++ .../components/host-stats/VariantSegments.tsx | 16 ++++++ .../host-stats/VariantSignature.tsx | 16 ++++++ .../host-stats/VariantSparkline.tsx | 16 ++++++ apps/web/src/components/host-stats/types.ts | 31 +++++++++++ .../web/src/components/host-stats/variants.ts | 33 ++++++++++++ .../components/settings/FeaturesSettings.tsx | 28 ++++++++++ apps/web/src/hooks/useHostStats.ts | 53 ++++++++++++++++++- packages/contracts/src/settings.ts | 19 +++++++ 15 files changed, 301 insertions(+), 26 deletions(-) create mode 100644 apps/web/src/components/host-stats/VariantBadge.tsx create mode 100644 apps/web/src/components/host-stats/VariantBars.tsx create mode 100644 apps/web/src/components/host-stats/VariantClassic.tsx create mode 100644 apps/web/src/components/host-stats/VariantEqualizer.tsx create mode 100644 apps/web/src/components/host-stats/VariantRings.tsx create mode 100644 apps/web/src/components/host-stats/VariantSegments.tsx create mode 100644 apps/web/src/components/host-stats/VariantSignature.tsx create mode 100644 apps/web/src/components/host-stats/VariantSparkline.tsx create mode 100644 apps/web/src/components/host-stats/types.ts create mode 100644 apps/web/src/components/host-stats/variants.ts diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 26f604318c1..806dfd2660c 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -33,6 +33,7 @@ const clientSettings: ClientSettings = { providerModelPreferences: {}, sidebarChatListView: "grouped", sidebarHostStatsVisible: false, + sidebarHostStatsStyle: "classic", sidebarProjectGroupingMode: "repository_path", sidebarProjectGroupingOverrides: { "environment-1:/tmp/project-a": "separate", diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 1b737a8d22b..24415d8a912 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -210,7 +210,8 @@ import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useIsMobile } from "~/hooks/useMediaQuery"; import { CommandDialogTrigger } from "./ui/command"; import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; -import { useHostStats } from "~/hooks/useHostStats"; +import { useHostStatsWithHistory } from "~/hooks/useHostStats"; +import { HOST_STATS_VARIANTS } from "~/components/host-stats/variants"; import { primaryServerConfigAtom, primaryServerKeybindingsAtom } from "../state/server"; import { derivePhysicalProjectKey, @@ -3298,37 +3299,19 @@ function T3Wordmark() { ); } -// Compact "3.2/15.6 GB" style figure for the footer host-stats readout. -function formatFooterGigabytes(bytes: number): string { - const gigabytes = bytes / 1024 ** 3; - if (gigabytes >= 100) return String(Math.round(gigabytes)); - return gigabytes.toFixed(1); -} - // Ambient CPU/memory telemetry for the T3 server host, shown next to the // Settings button when the "Server load" toggle (Settings → Features) is on. +// The visual style is selectable there too; see components/host-stats/. const SidebarHostStats = memo(function SidebarHostStats() { const visible = useClientSettings((settings) => settings.sidebarHostStatsVisible); - const stats = useHostStats(visible); + const style = useClientSettings((settings) => settings.sidebarHostStatsStyle); + const { stats, history } = useHostStatsWithHistory(visible); if (!visible || stats === null) { return null; } - const cpuLabel = `${Math.round(stats.cpuPercent)}%`; - const memLabel = `${formatFooterGigabytes(stats.memUsedBytes)}/${formatFooterGigabytes(stats.memTotalBytes)}G`; - const coreLabel = stats.cpuCount === 1 ? "1 core" : `${stats.cpuCount} cores`; - const detail = `Server load — CPU ${stats.cpuPercent.toFixed(1)}% of ${coreLabel} · memory ${formatFooterGigabytes(stats.memUsedBytes)} of ${formatFooterGigabytes(stats.memTotalBytes)} GB`; - - return ( -
- CPU {cpuLabel} - MEM {memLabel} -
- ); + const { Component } = HOST_STATS_VARIANTS[style]; + return ; }); const SidebarChromeFooter = memo(function SidebarChromeFooter() { diff --git a/apps/web/src/components/host-stats/VariantBadge.tsx b/apps/web/src/components/host-stats/VariantBadge.tsx new file mode 100644 index 00000000000..d8d1d4f6ca6 --- /dev/null +++ b/apps/web/src/components/host-stats/VariantBadge.tsx @@ -0,0 +1,16 @@ +import { formatFooterGigabytes, hostStatsDetail, type HostStatsVariantProps } from "./types"; + +// Placeholder for the "Badge" redesign — replaced by its design agent. +export function VariantBadge({ stats }: HostStatsVariantProps) { + const detail = hostStatsDetail(stats); + return ( +
+ Badge {Math.round(stats.cpuPercent)}% + {formatFooterGigabytes(stats.memUsedBytes)}G +
+ ); +} diff --git a/apps/web/src/components/host-stats/VariantBars.tsx b/apps/web/src/components/host-stats/VariantBars.tsx new file mode 100644 index 00000000000..f3cf6c9828b --- /dev/null +++ b/apps/web/src/components/host-stats/VariantBars.tsx @@ -0,0 +1,16 @@ +import { formatFooterGigabytes, hostStatsDetail, type HostStatsVariantProps } from "./types"; + +// Placeholder for the "Bars" redesign — replaced by its design agent. +export function VariantBars({ stats }: HostStatsVariantProps) { + const detail = hostStatsDetail(stats); + return ( +
+ Bars {Math.round(stats.cpuPercent)}% + {formatFooterGigabytes(stats.memUsedBytes)}G +
+ ); +} diff --git a/apps/web/src/components/host-stats/VariantClassic.tsx b/apps/web/src/components/host-stats/VariantClassic.tsx new file mode 100644 index 00000000000..97524429e4d --- /dev/null +++ b/apps/web/src/components/host-stats/VariantClassic.tsx @@ -0,0 +1,19 @@ +import { formatFooterGigabytes, hostStatsDetail, type HostStatsVariantProps } from "./types"; + +// The original plain-text readout: "CPU 12% MEM 3.2/15.6G". +export function VariantClassic({ stats }: HostStatsVariantProps) { + const cpuLabel = `${Math.round(stats.cpuPercent)}%`; + const memLabel = `${formatFooterGigabytes(stats.memUsedBytes)}/${formatFooterGigabytes(stats.memTotalBytes)}G`; + const detail = hostStatsDetail(stats); + + return ( +
+ CPU {cpuLabel} + MEM {memLabel} +
+ ); +} diff --git a/apps/web/src/components/host-stats/VariantEqualizer.tsx b/apps/web/src/components/host-stats/VariantEqualizer.tsx new file mode 100644 index 00000000000..5a2a4bee347 --- /dev/null +++ b/apps/web/src/components/host-stats/VariantEqualizer.tsx @@ -0,0 +1,16 @@ +import { formatFooterGigabytes, hostStatsDetail, type HostStatsVariantProps } from "./types"; + +// Placeholder for the "Equalizer" redesign — replaced by its design agent. +export function VariantEqualizer({ stats }: HostStatsVariantProps) { + const detail = hostStatsDetail(stats); + return ( +
+ Equalizer {Math.round(stats.cpuPercent)}% + {formatFooterGigabytes(stats.memUsedBytes)}G +
+ ); +} diff --git a/apps/web/src/components/host-stats/VariantRings.tsx b/apps/web/src/components/host-stats/VariantRings.tsx new file mode 100644 index 00000000000..734010ae817 --- /dev/null +++ b/apps/web/src/components/host-stats/VariantRings.tsx @@ -0,0 +1,16 @@ +import { formatFooterGigabytes, hostStatsDetail, type HostStatsVariantProps } from "./types"; + +// Placeholder for the "Rings" redesign — replaced by its design agent. +export function VariantRings({ stats }: HostStatsVariantProps) { + const detail = hostStatsDetail(stats); + return ( +
+ Rings {Math.round(stats.cpuPercent)}% + {formatFooterGigabytes(stats.memUsedBytes)}G +
+ ); +} diff --git a/apps/web/src/components/host-stats/VariantSegments.tsx b/apps/web/src/components/host-stats/VariantSegments.tsx new file mode 100644 index 00000000000..5ef2c78249d --- /dev/null +++ b/apps/web/src/components/host-stats/VariantSegments.tsx @@ -0,0 +1,16 @@ +import { formatFooterGigabytes, hostStatsDetail, type HostStatsVariantProps } from "./types"; + +// Placeholder for the "Segments" redesign — replaced by its design agent. +export function VariantSegments({ stats }: HostStatsVariantProps) { + const detail = hostStatsDetail(stats); + return ( +
+ Segments {Math.round(stats.cpuPercent)}% + {formatFooterGigabytes(stats.memUsedBytes)}G +
+ ); +} diff --git a/apps/web/src/components/host-stats/VariantSignature.tsx b/apps/web/src/components/host-stats/VariantSignature.tsx new file mode 100644 index 00000000000..f65eebd1fae --- /dev/null +++ b/apps/web/src/components/host-stats/VariantSignature.tsx @@ -0,0 +1,16 @@ +import { formatFooterGigabytes, hostStatsDetail, type HostStatsVariantProps } from "./types"; + +// Placeholder for the "Signature" redesign — replaced by its design agent. +export function VariantSignature({ stats }: HostStatsVariantProps) { + const detail = hostStatsDetail(stats); + return ( +
+ Signature {Math.round(stats.cpuPercent)}% + {formatFooterGigabytes(stats.memUsedBytes)}G +
+ ); +} diff --git a/apps/web/src/components/host-stats/VariantSparkline.tsx b/apps/web/src/components/host-stats/VariantSparkline.tsx new file mode 100644 index 00000000000..03fbc00ab59 --- /dev/null +++ b/apps/web/src/components/host-stats/VariantSparkline.tsx @@ -0,0 +1,16 @@ +import { formatFooterGigabytes, hostStatsDetail, type HostStatsVariantProps } from "./types"; + +// Placeholder for the "Sparkline" redesign — replaced by its design agent. +export function VariantSparkline({ stats }: HostStatsVariantProps) { + const detail = hostStatsDetail(stats); + return ( +
+ Sparkline {Math.round(stats.cpuPercent)}% + {formatFooterGigabytes(stats.memUsedBytes)}G +
+ ); +} diff --git a/apps/web/src/components/host-stats/types.ts b/apps/web/src/components/host-stats/types.ts new file mode 100644 index 00000000000..2344a55e1c2 --- /dev/null +++ b/apps/web/src/components/host-stats/types.ts @@ -0,0 +1,31 @@ +import type { ServerHostStatsSnapshot } from "@t3tools/contracts"; + +import type { HostStatsSample } from "../../hooks/useHostStats"; + +/** + * Props every sidebar-footer host-stats variant receives. `stats` is the + * latest non-null snapshot; `history` is an oldest→newest rolling window of + * recent samples (~2 minutes at the 5s poll interval, including `stats`). + * + * Layout contract: the variant renders inline at the right edge of the + * sidebar-footer row, next to the Settings button. It must stay within + * ~150px wide and ~32px tall so it never stretches the row, and should + * carry its own `title`/`aria-label` detail text. + */ +export interface HostStatsVariantProps { + readonly stats: ServerHostStatsSnapshot; + readonly history: readonly HostStatsSample[]; +} + +/** Compact "3.2/15.6 GB" style figure for footer host-stats readouts. */ +export function formatFooterGigabytes(bytes: number): string { + const gigabytes = bytes / 1024 ** 3; + if (gigabytes >= 100) return String(Math.round(gigabytes)); + return gigabytes.toFixed(1); +} + +/** Shared hover/aria detail line so every variant reads the same to a screen reader. */ +export function hostStatsDetail(stats: ServerHostStatsSnapshot): string { + const coreLabel = stats.cpuCount === 1 ? "1 core" : `${stats.cpuCount} cores`; + return `Server load — CPU ${stats.cpuPercent.toFixed(1)}% of ${coreLabel} · memory ${formatFooterGigabytes(stats.memUsedBytes)} of ${formatFooterGigabytes(stats.memTotalBytes)} GB`; +} diff --git a/apps/web/src/components/host-stats/variants.ts b/apps/web/src/components/host-stats/variants.ts new file mode 100644 index 00000000000..117ebe2a28d --- /dev/null +++ b/apps/web/src/components/host-stats/variants.ts @@ -0,0 +1,33 @@ +import type { ComponentType } from "react"; +import type { SidebarHostStatsStyle } from "@t3tools/contracts/settings"; + +import type { HostStatsVariantProps } from "./types"; +import { VariantBadge } from "./VariantBadge"; +import { VariantBars } from "./VariantBars"; +import { VariantClassic } from "./VariantClassic"; +import { VariantEqualizer } from "./VariantEqualizer"; +import { VariantRings } from "./VariantRings"; +import { VariantSegments } from "./VariantSegments"; +import { VariantSignature } from "./VariantSignature"; +import { VariantSparkline } from "./VariantSparkline"; + +export interface HostStatsVariantEntry { + /** Human label for the Settings → Features style picker. */ + readonly label: string; + readonly Component: ComponentType; +} + +// Redesign candidates for the sidebar server-load readout, selectable from +// Settings → Features → Sidebar → "Server load style". +export const HOST_STATS_VARIANTS: Readonly< + Record +> = { + classic: { label: "Classic (plain text)", Component: VariantClassic }, + signature: { label: "Signature", Component: VariantSignature }, + bars: { label: "Bars", Component: VariantBars }, + sparkline: { label: "Sparkline", Component: VariantSparkline }, + segments: { label: "Segments", Component: VariantSegments }, + rings: { label: "Rings", Component: VariantRings }, + equalizer: { label: "Equalizer", Component: VariantEqualizer }, + badge: { label: "Badge", Component: VariantBadge }, +}; diff --git a/apps/web/src/components/settings/FeaturesSettings.tsx b/apps/web/src/components/settings/FeaturesSettings.tsx index 06006c9780c..bae3336a0f6 100644 --- a/apps/web/src/components/settings/FeaturesSettings.tsx +++ b/apps/web/src/components/settings/FeaturesSettings.tsx @@ -10,11 +10,15 @@ import { type ClientSettings, DEFAULT_HEADER_CONTROL_VISIBILITY, type HeaderControlVisibility, + type SidebarHostStatsStyle, } from "@t3tools/contracts/settings"; +import { HOST_STATS_VARIANTS } from "../host-stats/variants"; + import { useIsMobile } from "../../hooks/useMediaQuery"; import { useClientSettings, useUpdateClientSettings } from "../../hooks/useSettings"; import { resolveHeaderControlVisibility } from "../chat/ChatHeader"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; import { SettingResetButton, @@ -72,6 +76,7 @@ export function FeaturesSettingsPanel() { headerProjectScriptsVisibility: s.headerProjectScriptsVisibility, fileExplorerShowDotfiles: s.fileExplorerShowDotfiles, sidebarHostStatsVisible: s.sidebarHostStatsVisible, + sidebarHostStatsStyle: s.sidebarHostStatsStyle, })); const updateSettings = useUpdateClientSettings(); @@ -148,6 +153,29 @@ export function FeaturesSettingsPanel() { /> } /> + { + updateSettings({ sidebarHostStatsStyle: value as SidebarHostStatsStyle }); + }} + > + + {HOST_STATS_VARIANTS[settings.sidebarHostStatsStyle].label} + + + {Object.entries(HOST_STATS_VARIANTS).map(([value, entry]) => ( + + {entry.label} + + ))} + + + } + />

Visibility is stored per device. “Auto” shows a control on desktop-width screens and hides diff --git a/apps/web/src/hooks/useHostStats.ts b/apps/web/src/hooks/useHostStats.ts index 11ef2ee8774..c84b76104e0 100644 --- a/apps/web/src/hooks/useHostStats.ts +++ b/apps/web/src/hooks/useHostStats.ts @@ -1,5 +1,5 @@ -import type { ServerHostStatsResult } from "@t3tools/contracts"; -import { useEffect } from "react"; +import type { ServerHostStatsResult, ServerHostStatsSnapshot } from "@t3tools/contracts"; +import { useEffect, useRef, useState } from "react"; import { usePrimaryEnvironmentId } from "../state/environments"; import { useEnvironmentQuery } from "../state/query"; @@ -37,3 +37,52 @@ export function useHostStats(enabled: boolean): ServerHostStatsResult { return query.data; } + +/** One retained host-stats sample; `at` is the client receive time (ms epoch). */ +export interface HostStatsSample { + readonly at: number; + readonly cpuPercent: number; + readonly memUsedBytes: number; + readonly memTotalBytes: number; +} + +/** ~2 minutes of history at the 5s poll interval. */ +const HISTORY_LIMIT = 24; + +export interface HostStatsWithHistory { + readonly stats: ServerHostStatsSnapshot | null; + /** Oldest→newest rolling window of recent samples, including `stats`. */ + readonly history: readonly HostStatsSample[]; +} + +/** + * `useHostStats` plus a client-side rolling window of recent samples so + * richer readouts (sparklines, trend meters) have something to draw. The + * history resets when the readout is disabled. + */ +export function useHostStatsWithHistory(enabled: boolean): HostStatsWithHistory { + const stats = useHostStats(enabled); + const [history, setHistory] = useState([]); + const lastSampleRef = useRef(null); + + useEffect(() => { + if (!enabled) { + lastSampleRef.current = null; + setHistory([]); + return; + } + if (stats === null || stats === lastSampleRef.current) { + return; + } + lastSampleRef.current = stats; + const sample: HostStatsSample = { + at: Date.now(), + cpuPercent: stats.cpuPercent, + memUsedBytes: stats.memUsedBytes, + memTotalBytes: stats.memTotalBytes, + }; + setHistory((previous) => [...previous.slice(-(HISTORY_LIMIT - 1)), sample]); + }, [enabled, stats]); + + return { stats, history }; +} diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 980a2907bc6..40cb8d66bda 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -34,6 +34,21 @@ export const SidebarChatListView = Schema.Literals(["grouped", "flat"]); export type SidebarChatListView = typeof SidebarChatListView.Type; export const DEFAULT_SIDEBAR_CHAT_LIST_VIEW: SidebarChatListView = "grouped"; +// Visual style of the sidebar-footer server-load readout. "classic" is the +// original plain-text CPU/MEM line; the rest are richer redesign candidates. +export const SidebarHostStatsStyle = Schema.Literals([ + "classic", + "signature", + "bars", + "sparkline", + "segments", + "rings", + "equalizer", + "badge", +]); +export type SidebarHostStatsStyle = typeof SidebarHostStatsStyle.Type; +export const DEFAULT_SIDEBAR_HOST_STATS_STYLE: SidebarHostStatsStyle = "classic"; + export const SidebarProjectGroupingMode = Schema.Literals([ "repository", "repository_path", @@ -116,6 +131,9 @@ export const ClientSettingsSchema = Schema.Struct({ // Settings button in the sidebar footer. Default off; toggled per device // from the Features settings page. sidebarHostStatsVisible: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + sidebarHostStatsStyle: SidebarHostStatsStyle.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_HOST_STATS_STYLE)), + ), sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), ), @@ -617,6 +635,7 @@ export const ClientSettingsPatch = Schema.Struct({ ), sidebarChatListView: Schema.optionalKey(SidebarChatListView), sidebarHostStatsVisible: Schema.optionalKey(Schema.Boolean), + sidebarHostStatsStyle: Schema.optionalKey(SidebarHostStatsStyle), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), sidebarProjectGroupingOverrides: Schema.optionalKey( Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode), From 55bda1a01a692e390ebd39952e38e38c1f261160 Mon Sep 17 00:00:00 2001 From: Daniel Gordon Date: Tue, 21 Jul 2026 18:08:56 +0000 Subject: [PATCH 2/4] feat(web): 7 host-stats redesign candidates, one per design model Each variant was written by a different model against the same brief (bigger, colorful, load-ramped via success/warning/destructive tokens): - signature fable-5 dot-on-track precision faders + peak ticks - bars opus-4.8 stacked color-ramped meter bars - sparkline sonnet-5 CPU history sparkline + hero figure - segments gpt-5.6-sol 10-cell LED meters with peak afterglow - rings glm-5.2 dual donut gauges - equalizer kimi-k2.7-code 24-bar CPU history equalizer - badge gemini-3.1-pro tinted status pill with pulsing dot Also fixes the pre-existing DesktopClientSettings round-trip test broken by #34 (fixture was missing fileExplorerShowDotfiles). Co-Authored-By: Claude Fable 5 --- .../settings/DesktopClientSettings.test.ts | 1 + .../components/host-stats/VariantBadge.tsx | 44 ++++++- .../src/components/host-stats/VariantBars.tsx | 56 +++++++- .../host-stats/VariantEqualizer.tsx | 82 +++++++++++- .../components/host-stats/VariantRings.tsx | 86 +++++++++++- .../components/host-stats/VariantSegments.tsx | 71 +++++++++- .../host-stats/VariantSignature.tsx | 122 +++++++++++++++++- .../host-stats/VariantSparkline.tsx | 68 +++++++++- 8 files changed, 497 insertions(+), 33 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 806dfd2660c..81d33c5db70 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -19,6 +19,7 @@ const clientSettings: ClientSettings = { dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, favorites: [], + fileExplorerShowDotfiles: true, headerGitActionsVisibility: "auto", headerOpenInEditorVisibility: "auto", headerProjectScriptsVisibility: "auto", diff --git a/apps/web/src/components/host-stats/VariantBadge.tsx b/apps/web/src/components/host-stats/VariantBadge.tsx index d8d1d4f6ca6..faf878d608e 100644 --- a/apps/web/src/components/host-stats/VariantBadge.tsx +++ b/apps/web/src/components/host-stats/VariantBadge.tsx @@ -1,16 +1,50 @@ +import { cn } from "../../lib/utils"; import { formatFooterGigabytes, hostStatsDetail, type HostStatsVariantProps } from "./types"; -// Placeholder for the "Badge" redesign — replaced by its design agent. -export function VariantBadge({ stats }: HostStatsVariantProps) { +export function VariantBadge({ stats, history }: HostStatsVariantProps) { + void history; const detail = hostStatsDetail(stats); + + const cpu = Math.max(0, Math.min(100, stats.cpuPercent)); + const memPercent = + stats.memTotalBytes > 0 ? (stats.memUsedBytes / stats.memTotalBytes) * 100 : 0; + + const cpuStatus = cpu >= 85 ? "destructive" : cpu >= 60 ? "warning" : "success"; + const memStatus = memPercent >= 85 ? "destructive" : memPercent >= 60 ? "warning" : "success"; + + const statusPriority = { destructive: 3, warning: 2, success: 1 }; + const overallStatus = + statusPriority[cpuStatus] > statusPriority[memStatus] ? cpuStatus : memStatus; + + const containerColorClasses = { + success: "bg-success/10 border-success/20", + warning: "bg-warning/10 border-warning/20", + destructive: "bg-destructive/10 border-destructive/20", + }[overallStatus]; + + const dotColorClasses = { + success: "bg-success", + warning: "bg-warning animate-pulse", + destructive: "bg-destructive animate-pulse", + }[overallStatus]; + return (

- Badge {Math.round(stats.cpuPercent)}% - {formatFooterGigabytes(stats.memUsedBytes)}G +
+
+ CPU + {Math.round(cpu)}% + · + MEM + {formatFooterGigabytes(stats.memUsedBytes)}G +
); } diff --git a/apps/web/src/components/host-stats/VariantBars.tsx b/apps/web/src/components/host-stats/VariantBars.tsx index f3cf6c9828b..867f2037230 100644 --- a/apps/web/src/components/host-stats/VariantBars.tsx +++ b/apps/web/src/components/host-stats/VariantBars.tsx @@ -1,16 +1,64 @@ +import { cn } from "../../lib/utils"; import { formatFooterGigabytes, hostStatsDetail, type HostStatsVariantProps } from "./types"; -// Placeholder for the "Bars" redesign — replaced by its design agent. +/** Map a 0–100 load into the app's status tokens: <60 success, 60–85 warning, >85 destructive. */ +function loadTone(percent: number): { fill: string; text: string } { + if (percent > 85) return { fill: "bg-destructive", text: "text-destructive-foreground" }; + if (percent >= 60) return { fill: "bg-warning", text: "text-warning-foreground" }; + return { fill: "bg-success", text: "text-success-foreground" }; +} + +function clampPercent(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.min(100, Math.max(0, value)); +} + +interface MeterRowProps { + readonly label: string; + readonly percent: number; + readonly value: string; +} + +function MeterRow({ label, percent, value }: MeterRowProps) { + const tone = loadTone(percent); + return ( +
+ + {label} + + + + + {value} +
+ ); +} + +/** + * Two slim stacked meter bars — CPU on top, MEM below — with a color-ramped + * fill and a right-aligned tabular value, all on a shared three-column grid so + * tracks and figures line up. Fills glide on each 5s refresh. + */ export function VariantBars({ stats }: HostStatsVariantProps) { + const cpuPercent = clampPercent(stats.cpuPercent); + const memPercent = + stats.memTotalBytes > 0 ? clampPercent((stats.memUsedBytes / stats.memTotalBytes) * 100) : 0; + + const cpuValue = `${Math.round(cpuPercent)}%`; + const memValue = `${formatFooterGigabytes(stats.memUsedBytes)}/${formatFooterGigabytes(stats.memTotalBytes)}G`; const detail = hostStatsDetail(stats); + return (
- Bars {Math.round(stats.cpuPercent)}% - {formatFooterGigabytes(stats.memUsedBytes)}G + +
); } diff --git a/apps/web/src/components/host-stats/VariantEqualizer.tsx b/apps/web/src/components/host-stats/VariantEqualizer.tsx index 5a2a4bee347..cd99d0c2f7a 100644 --- a/apps/web/src/components/host-stats/VariantEqualizer.tsx +++ b/apps/web/src/components/host-stats/VariantEqualizer.tsx @@ -1,16 +1,88 @@ import { formatFooterGigabytes, hostStatsDetail, type HostStatsVariantProps } from "./types"; -// Placeholder for the "Equalizer" redesign — replaced by its design agent. -export function VariantEqualizer({ stats }: HostStatsVariantProps) { +import { cn } from "../../lib/utils"; + +const MAX_BARS = 24; +const BAR_WIDTH = 2; +const GAP = 1; +const TRACK_WIDTH = MAX_BARS * (BAR_WIDTH + GAP) - GAP; + +function clampCpu(value: number): number { + return Math.max(0, Math.min(100, value)); +} + +function loadTone(cpu: number): "success" | "warning" | "destructive" { + if (cpu > 85) return "destructive"; + if (cpu >= 60) return "warning"; + return "success"; +} + +export function VariantEqualizer({ stats, history }: HostStatsVariantProps) { const detail = hostStatsDetail(stats); + const currentCpu = clampCpu(stats.cpuPercent); + const currentTone = loadTone(currentCpu); + + const bars = + history.length > 0 + ? history + : [ + { + at: 0, + cpuPercent: stats.cpuPercent, + memUsedBytes: stats.memUsedBytes, + memTotalBytes: stats.memTotalBytes, + }, + ]; + return (
- Equalizer {Math.round(stats.cpuPercent)}% - {formatFooterGigabytes(stats.memUsedBytes)}G +
+ {Array.from({ length: MAX_BARS }, (_, slot) => { + const dataIndex = slot - (MAX_BARS - bars.length); + if (dataIndex < 0) { + return
; + } + const sample = bars[dataIndex]; + if (!sample) return null; + const cpu = clampCpu(sample.cpuPercent); + const tone = loadTone(cpu); + const opacity = bars.length === 1 ? 1 : 0.35 + (dataIndex / (bars.length - 1)) * 0.65; + return ( +
+ ); + })} +
+ +
+ + {Math.round(currentCpu)}% + + + {formatFooterGigabytes(stats.memUsedBytes)} + / + {formatFooterGigabytes(stats.memTotalBytes)}G + +
); } diff --git a/apps/web/src/components/host-stats/VariantRings.tsx b/apps/web/src/components/host-stats/VariantRings.tsx index 734010ae817..82aaa08f17a 100644 --- a/apps/web/src/components/host-stats/VariantRings.tsx +++ b/apps/web/src/components/host-stats/VariantRings.tsx @@ -1,16 +1,94 @@ import { formatFooterGigabytes, hostStatsDetail, type HostStatsVariantProps } from "./types"; +import { cn } from "../../lib/utils"; + +// Two mini donut gauges (CPU + MEM): muted track circle with a round-capped +// progress arc color-ramped by load, starting at 12 o'clock, with the value +// and a tiny label beside it. Refreshes every 5s; the arc glides via a +// stroke-dashoffset transition. +const RING_RADIUS = 9; +const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS; + +function loadColorClass(pct: number): string { + if (pct >= 85) return "text-destructive"; + if (pct >= 60) return "text-warning"; + return "text-success"; +} + +interface RingGaugeProps { + readonly pct: number; + readonly colorClass: string; + readonly value: string; + readonly label: string; +} + +function RingGauge({ pct, colorClass, value, label }: RingGaugeProps) { + const offset = RING_CIRCUMFERENCE * (1 - pct / 100); + return ( +
+ +
+ {value} + + {label} + +
+
+ ); +} -// Placeholder for the "Rings" redesign — replaced by its design agent. export function VariantRings({ stats }: HostStatsVariantProps) { const detail = hostStatsDetail(stats); + const cpuPct = Math.min(100, Math.max(0, stats.cpuPercent)); + const memPct = + stats.memTotalBytes > 0 + ? Math.min(100, Math.max(0, (stats.memUsedBytes / stats.memTotalBytes) * 100)) + : 0; + return (
- Rings {Math.round(stats.cpuPercent)}% - {formatFooterGigabytes(stats.memUsedBytes)}G + +
); } diff --git a/apps/web/src/components/host-stats/VariantSegments.tsx b/apps/web/src/components/host-stats/VariantSegments.tsx index 5ef2c78249d..012acf4c4b6 100644 --- a/apps/web/src/components/host-stats/VariantSegments.tsx +++ b/apps/web/src/components/host-stats/VariantSegments.tsx @@ -1,16 +1,77 @@ +import { cn } from "../../lib/utils"; import { formatFooterGigabytes, hostStatsDetail, type HostStatsVariantProps } from "./types"; -// Placeholder for the "Segments" redesign — replaced by its design agent. -export function VariantSegments({ stats }: HostStatsVariantProps) { +const SEGMENT_COUNT = 10; +const SEGMENTS = Array.from({ length: SEGMENT_COUNT }, (_, index) => index); + +function clampPercent(value: number): number { + return Math.min(100, Math.max(0, Number.isFinite(value) ? value : 0)); +} + +function loadColor(percent: number): string { + if (percent > 85) return "bg-destructive"; + if (percent >= 60) return "bg-warning"; + return "bg-success"; +} + +interface SegmentMeterProps { + readonly percent: number; + readonly peakPercent: number; +} + +function SegmentMeter({ percent, peakPercent }: SegmentMeterProps) { + const litCount = Math.ceil((clampPercent(percent) / 100) * SEGMENT_COUNT); + const peakCount = Math.ceil((clampPercent(peakPercent) / 100) * SEGMENT_COUNT); + const color = loadColor(percent); + + return ( +