perf: frontend request cache, poll pause, and code-split shell (#1052)

Host/status caching, shell code-split, SSH pool waits, host-metrics concurrency, background-tab idle, per-host status subscriptions, homepage poll quieting, and virtualized host sidebar + file manager lists.
This commit is contained in:
ZacharyZcR
2026-07-14 13:58:28 +08:00
committed by GitHub
parent 6e66a5a4ef
commit 867058bddd
53 changed files with 2663 additions and 831 deletions
+27 -2
View File
@@ -171,18 +171,43 @@ export function AppRail({
useEffect(() => {
let cancelled = false;
let intervalId: ReturnType<typeof setInterval> | null = null;
const poll = () => {
if (document.visibilityState === "hidden") return;
getAlertFirings({ acknowledged: false, limit: 50 })
.then((firings) => {
if (!cancelled) setUnreadAlerts(firings.length);
})
.catch(() => {});
};
const start = () => {
if (intervalId !== null) return;
intervalId = setInterval(poll, 30000);
};
const stop = () => {
if (intervalId === null) return;
clearInterval(intervalId);
intervalId = null;
};
const onVisibility = () => {
if (document.visibilityState === "hidden") {
stop();
return;
}
poll();
start();
};
poll();
const iv = setInterval(poll, 30000);
if (document.visibilityState !== "hidden") start();
document.addEventListener("visibilitychange", onVisibility);
return () => {
cancelled = true;
clearInterval(iv);
stop();
document.removeEventListener("visibilitychange", onVisibility);
};
}, []);
const [hiddenTabs, setHiddenTabs] = useState<Set<string>>(() => {
+30 -13
View File
@@ -17,6 +17,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/tooltip";
import { usePageVisibleInterval } from "@/hooks/use-page-visible-interval";
const CONNECTION_TAB_TYPES: TabType[] = [
"terminal",
@@ -49,6 +50,28 @@ function formatDuration(ms: number): string {
return `${h}h ${m % 60}m`;
}
function sessionsUnchanged(
prev: ActiveSessionInfo[],
next: ActiveSessionInfo[],
): boolean {
if (prev.length !== next.length) return false;
for (let i = 0; i < prev.length; i++) {
const a = prev[i];
const b = next[i];
if (
a.sessionId !== b.sessionId ||
a.hostId !== b.hostId ||
a.hostName !== b.hostName ||
a.tabInstanceId !== b.tabInstanceId ||
a.isConnected !== b.isConnected ||
a.createdAt !== b.createdAt
) {
return false;
}
}
return true;
}
function formatExpiry(updatedAt: string): string {
const TTL_MS = 30 * 60 * 1000;
const elapsed = Date.now() - new Date(updatedAt).getTime();
@@ -285,7 +308,6 @@ export function ConnectionsPanel({
const [now, setNow] = useState(Date.now());
const [activeSessions, setActiveSessions] = useState<ActiveSessionInfo[]>([]);
const [search, setSearch] = useState("");
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Drag-to-reorder state
const [dragTabId, setDragTabId] = useState<string | null>(null);
@@ -322,17 +344,15 @@ export function ConnectionsPanel({
})
: backgroundTabs;
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, []);
// Duration labels only need minute-level freshness; 1s ticks re-render the whole panel.
usePageVisibleInterval(() => setNow(Date.now()), 15_000);
const refresh = useCallback(async () => {
try {
const sessions = await getActiveSessions();
setActiveSessions((prev) => {
const next = Array.isArray(sessions) ? sessions : [];
if (JSON.stringify(prev) === JSON.stringify(next)) return prev;
if (sessionsUnchanged(prev, next)) return prev;
return next;
});
} catch {
@@ -340,13 +360,10 @@ export function ConnectionsPanel({
}
}, []);
useEffect(() => {
refresh();
pollTimerRef.current = setInterval(refresh, 5000);
return () => {
if (pollTimerRef.current) clearInterval(pollTimerRef.current);
};
}, [refresh]);
// Initial fetch + visibility-aware poll (hook fires once on mount).
usePageVisibleInterval(() => {
void refresh();
}, 5_000);
// Global pointer listeners for drag reorder
useEffect(() => {
+19 -3
View File
@@ -292,9 +292,25 @@ export function HostsPanel({
}
useEffect(() => {
getSSHHosts()
.then(setRawHosts)
.catch(() => {});
let cancelled = false;
const load = () => {
getSSHHosts()
.then((hosts) => {
if (!cancelled) setRawHosts(hosts);
})
.catch(() => {});
};
load();
const onChanged = () => load();
window.addEventListener("ssh-hosts:changed", onChanged);
window.addEventListener("hosts:refresh", onChanged);
window.addEventListener("termix:hosts-changed", onChanged);
return () => {
cancelled = true;
window.removeEventListener("ssh-hosts:changed", onChanged);
window.removeEventListener("hosts:refresh", onChanged);
window.removeEventListener("termix:hosts-changed", onChanged);
};
}, []);
function handleEditingChange(editing: boolean) {
+14 -5
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { copyToClipboard } from "@/lib/clipboard";
import { usePageVisibleInterval } from "@/hooks/use-page-visible-interval";
import { Input } from "@/components/input";
import {
Tooltip,
@@ -190,10 +191,11 @@ export function SessionLogsPanel() {
.then((fresh) => {
setLogs((prev) => {
if (
JSON.stringify(prev.map((l) => l.id)) ===
JSON.stringify(fresh.map((l) => l.id))
)
prev.length === fresh.length &&
prev.every((log, i) => log.id === fresh[i]?.id)
) {
return prev;
}
return fresh;
});
})
@@ -212,10 +214,17 @@ export function SessionLogsPanel() {
getSessionRecordingRetention()
.then(setRetentionDays)
.catch(() => setRetentionDays(null));
const interval = setInterval(() => load(false), 5000);
return () => clearInterval(interval);
}, [load]);
usePageVisibleInterval(
() => {
void load(false);
},
5_000,
true,
{ runOnMount: false },
);
const q = filter.trim().toLowerCase();
const filtered = q
? logs.filter((l) =>
+147 -71
View File
@@ -1,6 +1,13 @@
/* eslint-disable react-refresh/only-export-components */
import { useState, useEffect, type MouseEvent } from "react";
import {
useState,
useEffect,
useRef,
useLayoutEffect,
type MouseEvent,
} from "react";
import { useTranslation } from "react-i18next";
import { useVirtualizer } from "@tanstack/react-virtual";
import {
Box,
Boxes,
@@ -62,7 +69,11 @@ import {
useStatusColorScheme,
getStatusClasses,
} from "@/hooks/use-status-color-scheme";
import { useServerStatus } from "@/lib/ServerStatusContext";
import {
useHostStatus,
useServerStatus,
useServerStatusMeta,
} from "@/lib/ServerStatusContext";
import {
Tooltip,
TooltipContent,
@@ -271,6 +282,7 @@ export function HostItem({
onTrayOpenChange,
onDragStart,
onDragEnd,
depth = 0,
}: {
host: Host;
onOpenTab: (type: TabType) => void;
@@ -290,6 +302,8 @@ export function HostItem({
onProxmoxDiscover?: () => void;
onDragStart?: () => void;
onDragEnd?: () => void;
/** Nesting level when rendered in a flattened virtual list. */
depth?: number;
}) {
const { t } = useTranslation();
const metricsEnabled =
@@ -305,10 +319,11 @@ export function HostItem({
() => localStorage.getItem("compactHostView") === "true",
);
const statusScheme = useStatusColorScheme();
const { initialLoadComplete, getStatus } = useServerStatus();
const { initialLoadComplete } = useServerStatusMeta();
const statusCheckOn = statusCheckEnabled(host);
const statusLoading = !initialLoadComplete && statusCheckOn;
const liveStatus = statusCheckOn ? getStatus(Number(host.id)) : null;
// Per-host subscription — status polls only re-render rows that flipped.
const liveStatus = useHostStatus(Number(host.id), statusCheckOn);
const isOnline = liveStatus != null ? liveStatus === "online" : host.online;
const isTouchOnly =
typeof window !== "undefined" && window.matchMedia("(hover: none)").matches;
@@ -372,6 +387,9 @@ export function HostItem({
if (query && !hostMatchesQuery(host, query)) return null;
const depthStyle =
depth > 0 ? ({ paddingLeft: depth * 12 } as const) : undefined;
if (compactHostView) {
return (
<div
@@ -381,6 +399,7 @@ export function HostItem({
onDragStart?.();
}}
onDragEnd={() => onDragEnd?.()}
style={depthStyle}
className={`group relative flex items-stretch cursor-pointer select-none transition-colors hover:bg-muted/40 ${
selected
? "bg-accent-brand/5"
@@ -882,6 +901,7 @@ export function HostItem({
onDragStart?.();
}}
onDragEnd={() => onDragEnd?.()}
style={depthStyle}
className={`group relative flex items-stretch cursor-pointer select-none transition-colors hover:bg-muted/40 ${
selected
? "bg-accent-brand/5"
@@ -1483,6 +1503,9 @@ export function FolderItem({
draggedHostIds,
onDragHostStart,
onDragEnd,
/** When true, only render the folder header (children come from the virtual list). */
flat = false,
stripeIndex: stripeIndexProp,
}: {
folder: HostFolder;
depth?: number;
@@ -1493,7 +1516,7 @@ export function FolderItem({
onDuplicateHost: (host: Host) => void;
onProxmoxDiscover?: (host: Host) => void;
query?: string;
stripeMap: Map<Host | HostFolder, number>;
stripeMap?: Map<Host | HostFolder, number>;
openFolders: Set<string>;
onToggleFolder: (name: string) => void;
selectionMode: boolean;
@@ -1510,6 +1533,8 @@ export function FolderItem({
draggedHostIds: string[] | null;
onDragHostStart: (hostId: string) => void;
onDragEnd: () => void;
flat?: boolean;
stripeIndex?: number;
}) {
const { t } = useTranslation();
const { getStatus, initialLoadComplete } = useServerStatus();
@@ -1525,13 +1550,14 @@ export function FolderItem({
const folderPath = folder.path ?? folder.name;
const isOpen = query ? true : openFolders.has(folderPath);
const stripeIndex = stripeMap.get(folder) ?? 0;
const stripeIndex = stripeIndexProp ?? stripeMap?.get(folder) ?? 0;
// Synthetic group headers (group-by tag/status/etc.) are not real folders, so
// they can't be edited, deleted, or used as drop targets.
const isGroup = folderPath.startsWith("__group__:");
return (
<div
style={depth > 0 ? { paddingLeft: depth * 12 } : undefined}
onDragOver={(e) => {
if (draggedHostIds && !isGroup) {
e.preventDefault();
@@ -1611,7 +1637,7 @@ export function FolderItem({
</>
}
</button>
{isOpen && (
{!flat && isOpen && (
<div className="border-l border-border/40 ml-[30px]">
{folder.children.map((child, i) =>
isFolder(child) ? (
@@ -1657,7 +1683,7 @@ export function FolderItem({
onDelete={() => onDeleteHost(child)}
onDuplicate={() => onDuplicateHost(child)}
query={query}
stripeIndex={stripeMap.get(child) ?? 0}
stripeIndex={stripeMap?.get(child) ?? 0}
selectionMode={selectionMode}
selected={selectedHostIds.has(child.id)}
onToggleSelect={() => onToggleSelect(child.id)}
@@ -1961,9 +1987,33 @@ export function SidebarTree({
const allFolderPaths = collectAllFolderPaths(children);
const visibleRows = collectVisibleRows(children, query, openFolders);
const stripeMap = new Map<Host | HostFolder, number>(
visibleRows.map((r, i) => [r.item, i]),
);
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: visibleRows.length,
getScrollElement: () => parentRef.current,
estimateSize: (index) => {
const row = visibleRows[index];
if (!row) return 36;
if (isFolder(row.item)) return 36;
// Expanded action tray is taller than a single host row.
if (openTrayHostId === row.item.id) return 88;
return 40;
},
overscan: 12,
getItemKey: (index) => {
const row = visibleRows[index];
if (!row) return index;
return isFolder(row.item)
? `folder:${row.item.path ?? row.item.name}`
: `host:${row.item.id}`;
},
});
// Remeasure when tray open state or tree shape changes (variable row heights).
useLayoutEffect(() => {
virtualizer.measure();
}, [virtualizer, openTrayHostId, openFolders, query, visibleRows.length]);
if (loading) {
return (
@@ -1993,6 +2043,7 @@ export function SidebarTree({
return (
<div className="relative flex flex-col flex-1 min-h-0">
<div
ref={parentRef}
className={`flex-1 min-h-0 overflow-y-auto ${rootDragOver ? "ring-1 ring-inset ring-accent-brand/50" : ""}`}
onDragOver={(e) => {
if (draggedHostIds) {
@@ -2019,66 +2070,91 @@ export function SidebarTree({
</span>
</div>
) : (
children.map((child, i) =>
isFolder(child) ? (
<FolderItem
key={i}
folder={child}
onOpenTab={onOpenTab}
onEditHost={onEditHost}
onShareHost={onShareHost}
onDeleteHost={handleDeleteHost}
onDuplicateHost={handleDuplicateHost}
onProxmoxDiscover={onProxmoxDiscover}
query={query}
stripeMap={stripeMap}
openFolders={openFolders}
onToggleFolder={toggleFolder}
selectionMode={selectionMode}
selectedHostIds={selectedHostIds}
onToggleSelect={toggleSelect}
openMenuHostId={openMenuHostId}
onMenuOpenChange={setOpenMenuHostId}
openTrayHostId={openTrayHostId}
onTrayOpenChange={setOpenTrayHostId}
onManageFolder={handleManageFolder}
onDeleteFolder={handleDeleteFolder}
onOpenAllSessions={handleOpenAllSessions}
onMoveHostsToFolder={handleMoveHostsToFolder}
draggedHostIds={draggedHostIds}
onDragHostStart={handleDragHostStart}
onDragEnd={() => setDraggedHostIds(null)}
/>
) : (
<HostItem
key={i}
host={child}
onOpenTab={(type) => onOpenTab(child, type)}
onEditHost={() => onEditHost(child)}
onShareHost={onShareHost ? () => onShareHost(child) : undefined}
onProxmoxDiscover={
onProxmoxDiscover ? () => onProxmoxDiscover(child) : undefined
}
onDelete={() => handleDeleteHost(child)}
onDuplicate={() => handleDuplicateHost(child)}
query={query}
stripeIndex={stripeMap.get(child) ?? 0}
selectionMode={selectionMode}
selected={selectedHostIds.has(child.id)}
onToggleSelect={() => toggleSelect(child.id)}
isMenuOpen={openMenuHostId === child.id}
onMenuOpenChange={(open) =>
setOpenMenuHostId(open ? child.id : null)
}
isTrayOpen={openTrayHostId === child.id}
onTrayOpenChange={(open) =>
setOpenTrayHostId(open ? child.id : null)
}
onDragStart={() => handleDragHostStart(child.id)}
onDragEnd={() => setDraggedHostIds(null)}
/>
),
)
<div
className="relative w-full"
style={{ height: virtualizer.getTotalSize() }}
>
{virtualizer.getVirtualItems().map((vItem) => {
const row = visibleRows[vItem.index];
if (!row) return null;
const { item, depth } = row;
return (
<div
key={vItem.key}
data-index={vItem.index}
ref={virtualizer.measureElement}
className="absolute top-0 left-0 w-full"
style={{
transform: `translateY(${vItem.start}px)`,
}}
>
{isFolder(item) ? (
<FolderItem
folder={item}
depth={depth}
flat
onOpenTab={onOpenTab}
onEditHost={onEditHost}
onShareHost={onShareHost}
onDeleteHost={handleDeleteHost}
onDuplicateHost={handleDuplicateHost}
onProxmoxDiscover={onProxmoxDiscover}
query={query}
openFolders={openFolders}
onToggleFolder={toggleFolder}
selectionMode={selectionMode}
selectedHostIds={selectedHostIds}
onToggleSelect={toggleSelect}
openMenuHostId={openMenuHostId}
onMenuOpenChange={setOpenMenuHostId}
openTrayHostId={openTrayHostId}
onTrayOpenChange={setOpenTrayHostId}
onManageFolder={handleManageFolder}
onDeleteFolder={handleDeleteFolder}
onOpenAllSessions={handleOpenAllSessions}
onMoveHostsToFolder={handleMoveHostsToFolder}
draggedHostIds={draggedHostIds}
onDragHostStart={handleDragHostStart}
onDragEnd={() => setDraggedHostIds(null)}
stripeIndex={vItem.index}
/>
) : (
<HostItem
host={item}
depth={depth}
onOpenTab={(type) => onOpenTab(item, type)}
onEditHost={() => onEditHost(item)}
onShareHost={
onShareHost ? () => onShareHost(item) : undefined
}
onProxmoxDiscover={
onProxmoxDiscover
? () => onProxmoxDiscover(item)
: undefined
}
onDelete={() => handleDeleteHost(item)}
onDuplicate={() => handleDuplicateHost(item)}
query={query}
stripeIndex={vItem.index}
selectionMode={selectionMode}
selected={selectedHostIds.has(item.id)}
onToggleSelect={() => toggleSelect(item.id)}
isMenuOpen={openMenuHostId === item.id}
onMenuOpenChange={(open) =>
setOpenMenuHostId(open ? item.id : null)
}
isTrayOpen={openTrayHostId === item.id}
onTrayOpenChange={(open) =>
setOpenTrayHostId(open ? item.id : null)
}
onDragStart={() => handleDragHostStart(item.id)}
onDragEnd={() => setDraggedHostIds(null)}
/>
)}
</div>
);
})}
</div>
)}
</div>
@@ -0,0 +1,129 @@
import { describe, expect, it } from "vitest";
import type { Host, HostFolder } from "@/types/ui-types";
// Mirror of SidebarTree collectVisibleRows for unit coverage without exporting
// the full React module graph.
function isFolder(item: Host | HostFolder): item is HostFolder {
return "children" in item;
}
function hostMatchesQuery(host: Host, query: string): boolean {
const q = query.toLowerCase();
return (
host.name.toLowerCase().includes(q) ||
host.ip.toLowerCase().includes(q) ||
host.username.toLowerCase().includes(q)
);
}
function folderHasMatch(folder: HostFolder, query: string): boolean {
if (folder.name.toLowerCase().includes(query.toLowerCase())) return true;
for (const child of folder.children) {
if (isFolder(child)) {
if (folderHasMatch(child, query)) return true;
} else if (hostMatchesQuery(child, query)) {
return true;
}
}
return false;
}
type VirtualRow = { item: Host | HostFolder; depth: number };
function collectVisibleRows(
children: (Host | HostFolder)[],
query: string,
openSet: Set<string>,
out: VirtualRow[] = [],
depth = 0,
): VirtualRow[] {
for (const child of children) {
if (isFolder(child)) {
const visible = query ? folderHasMatch(child, query) : true;
if (!visible) continue;
out.push({ item: child, depth });
const childOpen = query ? true : openSet.has(child.path ?? child.name);
if (childOpen)
collectVisibleRows(child.children, query, openSet, out, depth + 1);
} else {
if (!query || hostMatchesQuery(child, query))
out.push({ item: child, depth });
}
}
return out;
}
function host(id: string, name: string): Host {
return {
id,
name,
username: "u",
ip: "10.0.0." + id,
port: 22,
folder: "",
online: true,
cpu: null,
ram: null,
lastAccess: "",
tags: [],
authType: "password",
pin: false,
enableSsh: true,
enableTerminal: true,
enableTunnel: false,
enableFileManager: true,
enableDocker: false,
enableRdp: false,
enableVnc: false,
enableTelnet: false,
quickActions: [],
} as Host;
}
describe("collectVisibleRows", () => {
const tree: (Host | HostFolder)[] = [
host("1", "root-host"),
{
name: "prod",
path: "prod",
children: [
host("2", "web"),
{
name: "db",
path: "prod / db",
children: [host("3", "postgres")],
},
],
},
];
it("collapses closed folders", () => {
const rows = collectVisibleRows(tree, "", new Set());
expect(
rows.map((r) => (isFolder(r.item) ? r.item.name : r.item.name)),
).toEqual(["root-host", "prod"]);
});
it("expands open folders with depth", () => {
const rows = collectVisibleRows(tree, "", new Set(["prod", "prod / db"]));
expect(
rows.map((r) => ({
name: isFolder(r.item) ? r.item.name : r.item.name,
depth: r.depth,
})),
).toEqual([
{ name: "root-host", depth: 0 },
{ name: "prod", depth: 0 },
{ name: "web", depth: 1 },
{ name: "db", depth: 1 },
{ name: "postgres", depth: 2 },
]);
});
it("opens all matching folders when searching", () => {
const rows = collectVisibleRows(tree, "postgres", new Set());
expect(
rows.map((r) => (isFolder(r.item) ? r.item.name : r.item.name)),
).toEqual(["prod", "db", "postgres"]);
});
});