Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions frontend/app/view/hostinfo/hostinfo-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number> = {
system: 5000,
Expand Down Expand Up @@ -71,6 +95,8 @@ export class HostInfoViewModel implements ViewModel {
errorAtom = jotai.atom<string>(null) as jotai.PrimitiveAtom<string>;
loadingAtom = jotai.atom<boolean>(true) as jotai.PrimitiveAtom<boolean>;
pausedAtom = jotai.atom<boolean>(false) as jotai.PrimitiveAtom<boolean>;
railCollapsedAtom: jotai.PrimitiveAtom<boolean>;
railWidthAtom: jotai.PrimitiveAtom<number>;
dockerStatsAtom = jotai.atom<boolean>(false) as jotai.PrimitiveAtom<boolean>;
updatedAtAtom = jotai.atom<Record<string, number>>({}) as jotai.PrimitiveAtom<Record<string, number>>;
actionAtom = jotai.atom<HostActionState>(null) as jotai.PrimitiveAtom<HostActionState>;
Expand All @@ -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<boolean>;
this.railWidthAtom = jotai.atom(railPrefs.width) as jotai.PrimitiveAtom<number>;

this.connection = jotai.atom((get) => {
const connValue = get(this.env.getBlockMetaKeyAtom(blockId, "connection"));
Expand Down Expand Up @@ -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);
Expand Down
126 changes: 91 additions & 35 deletions frontend/app/view/hostinfo/hostinfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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<HTMLDivElement>) => {
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 (
<div
onPointerDown={onPointerDown}
onDoubleClick={() => 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 (
<nav className="flex w-48 shrink-0 flex-col gap-0.5 border-r border-border p-1.5">
<nav
className="relative flex shrink-0 flex-col gap-0.5 border-r border-border p-1.5"
style={{ width: collapsed ? RailCollapsedWidth : width }}
>
<Tooltip content={collapsed ? "Show sidebar" : "Hide sidebar labels"} placement="right">
<button
onClick={toggle}
className={cn(
"flex w-full cursor-pointer items-center rounded px-2 py-1 text-xs text-muted transition-colors hover:bg-hoverbg hover:text-primary",
collapsed ? "justify-center" : "justify-end"
)}
>
<i className={`fa-solid fa-angles-${collapsed ? "right" : "left"} fa-fw`} />
</button>
</Tooltip>
{HostSections.map((s) => {
const badge = data ? sectionBadge(s.id, data) : null;
const changed = sectionChanged(s.id, changes);
const tip = [s.label, badge?.text, changed ? "changed since baseline" : null]
.filter((t) => t != null)
.join(" · ");
return (
<button
key={s.id}
onClick={() => model.setSection(s.id)}
className={cn(
"flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors",
active === s.id
? "bg-accent/20 text-primary"
: "text-secondary hover:bg-hoverbg hover:text-primary"
)}
>
<i className={`fa-solid fa-${s.icon} fa-fw text-muted`} />
<span className="flex-1 truncate">{s.label}</span>
{changed && (
<span
className="h-1.5 w-1.5 shrink-0 rounded-full bg-accent"
title="Changed since baseline"
/>
)}
{badge && (
<span
className={cn(
"shrink-0 tabular-nums text-[11px]",
badge.tone === "crit"
? "text-error"
: badge.tone === "warn"
? "text-warning"
: "text-muted"
)}
>
{badge.text}
</span>
)}
</button>
<Tooltip key={s.id} content={tip} placement="right" disable={!collapsed}>
<button
onClick={() => model.setSection(s.id)}
className={cn(
"relative flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors",
collapsed && "justify-center",
active === s.id
? "bg-accent/20 text-primary"
: "text-secondary hover:bg-hoverbg hover:text-primary"
)}
>
<i className={`fa-solid fa-${s.icon} fa-fw text-muted`} />
{!collapsed && <span className="flex-1 truncate">{s.label}</span>}
{changed && (
<span
className={cn(
"h-1.5 w-1.5 shrink-0 rounded-full bg-accent",
collapsed && "absolute right-1 top-1"
)}
title={collapsed ? undefined : "Changed since baseline"}
/>
)}
{badge && !collapsed && (
<span
className={cn(
"shrink-0 tabular-nums text-[11px]",
badge.tone === "crit"
? "text-error"
: badge.tone === "warn"
? "text-warning"
: "text-muted"
)}
>
{badge.text}
</span>
)}
</button>
</Tooltip>
);
})}
{!collapsed && <RailResizeHandle model={model} />}
</nav>
);
});
Expand Down