mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-17 05:43:36 +00:00
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:
@@ -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>>(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user