diff --git a/frontend/app/view/hostinfo/hostinfo-model.ts b/frontend/app/view/hostinfo/hostinfo-model.ts index 6c293616b2..d39e44e0ed 100644 --- a/frontend/app/view/hostinfo/hostinfo-model.ts +++ b/frontend/app/view/hostinfo/hostinfo-model.ts @@ -19,6 +19,30 @@ export type HostInfoEnv = WaveEnvSubset<{ getBlockMetaKeyAtom: MetaKeyAtomFnType<"connection">; }>; +export const RailDefaultWidth = 192; +export const RailMinWidth = 120; +export const RailMaxWidth = 360; +const RailPrefsKey = "hostinfo:rail"; + +type RailPrefs = { collapsed: boolean; width: number }; + +// shared by every Host Inspector block; localStorage can be unavailable or hold junk, so fall back to defaults +function loadRailPrefs(): RailPrefs { + const prefs = { collapsed: false, width: RailDefaultWidth }; + try { + const raw = JSON.parse(localStorage.getItem(RailPrefsKey)); + if (typeof raw?.collapsed === "boolean") prefs.collapsed = raw.collapsed; + if (typeof raw?.width === "number") prefs.width = Math.min(RailMaxWidth, Math.max(RailMinWidth, raw.width)); + } catch (_) {} + return prefs; +} + +function saveRailPrefs(prefs: RailPrefs) { + try { + localStorage.setItem(RailPrefsKey, JSON.stringify(prefs)); + } catch (_) {} +} + // how often each backend section refreshes while its tab is on screen (system also feeds the vitals strip) const RefreshMs: Record = { system: 5000, @@ -71,6 +95,8 @@ export class HostInfoViewModel implements ViewModel { errorAtom = jotai.atom(null) as jotai.PrimitiveAtom; loadingAtom = jotai.atom(true) as jotai.PrimitiveAtom; pausedAtom = jotai.atom(false) as jotai.PrimitiveAtom; + railCollapsedAtom: jotai.PrimitiveAtom; + railWidthAtom: jotai.PrimitiveAtom; dockerStatsAtom = jotai.atom(false) as jotai.PrimitiveAtom; updatedAtAtom = jotai.atom>({}) as jotai.PrimitiveAtom>; actionAtom = jotai.atom(null) as jotai.PrimitiveAtom; @@ -90,6 +116,9 @@ export class HostInfoViewModel implements ViewModel { this.viewType = "hostinfo"; this.blockId = blockId; this.env = waveEnv; + const railPrefs = loadRailPrefs(); + this.railCollapsedAtom = jotai.atom(railPrefs.collapsed) as jotai.PrimitiveAtom; + this.railWidthAtom = jotai.atom(railPrefs.width) as jotai.PrimitiveAtom; this.connection = jotai.atom((get) => { const connValue = get(this.env.getBlockMetaKeyAtom(blockId, "connection")); @@ -229,6 +258,17 @@ export class HostInfoViewModel implements ViewModel { this.fetch(["system", backendSection(sec)], false); } + setRailCollapsed(collapsed: boolean) { + globalStore.set(this.railCollapsedAtom, collapsed); + saveRailPrefs({ collapsed, width: globalStore.get(this.railWidthAtom) }); + } + + setRailWidth(width: number) { + const next = Math.min(RailMaxWidth, Math.max(RailMinWidth, width)); + globalStore.set(this.railWidthAtom, next); + saveRailPrefs({ collapsed: globalStore.get(this.railCollapsedAtom), width: next }); + } + setSection(id: HostSectionId) { globalStore.set(this.sectionAtom, id); globalStore.set(this.actionAtom, null); diff --git a/frontend/app/view/hostinfo/hostinfo.tsx b/frontend/app/view/hostinfo/hostinfo.tsx index 2092010426..378e260e84 100644 --- a/frontend/app/view/hostinfo/hostinfo.tsx +++ b/frontend/app/view/hostinfo/hostinfo.tsx @@ -2,10 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 import { ScrollArea } from "@/app/element/scrollarea"; +import { Tooltip } from "@/app/element/tooltip"; +import { globalStore } from "@/app/store/jotaiStore"; import { cn } from "@/util/util"; import { useAtomValue } from "jotai"; import { memo } from "react"; -import type { HostInfoViewModel } from "./hostinfo-model"; +import { RailDefaultWidth, RailMaxWidth, RailMinWidth, type HostInfoViewModel } from "./hostinfo-model"; import { DockerSection, NetworkSection, @@ -17,6 +19,8 @@ import { } from "./hostinfo-sections"; import { changeCount, fmtDuration, groupPorts, HostChanges, HostSectionId, HostSections, pct } from "./hostinfo-util"; +const RailCollapsedWidth = 44; + type Badge = { text: string; tone: "muted" | "crit" | "warn" }; function sectionBadge(id: HostSectionId, data: HostInfoData): Badge { @@ -108,50 +112,102 @@ const VitalsStrip = memo(({ model, data }: { model: HostInfoViewModel; data: Hos }); VitalsStrip.displayName = "VitalsStrip"; +const RailResizeHandle = memo(({ model }: { model: HostInfoViewModel }) => { + const onPointerDown = (e: React.PointerEvent) => { + e.preventDefault(); + const startX = e.clientX; + const startWidth = globalStore.get(model.railWidthAtom); + const onMove = (ev: PointerEvent) => { + const next = Math.min(RailMaxWidth, Math.max(RailMinWidth, startWidth + ev.clientX - startX)); + model.setRailWidth(next); + }; + const onUp = () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + }; + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + }; + return ( +
model.setRailWidth(RailDefaultWidth)} + className="absolute -right-1 top-0 z-10 h-full w-2 cursor-col-resize transition-colors hover:bg-accent/40" + /> + ); +}); +RailResizeHandle.displayName = "RailResizeHandle"; + const SectionRail = memo(({ model, data }: { model: HostInfoViewModel; data: HostInfoData }) => { const active = useAtomValue(model.sectionAtom); const changes = useAtomValue(model.changesAtom); + const collapsed = useAtomValue(model.railCollapsedAtom); + const width = useAtomValue(model.railWidthAtom); + const toggle = () => model.setRailCollapsed(!collapsed); return ( -