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
+28
View File
@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.2", "@simplewebauthn/server": "^13.3.2",
"@tanstack/react-virtual": "^3.14.6",
"@types/ldapjs": "^3.0.6", "@types/ldapjs": "^3.0.6",
"axios": "^1.18.1", "axios": "^1.18.1",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
@@ -6694,6 +6695,33 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8" "vite": "^5.2.0 || ^6 || ^7 || ^8"
} }
}, },
"node_modules/@tanstack/react-virtual": {
"version": "3.14.6",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.6.tgz",
"integrity": "sha512-4+Uq8m0/gzO4kMCHUEpTtGX1RnONK0C+g88b2ltwPMWUBiaVarBuWKoPJaz7gj1cKCVRAdyu+U8GcKhwCc2beA==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.17.4"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.17.4",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.4.tgz",
"integrity": "sha512-nGm5KteqxasUdThLc2izl6dHUqLv0LQj7Nuyo5gYalTPf/U8a9ermvsl7reT+6ioBW1l8WfpP/mcU338nLXpqw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@testing-library/dom": { "node_modules/@testing-library/dom": {
"version": "10.4.1", "version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+1
View File
@@ -45,6 +45,7 @@
"dependencies": { "dependencies": {
"@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.2", "@simplewebauthn/server": "^13.3.2",
"@tanstack/react-virtual": "^3.14.6",
"@types/ldapjs": "^3.0.6", "@types/ldapjs": "^3.0.6",
"axios": "^1.18.1", "axios": "^1.18.1",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
@@ -0,0 +1,81 @@
import { describe, expect, it, vi } from "vitest";
import { ConcurrentLimiter, HostPollCache } from "./host-metrics-state.js";
describe("ConcurrentLimiter", () => {
it("never exceeds max concurrent runners", async () => {
const limiter = new ConcurrentLimiter(2);
let peak = 0;
let current = 0;
const job = async () => {
current += 1;
peak = Math.max(peak, current);
await new Promise((r) => setTimeout(r, 30));
current -= 1;
};
await Promise.all([
limiter.run(job),
limiter.run(job),
limiter.run(job),
limiter.run(job),
]);
expect(peak).toBeLessThanOrEqual(2);
expect(limiter.activeCount).toBe(0);
expect(limiter.pendingCount).toBe(0);
});
it("runs waiters in FIFO order after a slot frees", async () => {
const limiter = new ConcurrentLimiter(1);
const order: number[] = [];
const first = limiter.run(async () => {
order.push(1);
await new Promise((r) => setTimeout(r, 20));
});
const second = limiter.run(async () => {
order.push(2);
});
const third = limiter.run(async () => {
order.push(3);
});
await Promise.all([first, second, third]);
expect(order).toEqual([1, 2, 3]);
});
it("rejects invalid maxConcurrent", () => {
expect(() => new ConcurrentLimiter(0)).toThrow(/maxConcurrent/);
});
});
describe("HostPollCache", () => {
it("returns cached host within TTL for the same user", () => {
const cache = new HostPollCache<{ id: number; name: string }>(60_000);
cache.set(1, "user-a", { id: 1, name: "alpha" });
expect(cache.get(1, "user-a")).toEqual({ id: 1, name: "alpha" });
expect(cache.get(1, "user-b")).toBeNull();
});
it("expires entries after TTL", () => {
vi.useFakeTimers();
const cache = new HostPollCache<{ id: number }>(1_000);
cache.set(7, "u", { id: 7 });
expect(cache.get(7, "u")).toEqual({ id: 7 });
vi.advanceTimersByTime(1_001);
expect(cache.get(7, "u")).toBeNull();
vi.useRealTimers();
});
it("invalidate drops a host or the whole cache", () => {
const cache = new HostPollCache<{ id: number }>(60_000);
cache.set(1, "u", { id: 1 });
cache.set(2, "u", { id: 2 });
cache.invalidate(1);
expect(cache.get(1, "u")).toBeNull();
expect(cache.get(2, "u")).toEqual({ id: 2 });
cache.invalidate();
expect(cache.get(2, "u")).toBeNull();
});
});
+82
View File
@@ -250,6 +250,88 @@ class PollingBackoff {
} }
} }
/**
* Limits how many async jobs run at once. Extra callers wait in FIFO order.
* Used to stop host-metrics status/metrics polls from stampeding under load.
*/
export class ConcurrentLimiter {
private active = 0;
private readonly waiters: Array<() => void> = [];
constructor(private readonly maxConcurrent: number) {
if (maxConcurrent < 1) {
throw new Error("maxConcurrent must be >= 1");
}
}
get activeCount(): number {
return this.active;
}
get pendingCount(): number {
return this.waiters.length;
}
async run<T>(fn: () => Promise<T>): Promise<T> {
if (this.active >= this.maxConcurrent) {
await new Promise<void>((resolve) => {
this.waiters.push(resolve);
});
}
this.active += 1;
try {
return await fn();
} finally {
this.active -= 1;
const next = this.waiters.shift();
if (next) next();
}
}
}
/** Short-lived host snapshots for polling — avoids decrypting host rows every tick. */
export class HostPollCache<THost extends { id: number } = { id: number }> {
private cache = new Map<
number,
{ host: THost; userId: string; expiresAt: number }
>();
constructor(private readonly ttlMs = 30_000) {}
get(hostId: number, userId: string): THost | null {
const entry = this.cache.get(hostId);
if (!entry) return null;
if (entry.userId !== userId || Date.now() >= entry.expiresAt) {
this.cache.delete(hostId);
return null;
}
return entry.host;
}
set(hostId: number, userId: string, host: THost): void {
this.cache.set(hostId, {
host,
userId,
expiresAt: Date.now() + this.ttlMs,
});
}
invalidate(hostId?: number): void {
if (hostId === undefined) {
this.cache.clear();
return;
}
this.cache.delete(hostId);
}
}
/** TCP status checks are cheap relative to SSH metrics collection. */
export const statusPollLimiter = new ConcurrentLimiter(20);
/** SSH metrics execs are expensive; keep concurrency tight. */
export const metricsPollLimiter = new ConcurrentLimiter(5);
export const hostPollCache = new HostPollCache(30_000);
export const requestQueue = new RequestQueue(); export const requestQueue = new RequestQueue();
export const metricsCache = new MetricsCache(); export const metricsCache = new MetricsCache();
export const authFailureTracker = new AuthFailureTracker(); export const authFailureTracker = new AuthFailureTracker();
+115 -14
View File
@@ -62,9 +62,12 @@ import {
} from "./host-metrics-sessions.js"; } from "./host-metrics-sessions.js";
import { import {
authFailureTracker, authFailureTracker,
hostPollCache,
metricsCache, metricsCache,
metricsPollLimiter,
pollingBackoff, pollingBackoff,
requestQueue, requestQueue,
statusPollLimiter,
} from "./host-metrics-state.js"; } from "./host-metrics-state.js";
const authManager = AuthManager.getInstance(); const authManager = AuthManager.getInstance();
@@ -169,6 +172,9 @@ class PollingManager {
private activeViewers = new Map<number, Set<string>>(); private activeViewers = new Map<number, Set<string>>();
private viewerDetails = new Map<string, MetricsViewer>(); private viewerDetails = new Map<string, MetricsViewer>();
private viewerCleanupInterval: NodeJS.Timeout; private viewerCleanupInterval: NodeJS.Timeout;
/** Skip stacking another status/metrics poll while one is already running. */
private statusInFlight = new Set<number>();
private metricsInFlight = new Set<number>();
constructor() { constructor() {
this.viewerCleanupInterval = setInterval(() => { this.viewerCleanupInterval = setInterval(() => {
@@ -176,6 +182,81 @@ class PollingManager {
}, 60000); }, 60000);
} }
/** Spread timers so N hosts do not fire on the same wall-clock second. */
private intervalWithJitter(intervalMs: number, hostId: number): number {
const spread = Math.min(intervalMs * 0.2, 15_000);
const jitter = (hostId * 1103515245) % Math.max(1, Math.floor(spread));
return intervalMs + jitter;
}
private async resolveHostForPoll(
host: SSHHostWithCredentials,
userId: string,
): Promise<SSHHostWithCredentials | null> {
const cached = hostPollCache.get(
host.id,
userId,
) as SSHHostWithCredentials | null;
if (cached) {
return cached;
}
const refreshed = await fetchHostById(host.id, userId);
if (!refreshed) {
return null;
}
hostPollCache.set(host.id, userId, refreshed);
const config = this.pollingConfigs.get(host.id);
if (config) {
config.host = refreshed;
config.statsConfig = this.parseStatsConfig(refreshed.statsConfig);
}
return refreshed;
}
private scheduleStatusPoll(
host: SSHHostWithCredentials,
viewerUserId?: string,
): void {
if (this.statusInFlight.has(host.id)) {
return;
}
this.statusInFlight.add(host.id);
void statusPollLimiter
.run(() => this.pollHostStatus(host, viewerUserId))
.catch((err) => {
statsLogger.error("Status polling failed", err, {
operation: "status_poll_unhandled",
hostId: host.id,
});
})
.finally(() => {
this.statusInFlight.delete(host.id);
});
}
private scheduleMetricsPoll(
host: SSHHostWithCredentials,
viewerUserId?: string,
): void {
if (this.metricsInFlight.has(host.id)) {
return;
}
this.metricsInFlight.add(host.id);
void metricsPollLimiter
.run(() => this.pollHostMetrics(host, viewerUserId))
.catch((err) => {
statsLogger.error("Metrics polling failed", err, {
operation: "metrics_poll_unhandled",
hostId: host.id,
});
})
.finally(() => {
this.metricsInFlight.delete(host.id);
});
}
private getGlobalDefaults(): { private getGlobalDefaults(): {
statusCheckInterval: number; statusCheckInterval: number;
metricsInterval: number; metricsInterval: number;
@@ -309,14 +390,17 @@ class PollingManager {
this.pollingConfigs.set(host.id, config); this.pollingConfigs.set(host.id, config);
if (isTcpPingEnabled(statsConfig)) { if (isTcpPingEnabled(statsConfig)) {
const intervalMs = statsConfig.statusCheckInterval * 1000; const intervalMs = this.intervalWithJitter(
statsConfig.statusCheckInterval * 1000,
host.id,
);
this.pollHostStatus(host, viewerUserId); this.scheduleStatusPoll(host, viewerUserId);
config.statusTimer = setInterval(() => { config.statusTimer = setInterval(() => {
const latestConfig = this.pollingConfigs.get(host.id); const latestConfig = this.pollingConfigs.get(host.id);
if (latestConfig && isTcpPingEnabled(latestConfig.statsConfig)) { if (latestConfig && isTcpPingEnabled(latestConfig.statsConfig)) {
this.pollHostStatus(latestConfig.host, latestConfig.viewerUserId); this.scheduleStatusPoll(latestConfig.host, latestConfig.viewerUserId);
} }
}, intervalMs); }, intervalMs);
} else { } else {
@@ -324,9 +408,27 @@ class PollingManager {
} }
if (!statusOnly && statsConfig.metricsEnabled && canCollectMetrics) { if (!statusOnly && statsConfig.metricsEnabled && canCollectMetrics) {
const intervalMs = statsConfig.metricsInterval * 1000; const intervalMs = this.intervalWithJitter(
statsConfig.metricsInterval * 1000,
host.id,
);
await this.pollHostMetrics(host, viewerUserId); // First sample still awaited (gated) so callers can rely on a warm cache.
if (!this.metricsInFlight.has(host.id)) {
this.metricsInFlight.add(host.id);
try {
await metricsPollLimiter.run(() =>
this.pollHostMetrics(host, viewerUserId),
);
} catch (err) {
statsLogger.error("Metrics polling failed", err, {
operation: "metrics_poll_unhandled",
hostId: host.id,
});
} finally {
this.metricsInFlight.delete(host.id);
}
}
config.metricsTimer = setInterval(() => { config.metricsTimer = setInterval(() => {
const latestConfig = this.pollingConfigs.get(host.id); const latestConfig = this.pollingConfigs.get(host.id);
@@ -335,15 +437,10 @@ class PollingManager {
latestConfig.statsConfig.metricsEnabled && latestConfig.statsConfig.metricsEnabled &&
supportsMetrics(latestConfig.host) supportsMetrics(latestConfig.host)
) { ) {
this.pollHostMetrics( this.scheduleMetricsPoll(
latestConfig.host, latestConfig.host,
latestConfig.viewerUserId, latestConfig.viewerUserId,
).catch((err) => { );
statsLogger.error("Metrics polling failed", err, {
operation: "metrics_poll_unhandled",
hostId: host.id,
});
});
} }
}, intervalMs); }, intervalMs);
} else { } else {
@@ -356,7 +453,7 @@ class PollingManager {
viewerUserId?: string, viewerUserId?: string,
): Promise<void> { ): Promise<void> {
const userId = viewerUserId || host.userId; const userId = viewerUserId || host.userId;
const refreshedHost = await fetchHostById(host.id, userId); const refreshedHost = await this.resolveHostForPoll(host, userId);
if (!refreshedHost) { if (!refreshedHost) {
return; return;
} }
@@ -426,7 +523,7 @@ class PollingManager {
viewerUserId?: string, viewerUserId?: string,
): Promise<void> { ): Promise<void> {
const userId = viewerUserId || host.userId; const userId = viewerUserId || host.userId;
const refreshedHost = await fetchHostById(host.id, userId); const refreshedHost = await this.resolveHostForPoll(host, userId);
if (!refreshedHost) { if (!refreshedHost) {
return; return;
} }
@@ -581,6 +678,9 @@ class PollingManager {
this.metricsStore.delete(hostId); this.metricsStore.delete(hostId);
} }
} }
hostPollCache.invalidate(hostId);
this.statusInFlight.delete(hostId);
this.metricsInFlight.delete(hostId);
} }
stopMetricsOnly(hostId: number): void { stopMetricsOnly(hostId: number): void {
@@ -1843,6 +1943,7 @@ app.post("/host-updated", async (req, res) => {
} }
try { try {
hostPollCache.invalidate(hostId);
const host = await fetchHostById(hostId, userId); const host = await fetchHostById(hostId, userId);
if (host) { if (host) {
connectionPool.clearKeyConnections(getPoolKey(host)); connectionPool.clearKeyConnections(getPoolKey(host));
+135 -60
View File
@@ -8,18 +8,29 @@ interface PooledConnection {
hostKey: string; hostKey: string;
} }
interface ConnectionWaiter {
resolve: (client: Client) => void;
reject: (error: Error) => void;
factory: () => Promise<Client>;
timer: NodeJS.Timeout;
}
const DEFAULT_MAX_CONNECTIONS_PER_HOST = 3;
const DEFAULT_MAX_WAIT_MS = 30_000;
const IDLE_MAX_AGE_MS = 10 * 60 * 1000;
const CLEANUP_INTERVAL_MS = 2 * 60 * 1000;
class SSHConnectionPool { class SSHConnectionPool {
private connections = new Map<string, PooledConnection[]>(); private connections = new Map<string, PooledConnection[]>();
private maxConnectionsPerHost = 3; private waiters = new Map<string, ConnectionWaiter[]>();
private maxConnectionsPerHost = DEFAULT_MAX_CONNECTIONS_PER_HOST;
private maxWaitMs = DEFAULT_MAX_WAIT_MS;
private cleanupInterval: NodeJS.Timeout; private cleanupInterval: NodeJS.Timeout;
constructor() { constructor() {
this.cleanupInterval = setInterval( this.cleanupInterval = setInterval(() => {
() => {
this.cleanup(); this.cleanup();
}, }, CLEANUP_INTERVAL_MS);
2 * 60 * 1000,
);
} }
private isConnectionHealthy(client: Client): boolean { private isConnectionHealthy(client: Client): boolean {
@@ -38,34 +49,30 @@ class SSHConnectionPool {
} }
} }
async getConnection( private removeUnhealthy(
key: string, key: string,
factory: () => Promise<Client>, connections: PooledConnection[],
): Promise<Client> { target: PooledConnection,
let connections = this.connections.get(key) || []; ): PooledConnection[] {
const available = connections.find((conn) => !conn.inUse);
if (available) {
if (!this.isConnectionHealthy(available.client)) {
sshLogger.warn("Removing unhealthy connection from pool", { sshLogger.warn("Removing unhealthy connection from pool", {
operation: "pool_remove_dead", operation: "pool_remove_dead",
hostKey: key, hostKey: key,
}); });
try { try {
available.client.end(); target.client.end();
} catch { } catch {
// expected // expected
} }
connections = connections.filter((c) => c !== available); const filtered = connections.filter((c) => c !== target);
this.connections.set(key, connections); this.connections.set(key, filtered);
} else { return filtered;
available.inUse = true;
available.lastUsed = Date.now();
return available.client;
}
} }
if (connections.length < this.maxConnectionsPerHost) { private async createPooledClient(
key: string,
factory: () => Promise<Client>,
existing: PooledConnection[],
): Promise<Client> {
const client = await factory(); const client = await factory();
const pooled: PooledConnection = { const pooled: PooledConnection = {
client, client,
@@ -73,8 +80,8 @@ class SSHConnectionPool {
inUse: true, inUse: true,
hostKey: key, hostKey: key,
}; };
connections.push(pooled); existing.push(pooled);
this.connections.set(key, connections); this.connections.set(key, existing);
client.on("end", () => { client.on("end", () => {
this.removeConnection(key, client); this.removeConnection(key, client);
@@ -86,51 +93,113 @@ class SSHConnectionPool {
return client; return client;
} }
return new Promise((resolve) => { private enqueueWaiter(
const checkAvailable = () => { key: string,
const conns = this.connections.get(key) || []; factory: () => Promise<Client>,
const avail = conns.find((conn) => !conn.inUse); ): Promise<Client> {
if (avail) { return new Promise<Client>((resolve, reject) => {
if (!this.isConnectionHealthy(avail.client)) { const timer = setTimeout(() => {
try { const queue = this.waiters.get(key);
avail.client.end(); if (queue) {
} catch { const idx = queue.findIndex((w) => w.timer === timer);
// expected if (idx >= 0) queue.splice(idx, 1);
if (queue.length === 0) this.waiters.delete(key);
} }
const filtered = conns.filter((c) => c !== avail); const err = new Error(
this.connections.set(key, filtered); `SSH connection pool wait timed out after ${this.maxWaitMs}ms (${key})`,
factory().then((client) => { );
const pooled: PooledConnection = { sshLogger.warn("Connection pool wait timeout", {
client, operation: "pool_wait_timeout",
lastUsed: Date.now(),
inUse: true,
hostKey: key, hostKey: key,
}; maxWaitMs: this.maxWaitMs,
filtered.push(pooled);
this.connections.set(key, filtered);
client.on("end", () => this.removeConnection(key, client));
client.on("close", () => this.removeConnection(key, client));
resolve(client);
}); });
} else { reject(err);
avail.inUse = true; }, this.maxWaitMs);
avail.lastUsed = Date.now();
resolve(avail.client); const waiter: ConnectionWaiter = { resolve, reject, factory, timer };
} const queue = this.waiters.get(key) || [];
} else { queue.push(waiter);
setTimeout(checkAvailable, 100); this.waiters.set(key, queue);
}
};
checkAvailable();
}); });
} }
/** Hand a free connection (or new slot) to the next waiter, if any. */
private wakeWaiter(key: string): void {
const queue = this.waiters.get(key);
if (!queue || queue.length === 0) return;
let connections = this.connections.get(key) || [];
const available = connections.find((conn) => !conn.inUse);
if (available) {
if (!this.isConnectionHealthy(available.client)) {
connections = this.removeUnhealthy(key, connections, available);
// Fall through — may create or wait again.
} else {
const waiter = queue.shift()!;
if (queue.length === 0) this.waiters.delete(key);
else this.waiters.set(key, queue);
clearTimeout(waiter.timer);
available.inUse = true;
available.lastUsed = Date.now();
waiter.resolve(available.client);
return;
}
}
if (connections.length < this.maxConnectionsPerHost) {
const waiter = queue.shift()!;
if (queue.length === 0) this.waiters.delete(key);
else this.waiters.set(key, queue);
clearTimeout(waiter.timer);
this.createPooledClient(key, waiter.factory, connections)
.then(waiter.resolve)
.catch(waiter.reject);
return;
}
}
private rejectWaiters(key: string, reason: string): void {
const queue = this.waiters.get(key);
if (!queue) return;
this.waiters.delete(key);
for (const waiter of queue) {
clearTimeout(waiter.timer);
waiter.reject(new Error(reason));
}
}
async getConnection(
key: string,
factory: () => Promise<Client>,
): Promise<Client> {
let connections = this.connections.get(key) || [];
const available = connections.find((conn) => !conn.inUse);
if (available) {
if (!this.isConnectionHealthy(available.client)) {
connections = this.removeUnhealthy(key, connections, available);
} else {
available.inUse = true;
available.lastUsed = Date.now();
return available.client;
}
}
if (connections.length < this.maxConnectionsPerHost) {
return this.createPooledClient(key, factory, connections);
}
return this.enqueueWaiter(key, factory);
}
releaseConnection(key: string, client: Client): void { releaseConnection(key: string, client: Client): void {
const connections = this.connections.get(key) || []; const connections = this.connections.get(key) || [];
const pooled = connections.find((conn) => conn.client === client); const pooled = connections.find((conn) => conn.client === client);
if (pooled) { if (pooled) {
pooled.inUse = false; pooled.inUse = false;
pooled.lastUsed = Date.now(); pooled.lastUsed = Date.now();
this.wakeWaiter(key);
} }
} }
@@ -143,6 +212,8 @@ class SSHConnectionPool {
} else { } else {
this.connections.set(key, filtered); this.connections.set(key, filtered);
} }
// A slot or idle connection may now be free for waiters.
this.wakeWaiter(key);
} }
clearKeyConnections(key: string): void { clearKeyConnections(key: string): void {
@@ -155,15 +226,15 @@ class SSHConnectionPool {
} }
} }
this.connections.delete(key); this.connections.delete(key);
this.rejectWaiters(key, `SSH connection pool cleared for ${key}`);
} }
private cleanup(): void { private cleanup(): void {
const now = Date.now(); const now = Date.now();
const maxAge = 10 * 60 * 1000;
for (const [hostKey, connections] of this.connections.entries()) { for (const [hostKey, connections] of this.connections.entries()) {
const activeConnections = connections.filter((conn) => { const activeConnections = connections.filter((conn) => {
if (!conn.inUse && now - conn.lastUsed > maxAge) { if (!conn.inUse && now - conn.lastUsed > IDLE_MAX_AGE_MS) {
try { try {
conn.client.end(); conn.client.end();
} catch { } catch {
@@ -187,10 +258,14 @@ class SSHConnectionPool {
} else { } else {
this.connections.set(hostKey, activeConnections); this.connections.set(hostKey, activeConnections);
} }
this.wakeWaiter(hostKey);
} }
} }
clearAllConnections(): void { clearAllConnections(): void {
for (const key of [...this.waiters.keys()]) {
this.rejectWaiters(key, "SSH connection pool destroyed");
}
for (const connections of this.connections.values()) { for (const connections of this.connections.values()) {
for (const conn of connections) { for (const conn of connections) {
try { try {
+110 -20
View File
@@ -6,30 +6,101 @@ import { Separator } from "@/components/separator";
import { Button } from "@/components/button"; import { Button } from "@/components/button";
import { Sheet, SheetContent } from "@/components/sheet"; import { Sheet, SheetContent } from "@/components/sheet";
import { ChevronLeft, ChevronRight, Maximize2 } from "lucide-react"; import { ChevronLeft, ChevronRight, Maximize2 } from "lucide-react";
import { useState, useRef, useCallback, useEffect, createRef } from "react"; import {
useState,
useRef,
useCallback,
useEffect,
createRef,
lazy,
Suspense,
} from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useIsMobile } from "@/hooks/use-mobile"; import { useIsMobile } from "@/hooks/use-mobile";
import { MobileBottomBar } from "@/shell/MobileBottomBar"; import { MobileBottomBar } from "@/shell/MobileBottomBar";
import { CommandPalette } from "@/shell/CommandPalette";
import { AppRail } from "@/sidebar/AppRail"; import { AppRail } from "@/sidebar/AppRail";
import type { RailView } from "@/sidebar/AppRail"; import type { RailView } from "@/sidebar/AppRail";
import { HostsPanel } from "@/sidebar/HostsPanel";
import { QuickConnectPanel } from "@/sidebar/QuickConnectPanel";
import { SerialPanel } from "@/sidebar/SerialPanel";
import { SshToolsPanel } from "@/sidebar/SshToolsPanel";
import { SnippetsPanel } from "@/sidebar/SnippetsPanel";
import { HistoryPanel } from "@/sidebar/HistoryPanel";
import { SessionLogsPanel } from "@/sidebar/SessionLogsPanel";
import { SplitScreenPanel } from "@/sidebar/SplitScreenPanel";
import { UserProfilePanel } from "@/sidebar/UserProfilePanel";
import { AdminSettingsPanel } from "@/sidebar/AdminSettingsPanel";
import { AlertsPanel } from "@/sidebar/AlertsPanel";
import { CredentialsPanel } from "@/sidebar/CredentialsPanel";
import { TermixIdPanel } from "@/sidebar/TermixIdPanel";
import { SplitView } from "@/shell/SplitView"; import { SplitView } from "@/shell/SplitView";
import { renderTabContent } from "@/shell/tabUtils"; import { renderTabContent } from "@/shell/tabUtils";
import { AlertManager } from "@/dashboard/panels/alerts/AlertManager";
import { TabBar } from "@/shell/TabBar"; import { TabBar } from "@/shell/TabBar";
// Shell surfaces that are not needed for first paint.
const CommandPalette = lazy(() =>
import("@/shell/CommandPalette").then((m) => ({
default: m.CommandPalette,
})),
);
const HostsPanel = lazy(() =>
import("@/sidebar/HostsPanel").then((m) => ({ default: m.HostsPanel })),
);
const QuickConnectPanel = lazy(() =>
import("@/sidebar/QuickConnectPanel").then((m) => ({
default: m.QuickConnectPanel,
})),
);
const SerialPanel = lazy(() =>
import("@/sidebar/SerialPanel").then((m) => ({ default: m.SerialPanel })),
);
const SplitScreenPanel = lazy(() =>
import("@/sidebar/SplitScreenPanel").then((m) => ({
default: m.SplitScreenPanel,
})),
);
const AlertManager = lazy(() =>
import("@/dashboard/panels/alerts/AlertManager").then((m) => ({
default: m.AlertManager,
})),
);
// Secondary rail panels — load on first open, not with the shell critical path.
const SshToolsPanel = lazy(() =>
import("@/sidebar/SshToolsPanel").then((m) => ({ default: m.SshToolsPanel })),
);
const SnippetsPanel = lazy(() =>
import("@/sidebar/SnippetsPanel").then((m) => ({ default: m.SnippetsPanel })),
);
const HistoryPanel = lazy(() =>
import("@/sidebar/HistoryPanel").then((m) => ({ default: m.HistoryPanel })),
);
const SessionLogsPanel = lazy(() =>
import("@/sidebar/SessionLogsPanel").then((m) => ({
default: m.SessionLogsPanel,
})),
);
const UserProfilePanel = lazy(() =>
import("@/sidebar/UserProfilePanel").then((m) => ({
default: m.UserProfilePanel,
})),
);
const AdminSettingsPanel = lazy(() =>
import("@/sidebar/AdminSettingsPanel").then((m) => ({
default: m.AdminSettingsPanel,
})),
);
const AlertsPanel = lazy(() =>
import("@/sidebar/AlertsPanel").then((m) => ({ default: m.AlertsPanel })),
);
const CredentialsPanel = lazy(() =>
import("@/sidebar/CredentialsPanel").then((m) => ({
default: m.CredentialsPanel,
})),
);
const TermixIdPanel = lazy(() =>
import("@/sidebar/TermixIdPanel").then((m) => ({ default: m.TermixIdPanel })),
);
const ConnectionsPanel = lazy(() =>
import("@/sidebar/ConnectionsPanel").then((m) => ({
default: m.ConnectionsPanel,
})),
);
function SidebarPanelFallback() {
return (
<div className="flex flex-1 items-center justify-center p-6">
<div className="size-5 rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground/70 animate-spin" />
</div>
);
}
import type { import type {
Tab, Tab,
TabType, TabType,
@@ -59,7 +130,6 @@ import {
import { dbHealthMonitor } from "@/lib/db-health-monitor"; import { dbHealthMonitor } from "@/lib/db-health-monitor";
import type { SSHHostWithStatus } from "@/main-axios"; import type { SSHHostWithStatus } from "@/main-axios";
import { ServerStatusProvider } from "@/lib/ServerStatusContext"; import { ServerStatusProvider } from "@/lib/ServerStatusContext";
import { ConnectionsPanel } from "@/sidebar/ConnectionsPanel";
import { TransferMonitor } from "@/features/file-manager/TransferMonitor.tsx"; import { TransferMonitor } from "@/features/file-manager/TransferMonitor.tsx";
import { sshHostToHost } from "@/sidebar/HostManagerData"; import { sshHostToHost } from "@/sidebar/HostManagerData";
import { resolveHostTabType } from "@/lib/host-connection-tabs"; import { resolveHostTabType } from "@/lib/host-connection-tabs";
@@ -738,8 +808,17 @@ export function AppShell({
}, [loadHosts]); }, [loadHosts]);
useEffect(() => { useEffect(() => {
window.addEventListener("termix:hosts-changed", loadHosts); const onHostsChanged = () => {
return () => window.removeEventListener("termix:hosts-changed", loadHosts); void loadHosts();
};
window.addEventListener("termix:hosts-changed", onHostsChanged);
window.addEventListener("ssh-hosts:changed", onHostsChanged);
window.addEventListener("hosts:refresh", onHostsChanged);
return () => {
window.removeEventListener("termix:hosts-changed", onHostsChanged);
window.removeEventListener("ssh-hosts:changed", onHostsChanged);
window.removeEventListener("hosts:refresh", onHostsChanged);
};
}, [loadHosts]); }, [loadHosts]);
// Sync tab host data when allHosts updates (e.g. after editing terminal theme in host settings) // Sync tab host data when allHosts updates (e.g. after editing terminal theme in host settings)
@@ -1437,6 +1516,7 @@ export function AppShell({
// Sidebar panel content — shared between desktop inline sidebar and mobile sheet // Sidebar panel content — shared between desktop inline sidebar and mobile sheet
const sidebarPanelContent = ( const sidebarPanelContent = (
<Suspense fallback={<SidebarPanelFallback />}>
<div className="flex flex-col flex-1 min-h-0 overflow-hidden"> <div className="flex flex-col flex-1 min-h-0 overflow-hidden">
<div <div
className={`flex flex-col flex-1 min-h-0 ${railView === "hosts" ? "" : "hidden"}`} className={`flex flex-col flex-1 min-h-0 ${railView === "hosts" ? "" : "hidden"}`}
@@ -1507,7 +1587,10 @@ export function AppShell({
{railView === "history" && ( {railView === "history" && (
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto"> <div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
<HistoryPanel terminalTabs={terminalTabs} activeTabId={activeTabId} /> <HistoryPanel
terminalTabs={terminalTabs}
activeTabId={activeTabId}
/>
</div> </div>
)} )}
@@ -1600,6 +1683,7 @@ export function AppShell({
</div> </div>
)} )}
</div> </div>
</Suspense>
); );
// Sidebar header — shared // Sidebar header — shared
@@ -1812,6 +1896,8 @@ export function AppShell({
</div> </div>
</div> </div>
{commandPaletteOpen && (
<Suspense fallback={null}>
<CommandPalette <CommandPalette
isOpen={commandPaletteOpen} isOpen={commandPaletteOpen}
setIsOpen={setCommandPaletteOpen} setIsOpen={setCommandPaletteOpen}
@@ -1839,8 +1925,12 @@ export function AppShell({
} }
}} }}
/> />
</Suspense>
)}
<TransferMonitor /> <TransferMonitor />
<Suspense fallback={null}>
<AlertManager userId={userId} loggedIn={!!username} /> <AlertManager userId={userId} loggedIn={!!username} />
</Suspense>
</ServerStatusProvider> </ServerStatusProvider>
); );
} }
+4
View File
@@ -1,6 +1,7 @@
import axios, { type AxiosRequestConfig } from "axios"; import axios, { type AxiosRequestConfig } from "axios";
import { handleApiError, statsApi } from "@/main-axios"; import { handleApiError, statsApi } from "@/main-axios";
import type { ServerMetrics, ServerStatus } from "@/main-axios"; import type { ServerMetrics, ServerStatus } from "@/main-axios";
import { getCachedServerStatuses } from "@/lib/hosts-request-cache";
type ApiConnectionLog = { type ApiConnectionLog = {
type: "info" | "success" | "warning" | "error"; type: "info" | "success" | "warning" | "error";
@@ -73,6 +74,7 @@ function isTransientStatusError(error: unknown): boolean {
export async function getAllServerStatuses(): Promise< export async function getAllServerStatuses(): Promise<
Record<number, ServerStatus> Record<number, ServerStatus>
> { > {
return getCachedServerStatuses(async () => {
let lastError: unknown = null; let lastError: unknown = null;
for (let i = 0; i < STATUS_RETRY_SCHEDULE.length; i++) { for (let i = 0; i < STATUS_RETRY_SCHEDULE.length; i++) {
@@ -101,6 +103,8 @@ export async function getAllServerStatuses(): Promise<
} }
handleApiError(lastError, "fetch server statuses"); handleApiError(lastError, "fetch server statuses");
return {};
});
} }
export async function getServerStatusById(id: number): Promise<ServerStatus> { export async function getServerStatusById(id: number): Promise<ServerStatus> {
+6 -1
View File
@@ -1,4 +1,5 @@
import { authApi } from "@/main-axios"; import { authApi } from "@/main-axios";
import { createTtlRequestCache } from "@/lib/ttl-request-cache";
// OPEN TABS API // OPEN TABS API
// ============================================================================ // ============================================================================
@@ -42,6 +43,8 @@ export interface ActiveSessionInfo {
createdAt: number; createdAt: number;
} }
const activeSessionsCache = createTtlRequestCache<ActiveSessionInfo[]>(2_000);
export async function getOpenTabs(): Promise<OpenTabRecord[]> { export async function getOpenTabs(): Promise<OpenTabRecord[]> {
const response = await authApi.get("/open-tabs"); const response = await authApi.get("/open-tabs");
return response.data; return response.data;
@@ -69,8 +72,10 @@ export async function addOpenTab(tab: OpenTabUpsertPayload): Promise<void> {
} }
export async function getActiveSessions(): Promise<ActiveSessionInfo[]> { export async function getActiveSessions(): Promise<ActiveSessionInfo[]> {
return activeSessionsCache.get(async () => {
const response = await authApi.get("/open-tabs/active-sessions"); const response = await authApi.get("/open-tabs/active-sessions");
return response.data; return Array.isArray(response.data) ? response.data : [];
});
} }
// ============================================================================ // ============================================================================
+35 -5
View File
@@ -8,16 +8,38 @@ import {
import type { SSHHost, SSHHostData, ProxyNode } from "@/types/index"; import type { SSHHost, SSHHostData, ProxyNode } from "@/types/index";
import type { ServerStatus, SSHHostWithStatus } from "@/main-axios"; import type { ServerStatus, SSHHostWithStatus } from "@/main-axios";
import type { ProxmoxDiscoverResult } from "@/types/proxmox"; import type { ProxmoxDiscoverResult } from "@/types/proxmox";
import {
getCachedSSHHosts,
invalidateHostsAndStatusCaches,
} from "@/lib/hosts-request-cache";
// SSH HOST MANAGEMENT // SSH HOST MANAGEMENT
// ============================================================================ // ============================================================================
export async function getSSHHosts(): Promise<SSHHostWithStatus[]> { export type GetSSHHostsOptions = {
try { /** When false, skip the status service call (host config only). Default true. */
includeStatus?: boolean;
};
async function loadSSHHostsFromApi(): Promise<SSHHost[]> {
const hostsResponse = await sshHostApi.get("/db/host"); const hostsResponse = await sshHostApi.get("/db/host");
const hosts: SSHHost[] = Array.isArray(hostsResponse.data) return Array.isArray(hostsResponse.data) ? hostsResponse.data : [];
? hostsResponse.data }
: [];
export async function getSSHHosts(
options: GetSSHHostsOptions = {},
): Promise<SSHHostWithStatus[]> {
const includeStatus = options.includeStatus !== false;
try {
const hosts = await getCachedSSHHosts(loadSSHHostsFromApi);
if (!includeStatus) {
return hosts.map((host) => ({
...host,
status: "unknown",
}));
}
let statuses: Record<number, ServerStatus> = {}; let statuses: Record<number, ServerStatus> = {};
try { try {
@@ -45,9 +67,11 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
const response = await sshHostApi.post("/db/host", formData, { const response = await sshHostApi.post("/db/host", formData, {
headers: { "Content-Type": "multipart/form-data" }, headers: { "Content-Type": "multipart/form-data" },
}); });
invalidateHostsAndStatusCaches();
return response.data; return response.data;
} }
const response = await sshHostApi.post("/db/host", hostData); const response = await sshHostApi.post("/db/host", hostData);
invalidateHostsAndStatusCaches();
return response.data; return response.data;
} catch (error) { } catch (error) {
throw handleApiError(error, "create SSH host"); throw handleApiError(error, "create SSH host");
@@ -67,9 +91,11 @@ export async function updateSSHHost(
const response = await sshHostApi.put(`/db/host/${hostId}`, formData, { const response = await sshHostApi.put(`/db/host/${hostId}`, formData, {
headers: { "Content-Type": "multipart/form-data" }, headers: { "Content-Type": "multipart/form-data" },
}); });
invalidateHostsAndStatusCaches();
return response.data; return response.data;
} }
const response = await sshHostApi.put(`/db/host/${hostId}`, hostData); const response = await sshHostApi.put(`/db/host/${hostId}`, hostData);
invalidateHostsAndStatusCaches();
return response.data; return response.data;
} catch (error) { } catch (error) {
throw handleApiError(error, "update SSH host"); throw handleApiError(error, "update SSH host");
@@ -103,6 +129,7 @@ export async function bulkImportSSHHosts(
overwrite, overwrite,
...(credentials ? { credentials } : {}), ...(credentials ? { credentials } : {}),
}); });
invalidateHostsAndStatusCaches();
return response.data; return response.data;
} catch (error) { } catch (error) {
handleApiError(error, "bulk import SSH hosts"); handleApiError(error, "bulk import SSH hosts");
@@ -125,6 +152,7 @@ export async function importSSHConfigHosts(
content, content,
overwrite, overwrite,
}); });
invalidateHostsAndStatusCaches();
return response.data; return response.data;
} catch (error) { } catch (error) {
handleApiError(error, "import SSH config hosts"); handleApiError(error, "import SSH config hosts");
@@ -155,6 +183,7 @@ export async function bulkUpdateSSHHosts(
hostIds, hostIds,
updates, updates,
}); });
invalidateHostsAndStatusCaches();
return response.data; return response.data;
} catch (error) { } catch (error) {
handleApiError(error, "bulk update SSH hosts"); handleApiError(error, "bulk update SSH hosts");
@@ -166,6 +195,7 @@ export async function deleteSSHHost(
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
try { try {
const response = await sshHostApi.delete(`/db/host/${hostId}`); const response = await sshHostApi.delete(`/db/host/${hostId}`);
invalidateHostsAndStatusCaches();
return response.data; return response.data;
} catch (error) { } catch (error) {
handleApiError(error, "delete SSH host"); handleApiError(error, "delete SSH host");
+24 -3
View File
@@ -801,6 +801,7 @@ function CardItem({
onAddServiceLink, onAddServiceLink,
onDeleteServiceLink, onDeleteServiceLink,
statusLoading, statusLoading,
isVisible = true,
}: { }: {
slot: CardSlot; slot: CardSlot;
editMode: boolean; editMode: boolean;
@@ -830,6 +831,7 @@ function CardItem({
onAddServiceLink: (label: string, url: string) => Promise<void>; onAddServiceLink: (label: string, url: string) => Promise<void>;
onDeleteServiceLink: (id: number) => Promise<void>; onDeleteServiceLink: (id: number) => Promise<void>;
statusLoading?: boolean; statusLoading?: boolean;
isVisible?: boolean;
}) { }) {
const cardRef = useRef<HTMLDivElement | null>(null); const cardRef = useRef<HTMLDivElement | null>(null);
@@ -926,6 +928,7 @@ function CardItem({
{slot.id === "network_graph" && ( {slot.id === "network_graph" && (
<NetworkGraphCard <NetworkGraphCard
embedded={true} embedded={true}
isVisible={isVisible}
onOpenInNewTab={() => onOpenSingletonTab("network_graph")} onOpenInNewTab={() => onOpenSingletonTab("network_graph")}
/> />
)} )}
@@ -1055,6 +1058,7 @@ type PanelColumnProps = {
onAddServiceLink: (label: string, url: string) => Promise<void>; onAddServiceLink: (label: string, url: string) => Promise<void>;
onDeleteServiceLink: (id: number) => Promise<void>; onDeleteServiceLink: (id: number) => Promise<void>;
statusLoading: boolean; statusLoading: boolean;
isVisible?: boolean;
}; };
function PanelColumn({ function PanelColumn({
@@ -1086,6 +1090,7 @@ function PanelColumn({
onAddServiceLink, onAddServiceLink,
onDeleteServiceLink, onDeleteServiceLink,
statusLoading, statusLoading,
isVisible = true,
}: PanelColumnProps) { }: PanelColumnProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const sorted = [...slots].sort((a, b) => a.order - b.order); const sorted = [...slots].sort((a, b) => a.order - b.order);
@@ -1142,6 +1147,7 @@ function PanelColumn({
onAddServiceLink={onAddServiceLink} onAddServiceLink={onAddServiceLink}
onDeleteServiceLink={onDeleteServiceLink} onDeleteServiceLink={onDeleteServiceLink}
statusLoading={statusLoading} statusLoading={statusLoading}
isVisible={isVisible}
/> />
</div> </div>
))} ))}
@@ -1192,9 +1198,12 @@ function ColumnDivider({
export function DashboardTab({ export function DashboardTab({
onOpenSingletonTab, onOpenSingletonTab,
onOpenTab, onOpenTab,
isVisible = true,
}: { }: {
onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void; onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
onOpenTab: (host: Host, type: TabType) => void; onOpenTab: (host: Host, type: TabType) => void;
/** When false, pause dashboard metrics refresh while the tab stays mounted. */
isVisible?: boolean;
}) { }) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { initialLoadComplete } = useServerStatus(); const { initialLoadComplete } = useServerStatus();
@@ -1349,7 +1358,9 @@ export function DashboardTab({
const mapped = raw.map(sshHostToHost); const mapped = raw.map(sshHostToHost);
const statusHosts = mapped.filter(isStatusCheckEnabled); const statusHosts = mapped.filter(isStatusCheckEnabled);
if (mounted) setHosts(mapped); if (mounted) setHosts(mapped);
if (isVisible) {
fetchMetrics(statusHosts).catch(() => {}); fetchMetrics(statusHosts).catch(() => {});
}
}; };
load(); load();
@@ -1399,7 +1410,14 @@ export function DashboardTab({
}) })
.catch(() => {}); .catch(() => {});
if (!isVisible) {
return () => {
mounted = false;
};
}
const metricsInterval = setInterval(async () => { const metricsInterval = setInterval(async () => {
if (document.visibilityState === "hidden") return;
const raw = await getSSHHosts().catch(() => []); const raw = await getSSHHosts().catch(() => []);
const mapped = raw.map(sshHostToHost); const mapped = raw.map(sshHostToHost);
const statusHosts = mapped.filter(isStatusCheckEnabled); const statusHosts = mapped.filter(isStatusCheckEnabled);
@@ -1411,17 +1429,18 @@ export function DashboardTab({
mounted = false; mounted = false;
clearInterval(metricsInterval); clearInterval(metricsInterval);
}; };
}, [fetchMetrics]); }, [fetchMetrics, isVisible]);
useEffect(() => { useEffect(() => {
if (viewerSessionsRef.current.size === 0) return; if (!isVisible || viewerSessionsRef.current.size === 0) return;
const heartbeat = setInterval(async () => { const heartbeat = setInterval(async () => {
if (document.visibilityState === "hidden") return;
for (const [, sessionId] of viewerSessionsRef.current) { for (const [, sessionId] of viewerSessionsRef.current) {
sendMetricsHeartbeat(sessionId).catch(() => {}); sendMetricsHeartbeat(sessionId).catch(() => {});
} }
}, 30000); }, 30000);
return () => clearInterval(heartbeat); return () => clearInterval(heartbeat);
}, [hostMetrics]); }, [hostMetrics, isVisible]);
const handleClearActivity = async () => { const handleClearActivity = async () => {
try { try {
@@ -1594,6 +1613,7 @@ export function DashboardTab({
onAddServiceLink: handleAddServiceLink, onAddServiceLink: handleAddServiceLink,
onDeleteServiceLink: handleDeleteServiceLink, onDeleteServiceLink: handleDeleteServiceLink,
statusLoading, statusLoading,
isVisible,
}; };
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@@ -1733,6 +1753,7 @@ export function DashboardTab({
{slot.id === "network_graph" && ( {slot.id === "network_graph" && (
<NetworkGraphCard <NetworkGraphCard
embedded={true} embedded={true}
isVisible={isVisible}
onOpenInNewTab={() => onOpenSingletonTab("network_graph")} onOpenInNewTab={() => onOpenSingletonTab("network_graph")}
/> />
)} )}
+16 -2
View File
@@ -92,6 +92,8 @@ interface NetworkGraphCardProps {
rightSidebarWidth?: number; rightSidebarWidth?: number;
embedded?: boolean; embedded?: boolean;
onOpenInNewTab?: () => void; onOpenInNewTab?: () => void;
/** When false, pause status refresh while the surface stays mounted. */
isVisible?: boolean;
} }
type NetworkElement = NetworkTopologyNode | NetworkTopologyEdge; type NetworkElement = NetworkTopologyNode | NetworkTopologyEdge;
@@ -195,6 +197,7 @@ function buildNodeSvg(
export function NetworkGraphCard({ export function NetworkGraphCard({
embedded = true, embedded = true,
onOpenInNewTab, onOpenInNewTab,
isVisible = true,
}: NetworkGraphCardProps): React.ReactElement { }: NetworkGraphCardProps): React.ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
const { addTab } = useTabsSafe(); const { addTab } = useTabsSafe();
@@ -269,8 +272,19 @@ export function NetworkGraphCard({
}, [hostMap]); }, [hostMap]);
useEffect(() => { useEffect(() => {
if (!isVisible) {
if (statusIntervalRef.current) {
clearInterval(statusIntervalRef.current);
statusIntervalRef.current = null;
}
return;
}
loadData(); loadData();
statusIntervalRef.current = setInterval(updateHostStatuses, 30000); statusIntervalRef.current = setInterval(() => {
if (document.visibilityState === "hidden") return;
updateHostStatuses();
}, 30000);
const onClickOutside = (e: MouseEvent) => { const onClickOutside = (e: MouseEvent) => {
if ( if (
contextMenuRef.current && contextMenuRef.current &&
@@ -293,7 +307,7 @@ export function NetworkGraphCard({
document.removeEventListener("mousedown", onClickOutside, true); document.removeEventListener("mousedown", onClickOutside, true);
themeObserver.disconnect(); themeObserver.disconnect();
}; };
}, []); }, [isVisible]);
const loadData = async () => { const loadData = async () => {
setLoading(true); setLoading(true);
+4 -1
View File
@@ -335,7 +335,10 @@ function DockerManagerInner({
}; };
pollContainers(); pollContainers();
const interval = setInterval(pollContainers, 5000); const interval = setInterval(() => {
if (document.visibilityState === "hidden") return;
void pollContainers();
}, 5000);
return () => { return () => {
cancelled = true; cancelled = true;
@@ -50,7 +50,10 @@ export function ContainerStats({
React.useEffect(() => { React.useEffect(() => {
fetchStats(); fetchStats();
const interval = setInterval(fetchStats, 2000); const interval = setInterval(() => {
if (document.visibilityState === "hidden") return;
void fetchStats();
}, 2000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [fetchStats]); }, [fetchStats]);
@@ -90,7 +90,10 @@ export function LogViewer({
React.useEffect(() => { React.useEffect(() => {
if (!autoRefresh) return; if (!autoRefresh) return;
const interval = setInterval(fetchLogs, 3000); const interval = setInterval(() => {
if (document.visibilityState === "hidden") return;
void fetchLogs();
}, 3000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [autoRefresh, fetchLogs]); }, [autoRefresh, fetchLogs]);
+5 -2
View File
@@ -87,6 +87,7 @@ function FileManagerContent({
initialPath, initialPath,
onClose, onClose,
onOpenTerminalTab, onOpenTerminalTab,
isVisible = true,
}: FileManagerProps) { }: FileManagerProps) {
const { openWindow } = useWindowManager(); const { openWindow } = useWindowManager();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -268,7 +269,7 @@ function FileManagerContent({
}, [currentHost]); }, [currentHost]);
useEffect(() => { useEffect(() => {
if (sshSessionId) { if (sshSessionId && isVisible) {
startKeepalive(); startKeepalive();
} else { } else {
stopKeepalive(); stopKeepalive();
@@ -277,7 +278,7 @@ function FileManagerContent({
return () => { return () => {
stopKeepalive(); stopKeepalive();
}; };
}, [sshSessionId, startKeepalive, stopKeepalive]); }, [sshSessionId, isVisible, startKeepalive, stopKeepalive]);
const initialFileOpenedRef = useRef(false); const initialFileOpenedRef = useRef(false);
useEffect(() => { useEffect(() => {
@@ -3105,6 +3106,7 @@ function FileManagerInner({
initialPath, initialPath,
onClose, onClose,
onOpenTerminalTab, onOpenTerminalTab,
isVisible = true,
}: FileManagerProps) { }: FileManagerProps) {
return ( return (
<WindowManager> <WindowManager>
@@ -3114,6 +3116,7 @@ function FileManagerInner({
initialPath={initialPath} initialPath={initialPath}
onClose={onClose} onClose={onClose}
onOpenTerminalTab={onOpenTerminalTab} onOpenTerminalTab={onOpenTerminalTab}
isVisible={isVisible}
/> />
</WindowManager> </WindowManager>
); );
+206 -21
View File
@@ -1,6 +1,14 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
import React, { useState, useRef, useCallback, useEffect } from "react"; import React, {
useState,
useRef,
useCallback,
useEffect,
useMemo,
useLayoutEffect,
} from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useVirtualizer } from "@tanstack/react-virtual";
import { cn } from "@/lib/utils.ts"; import { cn } from "@/lib/utils.ts";
import { import {
Folder, Folder,
@@ -185,6 +193,12 @@ export function FileManagerGrid({
const { t } = useTranslation(); const { t } = useTranslation();
const gridRef = useRef<HTMLDivElement>(null); const gridRef = useRef<HTMLDivElement>(null);
const [editingName, setEditingName] = useState(""); const [editingName, setEditingName] = useState("");
const [gridCols, setGridCols] = useState(4);
const LIST_ROW_H = 41;
const GRID_ROW_H = 112;
const LIST_HEADER_H = 33;
const CONTENT_PAD = 16;
const [dragState, setDragState] = useState<DragState>({ const [dragState, setDragState] = useState<DragState>({
type: "none", type: "none",
@@ -192,6 +206,52 @@ export function FileManagerGrid({
counter: 0, counter: 0,
}); });
// Responsive column count for grid virtualization (matches Tailwind breakpoints roughly).
useEffect(() => {
if (viewMode !== "grid") return;
const el = gridRef.current;
if (!el) return;
const updateCols = () => {
const w = el.clientWidth - CONTENT_PAD * 2;
// gap-4 (16px) + ~min cell 96px
const n = Math.max(2, Math.min(8, Math.floor((w + 16) / 112)));
setGridCols(n);
};
updateCols();
const ro = new ResizeObserver(updateCols);
ro.observe(el);
return () => ro.disconnect();
}, [viewMode]);
const gridRowCount = useMemo(
() => (viewMode === "grid" ? Math.ceil(files.length / gridCols) : 0),
[viewMode, files.length, gridCols],
);
const listVirtualizer = useVirtualizer({
count: viewMode === "list" ? files.length : 0,
getScrollElement: () => gridRef.current,
estimateSize: () => LIST_ROW_H,
overscan: 16,
getItemKey: (index) => files[index]?.path ?? index,
enabled: viewMode === "list" && files.length > 0,
});
const gridVirtualizer = useVirtualizer({
count: gridRowCount,
getScrollElement: () => gridRef.current,
estimateSize: () => GRID_ROW_H,
overscan: 6,
getItemKey: (index) => `row-${index}`,
enabled: viewMode === "grid" && files.length > 0,
});
useLayoutEffect(() => {
if (viewMode === "list") listVirtualizer.measure();
else gridVirtualizer.measure();
}, [viewMode, files.length, editingFile?.path, createIntent, gridCols]);
useEffect(() => { useEffect(() => {
const handleGlobalMouseMove = (e: MouseEvent) => { const handleGlobalMouseMove = (e: MouseEvent) => {
if (dragState.type === "internal" && dragState.files.length > 0) { if (dragState.type === "internal" && dragState.files.length > 0) {
@@ -442,6 +502,77 @@ export function FileManagerGrid({
setSelectionRect({ x, y, width, height }); setSelectionRect({ x, y, width, height });
if (gridRef.current) { if (gridRef.current) {
const selectionBox = {
left: x,
top: y,
right: x + width,
bottom: y + height,
};
// Virtual list: only visible DOM nodes exist. For list mode compute
// selection from scroll position + fixed row height so drag-select
// still covers off-screen rows.
if (viewMode === "list" && files.length > 0) {
const scrollTop = gridRef.current.scrollTop;
const createExtra = createIntent ? LIST_ROW_H : 0;
const contentTop =
selectionBox.top +
scrollTop -
CONTENT_PAD -
LIST_HEADER_H -
createExtra;
const contentBottom =
selectionBox.bottom +
scrollTop -
CONTENT_PAD -
LIST_HEADER_H -
createExtra;
const startIdx = Math.max(0, Math.floor(contentTop / LIST_ROW_H));
const endIdx = Math.min(
files.length - 1,
Math.ceil(contentBottom / LIST_ROW_H),
);
if (endIdx >= startIdx) {
onSelectionChange(files.slice(startIdx, endIdx + 1));
} else {
onSelectionChange([]);
}
return;
}
if (viewMode === "grid" && files.length > 0) {
const scrollTop = gridRef.current.scrollTop;
const createExtra = createIntent ? GRID_ROW_H : 0;
const contentTop =
selectionBox.top + scrollTop - CONTENT_PAD - createExtra;
const contentBottom =
selectionBox.bottom + scrollTop - CONTENT_PAD - createExtra;
const startRow = Math.max(0, Math.floor(contentTop / GRID_ROW_H));
const endRow = Math.min(
gridRowCount - 1,
Math.ceil(contentBottom / GRID_ROW_H),
);
// Column range from X within padded content.
const contentLeft = selectionBox.left - CONTENT_PAD;
const contentRight = selectionBox.right - CONTENT_PAD;
const cellW =
(gridRef.current.clientWidth - CONTENT_PAD * 2 + 16) / gridCols;
const startCol = Math.max(0, Math.floor(contentLeft / cellW));
const endCol = Math.min(
gridCols - 1,
Math.ceil(contentRight / cellW),
);
const selected: FileItem[] = [];
for (let r = startRow; r <= endRow; r++) {
for (let c = startCol; c <= endCol; c++) {
const idx = r * gridCols + c;
if (idx >= 0 && idx < files.length) selected.push(files[idx]);
}
}
onSelectionChange(selected);
return;
}
const fileElements = const fileElements =
gridRef.current.querySelectorAll("[data-file-path]"); gridRef.current.querySelectorAll("[data-file-path]");
const selectedPaths: string[] = []; const selectedPaths: string[] = [];
@@ -457,13 +588,6 @@ export function FileManagerGrid({
bottom: elementRect.bottom - containerRect.top, bottom: elementRect.bottom - containerRect.top,
}; };
const selectionBox = {
left: x,
top: y,
right: x + width,
bottom: y + height,
};
const intersects = !( const intersects = !(
relativeElementRect.right < selectionBox.left || relativeElementRect.right < selectionBox.left ||
relativeElementRect.left > selectionBox.right || relativeElementRect.left > selectionBox.right ||
@@ -486,7 +610,16 @@ export function FileManagerGrid({
} }
} }
}, },
[isSelecting, selectionStart, files, onSelectionChange], [
isSelecting,
selectionStart,
files,
onSelectionChange,
viewMode,
createIntent,
gridCols,
gridRowCount,
],
); );
const handleMouseUp = useCallback( const handleMouseUp = useCallback(
@@ -819,19 +952,48 @@ export function FileManagerGrid({
</span> </span>
</div> </div>
) : viewMode === "grid" ? ( ) : viewMode === "grid" ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4"> <div className="flex flex-col gap-4">
{createIntent && ( {createIntent && (
<div
className="grid gap-4"
style={{
gridTemplateColumns: `repeat(${gridCols}, minmax(0, 1fr))`,
}}
>
<CreateIntentGridItem <CreateIntentGridItem
intent={createIntent} intent={createIntent}
onConfirm={onConfirmCreate} onConfirm={onConfirmCreate}
onCancel={onCancelCreate} onCancel={onCancelCreate}
/> />
</div>
)} )}
{files.map((file) => { <div
className="relative w-full"
style={{ height: gridVirtualizer.getTotalSize() }}
>
{gridVirtualizer.getVirtualItems().map((vRow) => {
const start = vRow.index * gridCols;
const rowFiles = files.slice(start, start + gridCols);
return (
<div
key={vRow.key}
data-index={vRow.index}
ref={gridVirtualizer.measureElement}
className="absolute top-0 left-0 w-full"
style={{
transform: `translateY(${vRow.start}px)`,
}}
>
<div
className="grid gap-4 pb-4"
style={{
gridTemplateColumns: `repeat(${gridCols}, minmax(0, 1fr))`,
}}
>
{rowFiles.map((file) => {
const isSelected = selectedFiles.some( const isSelected = selectedFiles.some(
(f) => f.path === file.path, (f) => f.path === file.path,
); );
return ( return (
<div <div
key={file.path} key={file.path}
@@ -839,11 +1001,13 @@ export function FileManagerGrid({
draggable={true} draggable={true}
className={cn( className={cn(
"group flex flex-col items-center p-3 rounded-none border-2 border-transparent transition-all cursor-pointer hover:bg-muted/50 select-none", "group flex flex-col items-center p-3 rounded-none border-2 border-transparent transition-all cursor-pointer hover:bg-muted/50 select-none",
isSelected && "bg-accent-brand/10 border-accent-brand/40", isSelected &&
"bg-accent-brand/10 border-accent-brand/40",
dragState.target?.path === file.path && dragState.target?.path === file.path &&
"bg-accent-brand/20 border-accent-brand border-dashed", "bg-accent-brand/20 border-accent-brand border-dashed",
dragState.files.some((f) => f.path === file.path) && dragState.files.some(
"opacity-50", (f) => f.path === file.path,
) && "opacity-50",
)} )}
title={file.name} title={file.name}
onClick={(e) => handleFileClick(file, e)} onClick={(e) => handleFileClick(file, e)}
@@ -861,14 +1025,15 @@ export function FileManagerGrid({
<div className="relative mb-2 pointer-events-none"> <div className="relative mb-2 pointer-events-none">
{getFileIcon(file, viewMode)} {getFileIcon(file, viewMode)}
</div> </div>
<div className="w-full flex flex-col items-center pointer-events-none"> <div className="w-full flex flex-col items-center pointer-events-none">
{editingFile?.path === file.path ? ( {editingFile?.path === file.path ? (
<input <input
ref={editInputRef} ref={editInputRef}
type="text" type="text"
value={editingName} value={editingName}
onChange={(e) => setEditingName(e.target.value)} onChange={(e) =>
setEditingName(e.target.value)
}
onKeyDown={handleEditKeyDown} onKeyDown={handleEditKeyDown}
onBlur={handleEditConfirm} onBlur={handleEditConfirm}
className="max-w-[120px] min-w-[60px] w-fit border border-accent-brand/60 bg-card px-2 py-1 text-xs rounded-none outline-none focus:ring-1 focus:ring-accent-brand/50 text-center pointer-events-auto" className="max-w-[120px] min-w-[60px] w-fit border border-accent-brand/60 bg-card px-2 py-1 text-xs rounded-none outline-none focus:ring-1 focus:ring-accent-brand/50 text-center pointer-events-auto"
@@ -903,6 +1068,11 @@ export function FileManagerGrid({
); );
})} })}
</div> </div>
</div>
);
})}
</div>
</div>
) : ( ) : (
<div className="flex flex-col"> <div className="flex flex-col">
<div className="grid grid-cols-[1fr_120px_150px_80px_90px] gap-2 px-4 py-2 text-[10px] font-bold uppercase tracking-widest text-muted-foreground border-b border-border sticky top-0 bg-card z-10"> <div className="grid grid-cols-[1fr_120px_150px_80px_90px] gap-2 px-4 py-2 text-[10px] font-bold uppercase tracking-widest text-muted-foreground border-b border-border sticky top-0 bg-card z-10">
@@ -952,18 +1122,31 @@ export function FileManagerGrid({
onCancel={onCancelCreate} onCancel={onCancelCreate}
/> />
)} )}
{files.map((file) => { <div
className="relative w-full"
style={{ height: listVirtualizer.getTotalSize() }}
>
{listVirtualizer.getVirtualItems().map((vItem) => {
const file = files[vItem.index];
if (!file) return null;
const isSelected = selectedFiles.some( const isSelected = selectedFiles.some(
(f) => f.path === file.path, (f) => f.path === file.path,
); );
return ( return (
<div <div
key={file.path} key={vItem.key}
data-index={vItem.index}
ref={listVirtualizer.measureElement}
className="absolute top-0 left-0 w-full"
style={{
transform: `translateY(${vItem.start}px)`,
}}
>
<div
data-file-path={file.path} data-file-path={file.path}
draggable={true} draggable={true}
className={cn( className={cn(
"grid grid-cols-[1fr_120px_150px_80px_90px] gap-2 px-4 py-2 items-center text-xs cursor-pointer border-b border-border hover:bg-muted/50 last:border-0 rounded-none select-none transition-colors", "grid grid-cols-[1fr_120px_150px_80px_90px] gap-2 px-4 py-2 items-center text-xs cursor-pointer border-b border-border hover:bg-muted/50 rounded-none select-none transition-colors",
isSelected && "bg-accent-brand/10", isSelected && "bg-accent-brand/10",
dragState.target?.path === file.path && dragState.target?.path === file.path &&
"bg-accent-brand/20 border-accent-brand border-dashed", "bg-accent-brand/20 border-accent-brand border-dashed",
@@ -1035,9 +1218,11 @@ export function FileManagerGrid({
{file.permissions || "—"} {file.permissions || "—"}
</span> </span>
</div> </div>
</div>
); );
})} })}
</div> </div>
</div>
)} )}
{isSelecting && selectionRect && ( {isSelecting && selectionRect && (
@@ -71,9 +71,34 @@ export function TransferMonitor() {
} }
}; };
let intervalId: ReturnType<typeof setInterval> | null = null;
const start = () => {
if (intervalId !== null) return;
intervalId = setInterval(reconcileTransfers, POLL_INTERVAL_MS);
};
const stop = () => {
if (intervalId === null) return;
clearInterval(intervalId);
intervalId = null;
};
const onVisibility = () => {
if (document.visibilityState === "hidden") {
stop();
return;
}
void reconcileTransfers(); void reconcileTransfers();
const interval = setInterval(reconcileTransfers, POLL_INTERVAL_MS); start();
return () => clearInterval(interval); };
void reconcileTransfers();
if (document.visibilityState !== "hidden") start();
document.addEventListener("visibilitychange", onVisibility);
return () => {
stop();
document.removeEventListener("visibilitychange", onVisibility);
};
}, [t, formatTransferMetrics]); }, [t, formatTransferMetrics]);
return null; return null;
@@ -7,6 +7,8 @@ export interface FileManagerProps {
initialPath?: string; initialPath?: string;
onClose?: () => void; onClose?: () => void;
onOpenTerminalTab?: (path?: string) => void; onOpenTerminalTab?: (path?: string) => void;
/** When false, pause keepalive while the tab stays mounted in the background. */
isVisible?: boolean;
} }
export type ConnectionLogPayload = Omit<LogEntry, "id" | "timestamp">; export type ConnectionLogPayload = Omit<LogEntry, "id" | "timestamp">;
@@ -0,0 +1,50 @@
/**
* setInterval that pauses while the document is hidden and fires once on resume.
* Returns a cleanup function for useEffect.
*/
export function runVisibleInterval(
tick: () => void,
intervalMs: number,
): () => void {
let intervalId: ReturnType<typeof setInterval> | null = null;
const start = () => {
if (intervalId !== null || intervalMs <= 0) return;
intervalId = setInterval(() => {
if (document.visibilityState === "hidden") return;
tick();
}, intervalMs);
};
const stop = () => {
if (intervalId === null) return;
clearInterval(intervalId);
intervalId = null;
};
const onVisibility = () => {
if (document.visibilityState === "hidden") {
stop();
return;
}
tick();
start();
};
if (
typeof document === "undefined" ||
document.visibilityState !== "hidden"
) {
start();
}
if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", onVisibility);
}
return () => {
stop();
if (typeof document !== "undefined") {
document.removeEventListener("visibilitychange", onVisibility);
}
};
}
@@ -61,8 +61,35 @@ function AlertFeedWidget({
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
const iv = setInterval(fetchData, 30_000);
return () => clearInterval(iv); let intervalId: ReturnType<typeof setInterval> | null = null;
const start = () => {
if (intervalId !== null) return;
intervalId = setInterval(() => {
if (document.visibilityState === "hidden") return;
void fetchData();
}, 30_000);
};
const stop = () => {
if (intervalId === null) return;
clearInterval(intervalId);
intervalId = null;
};
const onVisibility = () => {
if (document.visibilityState === "hidden") {
stop();
return;
}
void fetchData();
start();
};
if (document.visibilityState !== "hidden") start();
document.addEventListener("visibilitychange", onVisibility);
return () => {
stop();
document.removeEventListener("visibilitychange", onVisibility);
};
}, [maxItems, showAcknowledged]); }, [maxItems, showAcknowledged]);
const handleAck = async (id: number) => { const handleAck = async (id: number) => {
@@ -8,6 +8,7 @@ import type {
} from "@/types/homepage-types"; } from "@/types/homepage-types";
import { GRID_SIZE } from "@/types/homepage-types"; import { GRID_SIZE } from "@/types/homepage-types";
import { WidgetTitle } from "./WidgetTitle"; import { WidgetTitle } from "./WidgetTitle";
import { runVisibleInterval } from "../use-visible-interval";
function getMonthData(year: number, month: number, startOnMonday: boolean) { function getMonthData(year: number, month: number, startOnMonday: boolean) {
const firstDay = new Date(year, month, 1).getDay(); const firstDay = new Date(year, month, 1).getDay();
@@ -27,8 +28,7 @@ function CalendarWidget({
const [viewMonth, setViewMonth] = useState(now.getMonth()); const [viewMonth, setViewMonth] = useState(now.getMonth());
useEffect(() => { useEffect(() => {
const iv = setInterval(() => setNow(new Date()), 60_000); return runVisibleInterval(() => setNow(new Date()), 60_000);
return () => clearInterval(iv);
}, []); }, []);
const { daysInMonth, offset } = getMonthData( const { daysInMonth, offset } = getMonthData(
@@ -1,18 +1,20 @@
import { useEffect, useState } from "react"; import { useState } from "react";
import { Clock } from "lucide-react"; import { Clock } from "lucide-react";
import { registerWidget } from "./WidgetRegistry"; import { registerWidget } from "./WidgetRegistry";
import type { ClockConfig, WidgetComponentProps } from "@/types/homepage-types"; import type { ClockConfig, WidgetComponentProps } from "@/types/homepage-types";
import { GRID_SIZE } from "@/types/homepage-types"; import { GRID_SIZE } from "@/types/homepage-types";
import { WidgetTitle } from "./WidgetTitle"; import { WidgetTitle } from "./WidgetTitle";
import { usePageVisibleInterval } from "@/hooks/use-page-visible-interval";
function ClockWidget({ widget, config }: WidgetComponentProps<ClockConfig>) { function ClockWidget({ widget, config }: WidgetComponentProps<ClockConfig>) {
const { timezone, showSeconds, format } = config; const { timezone, showSeconds, format } = config;
const [now, setNow] = useState(new Date()); const [now, setNow] = useState(new Date());
useEffect(() => { // Minute-level is enough without seconds; pause when the tab is backgrounded.
const iv = setInterval(() => setNow(new Date()), 1000); usePageVisibleInterval(
return () => clearInterval(iv); () => setNow(new Date()),
}, []); showSeconds ? 1_000 : 30_000,
);
const opts: Intl.DateTimeFormatOptions = { const opts: Intl.DateTimeFormatOptions = {
hour: "2-digit", hour: "2-digit",
@@ -69,8 +69,32 @@ function CountdownWidget({
} }
}; };
update(); update();
const iv = setInterval(update, 1000);
return () => clearInterval(iv); let intervalId: ReturnType<typeof setInterval> | null = null;
const start = () => {
if (intervalId !== null) return;
intervalId = setInterval(update, 1000);
};
const stop = () => {
if (intervalId === null) return;
clearInterval(intervalId);
intervalId = null;
};
const onVisibility = () => {
if (document.visibilityState === "hidden") {
stop();
return;
}
update();
start();
};
if (document.visibilityState !== "hidden") start();
document.addEventListener("visibilitychange", onVisibility);
return () => {
stop();
document.removeEventListener("visibilitychange", onVisibility);
};
}, [targetDate]); }, [targetDate]);
const titleBar = ( const titleBar = (
@@ -9,6 +9,7 @@ import type {
import { GRID_SIZE } from "@/types/homepage-types"; import { GRID_SIZE } from "@/types/homepage-types";
import { homepageApi } from "@/main-axios"; import { homepageApi } from "@/main-axios";
import { WidgetTitle } from "./WidgetTitle"; import { WidgetTitle } from "./WidgetTitle";
import { runVisibleInterval } from "../use-visible-interval";
function resolvePath(obj: unknown, path: string): unknown { function resolvePath(obj: unknown, path: string): unknown {
return path.split(".").reduce((acc: unknown, key) => { return path.split(".").reduce((acc: unknown, key) => {
@@ -70,8 +71,9 @@ function CustomApiWidget({
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
const iv = setInterval(fetchData, interval * 1000); return runVisibleInterval(() => {
return () => clearInterval(iv); void fetchData();
}, interval * 1000);
}, [url, interval]); }, [url, interval]);
const accent = const accent =
@@ -10,6 +10,7 @@ import { GRID_SIZE } from "@/types/homepage-types";
import { getRecentActivity } from "@/api/dashboard-api"; import { getRecentActivity } from "@/api/dashboard-api";
import type { RecentActivityItem } from "@/api/dashboard-api"; import type { RecentActivityItem } from "@/api/dashboard-api";
import { WidgetTitle } from "./WidgetTitle"; import { WidgetTitle } from "./WidgetTitle";
import { runVisibleInterval } from "../use-visible-interval";
function relativeTime(ts: string): string { function relativeTime(ts: string): string {
const diff = Date.now() - new Date(ts).getTime(); const diff = Date.now() - new Date(ts).getTime();
@@ -43,8 +44,9 @@ function DockerActivityWidget({
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
const iv = setInterval(fetchData, 30_000); return runVisibleInterval(() => {
return () => clearInterval(iv); void fetchData();
}, 30_000);
}, [maxItems]); }, [maxItems]);
if (loading) { if (loading) {
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useCallback, useState } from "react";
import { LayoutGrid } from "lucide-react"; import { LayoutGrid } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { registerWidget } from "./WidgetRegistry"; import { registerWidget } from "./WidgetRegistry";
@@ -8,9 +8,9 @@ import type {
} from "@/types/homepage-types"; } from "@/types/homepage-types";
import { GRID_SIZE } from "@/types/homepage-types"; import { GRID_SIZE } from "@/types/homepage-types";
import { getSSHHosts } from "@/api/ssh-host-management-api"; import { getSSHHosts } from "@/api/ssh-host-management-api";
import { getAllServerStatuses } from "@/api/host-metrics-status-api";
import type { SSHHostWithStatus } from "@/main-axios"; import type { SSHHostWithStatus } from "@/main-axios";
import { WidgetTitle } from "./WidgetTitle"; import { WidgetTitle } from "./WidgetTitle";
import { usePageVisibleInterval } from "@/hooks/use-page-visible-interval";
function getAccentColor(): string { function getAccentColor(): string {
return ( return (
@@ -44,39 +44,27 @@ function HostGridWidget({
const [hosts, setHosts] = useState<SSHHostWithStatus[]>([]); const [hosts, setHosts] = useState<SSHHostWithStatus[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const fetchData = async () => { // getSSHHosts already attaches cached server status — no second /status call.
const fetchData = useCallback(async () => {
try { try {
const [allHosts, statuses] = await Promise.all([ const allHosts = await getSSHHosts();
getSSHHosts(),
getAllServerStatuses().catch(
() => ({}) as Record<number, { status: string }>,
),
]);
const filtered = const filtered =
hostIds.length > 0 hostIds.length > 0
? allHosts.filter((h) => hostIds.includes(h.id)) ? allHosts.filter((h) => hostIds.includes(h.id))
: allHosts; : allHosts;
const withStatus = filtered.map((h) => ({ setHosts(filtered);
...h,
status:
(statuses as Record<number, { status: string }>)[h.id]?.status ??
h.status ??
"unknown",
}));
setHosts(withStatus);
} catch { } catch {
/* ignore */ /* ignore */
} finally { } finally {
setLoading(false); setLoading(false);
} }
};
useEffect(() => {
fetchData();
const iv = setInterval(fetchData, 10_000);
return () => clearInterval(iv);
}, [hostIds.join(",")]); }, [hostIds.join(",")]);
// Align with global status cadence; pause when the tab is hidden.
usePageVisibleInterval(() => {
void fetchData();
}, 30_000);
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground"> <div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
@@ -130,10 +130,37 @@ function HostStatusWidget({
}; };
poll(); poll();
const iv = setInterval(poll, 10000);
let intervalId: ReturnType<typeof setInterval> | null = null;
const start = () => {
if (intervalId !== null) return;
// Align with global status cadence; metrics do not need sub-10s when hidden.
intervalId = setInterval(() => {
if (document.visibilityState === "hidden") return;
void poll();
}, 30_000);
};
const stop = () => {
if (intervalId === null) return;
clearInterval(intervalId);
intervalId = null;
};
const onVisibility = () => {
if (document.visibilityState === "hidden") {
stop();
return;
}
void poll();
start();
};
if (document.visibilityState !== "hidden") start();
document.addEventListener("visibilitychange", onVisibility);
return () => { return () => {
cancelled = true; cancelled = true;
clearInterval(iv); stop();
document.removeEventListener("visibilitychange", onVisibility);
}; };
}, [hostId, needsMetrics]); }, [hostId, needsMetrics]);
@@ -12,6 +12,7 @@ import { getMetricsHistory } from "@/api/host-metrics-api";
import { getSSHHosts } from "@/api/ssh-host-management-api"; import { getSSHHosts } from "@/api/ssh-host-management-api";
import type { MetricsHistoryRow } from "@/api/host-metrics-api"; import type { MetricsHistoryRow } from "@/api/host-metrics-api";
import { WidgetTitle } from "./WidgetTitle"; import { WidgetTitle } from "./WidgetTitle";
import { runVisibleInterval } from "../use-visible-interval";
function getAccentColor(): string { function getAccentColor(): string {
return ( return (
@@ -161,8 +162,9 @@ function MetricsChartWidget({
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
const iv = setInterval(fetchData, 60_000); return runVisibleInterval(() => {
return () => clearInterval(iv); void fetchData();
}, 60_000);
}, [hostId, metric, range]); }, [hostId, metric, range]);
const accent = getAccentColor(); const accent = getAccentColor();
@@ -9,6 +9,7 @@ import type {
import { GRID_SIZE } from "@/types/homepage-types"; import { GRID_SIZE } from "@/types/homepage-types";
import { homepageApi } from "@/main-axios"; import { homepageApi } from "@/main-axios";
import { WidgetTitle } from "./WidgetTitle"; import { WidgetTitle } from "./WidgetTitle";
import { runVisibleInterval } from "../use-visible-interval";
interface PingResult { interface PingResult {
ok: boolean; ok: boolean;
@@ -72,8 +73,9 @@ function PingStatusWidget({
useEffect(() => { useEffect(() => {
fetchAll(); fetchAll();
const iv = setInterval(fetchAll, interval * 1000); return runVisibleInterval(() => {
return () => clearInterval(iv); void fetchAll();
}, interval * 1000);
}, [JSON.stringify(urls), interval]); }, [JSON.stringify(urls), interval]);
if (urls.length === 0) { if (urls.length === 0) {
@@ -18,6 +18,7 @@ import { GRID_SIZE } from "@/types/homepage-types";
import { getRecentActivity } from "@/api/dashboard-api"; import { getRecentActivity } from "@/api/dashboard-api";
import type { RecentActivityItem } from "@/api/dashboard-api"; import type { RecentActivityItem } from "@/api/dashboard-api";
import { WidgetTitle } from "./WidgetTitle"; import { WidgetTitle } from "./WidgetTitle";
import { runVisibleInterval } from "../use-visible-interval";
function relativeTime(ts: string): string { function relativeTime(ts: string): string {
const diff = Date.now() - new Date(ts).getTime(); const diff = Date.now() - new Date(ts).getTime();
@@ -75,8 +76,9 @@ function RecentActivityWidget({
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
const iv = setInterval(fetchData, 60_000); return runVisibleInterval(() => {
return () => clearInterval(iv); void fetchData();
}, 60_000);
}, [maxItems, filterTypes.join(",")]); }, [maxItems, filterTypes.join(",")]);
if (loading) { if (loading) {
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useCallback, useState } from "react";
import { Zap, Terminal, FolderOpen, Container } from "lucide-react"; import { Zap, Terminal, FolderOpen, Container } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { registerWidget } from "./WidgetRegistry"; import { registerWidget } from "./WidgetRegistry";
@@ -8,9 +8,9 @@ import type {
} from "@/types/homepage-types"; } from "@/types/homepage-types";
import { GRID_SIZE } from "@/types/homepage-types"; import { GRID_SIZE } from "@/types/homepage-types";
import { getSSHHosts } from "@/api/ssh-host-management-api"; import { getSSHHosts } from "@/api/ssh-host-management-api";
import { getAllServerStatuses } from "@/api/host-metrics-status-api";
import type { SSHHostWithStatus } from "@/main-axios"; import type { SSHHostWithStatus } from "@/main-axios";
import { WidgetTitle } from "./WidgetTitle"; import { WidgetTitle } from "./WidgetTitle";
import { usePageVisibleInterval } from "@/hooks/use-page-visible-interval";
function getAccentColor(): string { function getAccentColor(): string {
return ( return (
@@ -51,40 +51,25 @@ function SshQuickConnectWidget({
const [hosts, setHosts] = useState<SSHHostWithStatus[]>([]); const [hosts, setHosts] = useState<SSHHostWithStatus[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const fetchData = async () => { const fetchData = useCallback(async () => {
try { try {
const [allHosts, statuses] = await Promise.all([ const allHosts = await getSSHHosts();
getSSHHosts(),
getAllServerStatuses().catch(
() => ({}) as Record<number, { status: string }>,
),
]);
const filtered = const filtered =
hostIds.length > 0 hostIds.length > 0
? allHosts.filter((h) => hostIds.includes(h.id)) ? allHosts.filter((h) => hostIds.includes(h.id))
: allHosts; : allHosts;
setHosts( setHosts(filtered);
filtered.map((h) => ({
...h,
status:
(statuses as Record<number, { status: string }>)[h.id]?.status ??
h.status ??
"unknown",
})),
);
} catch { } catch {
/* ignore */ /* ignore */
} finally { } finally {
setLoading(false); setLoading(false);
} }
};
useEffect(() => {
fetchData();
const iv = setInterval(fetchData, 10_000);
return () => clearInterval(iv);
}, [hostIds.join(",")]); }, [hostIds.join(",")]);
usePageVisibleInterval(() => {
void fetchData();
}, 30_000);
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground"> <div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
@@ -10,6 +10,7 @@ import { GRID_SIZE } from "@/types/homepage-types";
import { getVersionInfo, getDatabaseHealth } from "@/api/system-status-api"; import { getVersionInfo, getDatabaseHealth } from "@/api/system-status-api";
import { getUptime } from "@/api/dashboard-api"; import { getUptime } from "@/api/dashboard-api";
import { WidgetTitle } from "./WidgetTitle"; import { WidgetTitle } from "./WidgetTitle";
import { runVisibleInterval } from "../use-visible-interval";
interface InfoRowProps { interface InfoRowProps {
label: string; label: string;
@@ -65,8 +66,9 @@ function SystemOverviewWidget({
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
const iv = setInterval(fetchData, 300_000); return runVisibleInterval(() => {
return () => clearInterval(iv); void fetchData();
}, 300_000);
}, [showVersion, showDbHealth, showUptime]); }, [showVersion, showDbHealth, showUptime]);
const accent = const accent =
@@ -9,6 +9,7 @@ import type {
import { GRID_SIZE } from "@/types/homepage-types"; import { GRID_SIZE } from "@/types/homepage-types";
import { getUptime } from "@/api/dashboard-api"; import { getUptime } from "@/api/dashboard-api";
import { WidgetTitle } from "./WidgetTitle"; import { WidgetTitle } from "./WidgetTitle";
import { runVisibleInterval } from "../use-visible-interval";
function formatUptime(seconds: number): { function formatUptime(seconds: number): {
days: number; days: number;
@@ -43,14 +44,12 @@ function TermixUptimeWidget({
}) })
.catch(() => setError(true)); .catch(() => setError(true));
const iv = setInterval(() => { return runVisibleInterval(() => {
if (baseRef.current !== null) { if (baseRef.current !== null) {
const elapsed = (Date.now() - startRef.current) / 1000; const elapsed = (Date.now() - startRef.current) / 1000;
setSeconds(Math.floor(baseRef.current + elapsed)); setSeconds(Math.floor(baseRef.current + elapsed));
} }
}, 1000); }, 1000);
return () => clearInterval(iv);
}, []); }, []);
if (error) { if (error) {
@@ -14,6 +14,7 @@ import type {
} from "@/types/homepage-types"; } from "@/types/homepage-types";
import { GRID_SIZE } from "@/types/homepage-types"; import { GRID_SIZE } from "@/types/homepage-types";
import { WidgetTitle } from "./WidgetTitle"; import { WidgetTitle } from "./WidgetTitle";
import { runVisibleInterval } from "../use-visible-interval";
interface WttrCurrent { interface WttrCurrent {
temp_C: string; temp_C: string;
@@ -77,10 +78,15 @@ function WeatherWidget({
} }
}; };
fetchWeather(); fetchWeather();
const interval = setInterval(fetchWeather, 10 * 60 * 1000); const stop = runVisibleInterval(
() => {
void fetchWeather();
},
10 * 60 * 1000,
);
return () => { return () => {
cancelled = true; cancelled = true;
clearInterval(interval); stop();
}; };
}, [location]); }, [location]);
@@ -286,8 +286,8 @@ function HostMetricsInner({
}, [hostConfig?.id]); }, [hostConfig?.id]);
React.useEffect(() => { React.useEffect(() => {
if (!statusCheckEnabled || !currentHostConfig?.id) { if (!statusCheckEnabled || !currentHostConfig?.id || !isActuallyVisible) {
setServerStatus("offline"); if (!statusCheckEnabled) setServerStatus("offline");
return; return;
} }
let cancelled = false; let cancelled = false;
@@ -314,6 +314,7 @@ function HostMetricsInner({
currentHostConfig?.id, currentHostConfig?.id,
statusCheckEnabled, statusCheckEnabled,
statsConfig.statusCheckInterval, statsConfig.statusCheckInterval,
isActuallyVisible,
]); ]);
React.useEffect(() => { React.useEffect(() => {
+5 -15
View File
@@ -432,20 +432,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}, [isVisible]); }, [isVisible]);
useEffect(() => { useEffect(() => {
const checkAuth = () => { // One-shot: historical code polled every 5s but only ever flipped false→true.
setIsAuthenticated((prev) => { setIsAuthenticated(true);
if (!prev) {
return true;
}
return prev;
});
};
checkAuth();
const authCheckInterval = setInterval(checkAuth, 5000);
return () => clearInterval(authCheckInterval);
}, []); }, []);
function hardRefresh() { function hardRefresh() {
@@ -2288,9 +2276,11 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
element?.addEventListener("keydown", handleTabCapture, true); element?.addEventListener("keydown", handleTabCapture, true);
const resizeObserver = new ResizeObserver(() => { const resizeObserver = new ResizeObserver(() => {
// Background keep-alive tabs still observe layout; skip fit work.
if (!isVisibleRef.current) return;
if (resizeTimeout.current) clearTimeout(resizeTimeout.current); if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
resizeTimeout.current = setTimeout(() => { resizeTimeout.current = setTimeout(() => {
if (isVisible) { if (isVisibleRef.current) {
performFit(); performFit();
} }
}, 50); }, 50);
+2 -1
View File
@@ -241,9 +241,10 @@ export function TmuxMonitor({
// -- relative time refresh -------------------------------------------------- // -- relative time refresh --------------------------------------------------
useEffect(() => { useEffect(() => {
if (!isVisible) return;
const interval = setInterval(() => setNow(Date.now()), TIME_TICK_MS); const interval = setInterval(() => setNow(Date.now()), TIME_TICK_MS);
return () => clearInterval(interval); return () => clearInterval(interval);
}, []); }, [isVisible]);
// -- overview polling ----------------------------------------------------- // -- overview polling -----------------------------------------------------
const loadOverview = useCallback( const loadOverview = useCallback(
+37 -4
View File
@@ -239,7 +239,14 @@ function TunnelCard({
); );
} }
export function TunnelTab({ host }: { label: string; host?: DemoHost }) { export function TunnelTab({
host,
isVisible = true,
}: {
label: string;
host?: DemoHost;
isVisible?: boolean;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
const [sshHost, setSshHost] = useState<SSHHost | null>(null); const [sshHost, setSshHost] = useState<SSHHost | null>(null);
const [tunnelStatuses, setTunnelStatuses] = useState< const [tunnelStatuses, setTunnelStatuses] = useState<
@@ -264,14 +271,40 @@ export function TunnelTab({ host }: { label: string; host?: DemoHost }) {
}, [host]); }, [host]);
useEffect(() => { useEffect(() => {
if (!isVisible) return;
fetchHost(); fetchHost();
const interval = setInterval(fetchHost, 5000);
window.addEventListener("ssh-hosts:changed", fetchHost); window.addEventListener("ssh-hosts:changed", fetchHost);
let intervalId: ReturnType<typeof setInterval> | null = null;
const start = () => {
if (intervalId !== null) return;
// Host config rarely changes; tunnel runtime status uses subscribeTunnelStatuses.
intervalId = setInterval(fetchHost, 30_000);
};
const stop = () => {
if (intervalId === null) return;
clearInterval(intervalId);
intervalId = null;
};
const onVisibility = () => {
if (document.visibilityState === "hidden") {
stop();
return;
}
void fetchHost();
start();
};
if (document.visibilityState !== "hidden") start();
document.addEventListener("visibilitychange", onVisibility);
return () => { return () => {
clearInterval(interval); stop();
document.removeEventListener("visibilitychange", onVisibility);
window.removeEventListener("ssh-hosts:changed", fetchHost); window.removeEventListener("ssh-hosts:changed", fetchHost);
}; };
}, [fetchHost]); }, [fetchHost, isVisible]);
useEffect(() => { useEffect(() => {
return subscribeTunnelStatuses(setTunnelStatuses, () => {}); return subscribeTunnelStatuses(setTunnelStatuses, () => {});
+67
View File
@@ -0,0 +1,67 @@
import { useEffect, useRef } from "react";
function isDocumentVisible(): boolean {
if (typeof document === "undefined") return true;
return document.visibilityState !== "hidden";
}
type Options = {
/** Run callback once when the effect mounts (if visible). Default true. */
runOnMount?: boolean;
};
/**
* Runs `callback` on `intervalMs` while the page is visible.
* Pauses when the tab is backgrounded; fires once on resume.
*/
export function usePageVisibleInterval(
callback: () => void,
intervalMs: number,
enabled = true,
options: Options = {},
): void {
const { runOnMount = true } = options;
const callbackRef = useRef(callback);
callbackRef.current = callback;
useEffect(() => {
if (!enabled || intervalMs <= 0) return;
let intervalId: ReturnType<typeof setInterval> | null = null;
const tick = () => {
callbackRef.current();
};
const start = () => {
if (intervalId !== null) return;
intervalId = setInterval(tick, intervalMs);
};
const stop = () => {
if (intervalId === null) return;
clearInterval(intervalId);
intervalId = null;
};
const onVisibility = () => {
if (isDocumentVisible()) {
tick();
start();
} else {
stop();
}
};
if (isDocumentVisible()) {
if (runOnMount) tick();
start();
}
document.addEventListener("visibilitychange", onVisibility);
return () => {
stop();
document.removeEventListener("visibilitychange", onVisibility);
};
}, [intervalMs, enabled, runOnMount]);
}
+156 -70
View File
@@ -2,21 +2,20 @@
import React, { import React, {
createContext, createContext,
useContext, useContext,
useState,
useEffect, useEffect,
useCallback, useCallback,
useRef, useRef,
useMemo, useMemo,
useSyncExternalStore,
useState,
} from "react"; } from "react";
import { getAllServerStatuses, getSSHHosts } from "@/main-axios"; import { getAllServerStatuses, getSSHHosts } from "@/main-axios";
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets"; import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
import {
type StatusValue = "online" | "offline" | "degraded"; ServerStatusStore,
type ServerStatusEntry,
interface ServerStatusEntry { type StatusValue,
status: StatusValue; } from "./server-status-store";
lastChecked: string;
}
interface ServerStatusContextType { interface ServerStatusContextType {
statuses: Map<number, ServerStatusEntry>; statuses: Map<number, ServerStatusEntry>;
@@ -26,6 +25,8 @@ interface ServerStatusContextType {
getStatus: (hostId: number) => StatusValue; getStatus: (hostId: number) => StatusValue;
} }
/** Stable for the provider lifetime — fine-grained hooks only need this. */
const StatusStoreContext = createContext<ServerStatusStore | null>(null);
const ServerStatusContext = createContext<ServerStatusContextType | null>(null); const ServerStatusContext = createContext<ServerStatusContextType | null>(null);
const POLL_INTERVAL = 30000; const POLL_INTERVAL = 30000;
@@ -37,34 +38,47 @@ export function ServerStatusProvider({
children: React.ReactNode; children: React.ReactNode;
isAuthenticated?: boolean; isAuthenticated?: boolean;
}) { }) {
const [statuses, setStatuses] = useState<Map<number, ServerStatusEntry>>( const storeRef = useRef<ServerStatusStore | null>(null);
new Map(), if (!storeRef.current) {
); storeRef.current = new ServerStatusStore();
const [isLoading, setIsLoading] = useState(false); }
const [initialLoadComplete, setInitialLoadComplete] = useState(false); const store = storeRef.current;
const [enabledHostIds, setEnabledHostIds] = useState<Set<number>>(new Set());
// Bumps only full-context consumers (dashboard, folder counts, etc.).
const [version, setVersion] = useState(0);
const mountedRef = useRef(true); const mountedRef = useRef(true);
const enabledHostIdsRef = useRef(enabledHostIds); const refreshInFlightRef = useRef<Promise<void> | null>(null);
useEffect(() => { useEffect(() => {
enabledHostIdsRef.current = enabledHostIds; return store.subscribeAll(() => {
}, [enabledHostIds]); setVersion((v) => v + 1);
});
}, [store]);
useEffect(() => {
return store.subscribeMeta(() => {
setVersion((v) => v + 1);
});
}, [store]);
const fetchEnabledHosts = useCallback(async () => { const fetchEnabledHosts = useCallback(async () => {
if (!isAuthenticated) { if (!isAuthenticated) {
store.setEnabledHostIds(new Set());
return new Set<number>(); return new Set<number>();
} }
try { try {
const hosts = await getSSHHosts(); const hosts = await getSSHHosts({ includeStatus: false });
const enabled = new Set<number>(); const enabled = new Set<number>();
hosts.forEach((host) => { hosts.forEach((host) => {
const statsConfig = (() => { const statsConfig = (() => {
try { try {
return host.statsConfig if (!host.statsConfig) return DEFAULT_STATS_CONFIG;
? JSON.parse(host.statsConfig) if (typeof host.statsConfig === "string") {
: DEFAULT_STATS_CONFIG; return JSON.parse(host.statsConfig);
}
return host.statsConfig;
} catch { } catch {
return DEFAULT_STATS_CONFIG; return DEFAULT_STATS_CONFIG;
} }
@@ -75,23 +89,30 @@ export function ServerStatusProvider({
} }
}); });
setEnabledHostIds((prev) => { store.setEnabledHostIds(enabled);
if (prev.size !== enabled.size) return enabled;
for (const id of enabled) {
if (!prev.has(id)) return enabled;
}
return prev;
});
return enabled; return enabled;
} catch { } catch {
return new Set<number>(); return store.getEnabledHostIds();
} }
}, [isAuthenticated]); }, [isAuthenticated, store]);
const refreshStatuses = useCallback(async () => { const refreshStatuses = useCallback(async () => {
if (!mountedRef.current || !isAuthenticated) return; if (!mountedRef.current || !isAuthenticated) return;
if (
typeof document !== "undefined" &&
document.visibilityState === "hidden"
) {
return;
}
setIsLoading(true); if (refreshInFlightRef.current) {
return refreshInFlightRef.current;
}
const showLoading = !store.getInitialLoadComplete();
if (showLoading) store.setLoading(true);
const run = (async () => {
try { try {
const data = await getAllServerStatuses(); const data = await getAllServerStatuses();
if (!mountedRef.current) return; if (!mountedRef.current) return;
@@ -113,56 +134,77 @@ export function ServerStatusProvider({
}); });
} }
setStatuses(newStatuses); store.applyStatuses(newStatuses);
} catch { } catch {
if (mountedRef.current) { if (mountedRef.current) {
setStatuses((prev) => { store.markDegraded(store.getEnabledHostIds());
const updated = new Map(prev);
enabledHostIdsRef.current.forEach((id) => {
const existing = updated.get(id);
updated.set(id, {
status: "degraded",
lastChecked: existing?.lastChecked || new Date().toISOString(),
});
});
return updated;
});
} }
} finally { } finally {
if (mountedRef.current) { if (mountedRef.current) {
setIsLoading(false); if (showLoading) store.setLoading(false);
setInitialLoadComplete(true); store.setInitialLoadComplete(true);
} }
} }
}, [isAuthenticated]); })();
const stableEnabledHostIds = useMemo(() => enabledHostIds, [enabledHostIds]); refreshInFlightRef.current = run.finally(() => {
refreshInFlightRef.current = null;
});
return refreshInFlightRef.current;
}, [isAuthenticated, store]);
const getStatus = useCallback( const getStatus = useCallback(
(hostId: number): StatusValue => { (hostId: number): StatusValue => store.getStatus(hostId),
if (!stableEnabledHostIds.has(hostId)) { [store],
return "offline";
}
return statuses.get(hostId)?.status || "degraded";
},
[statuses, stableEnabledHostIds],
); );
useEffect(() => { useEffect(() => {
mountedRef.current = true; mountedRef.current = true;
let intervalId: ReturnType<typeof setInterval> | null = null;
const startPolling = () => {
if (intervalId !== null) return;
intervalId = setInterval(() => {
void refreshStatuses();
}, POLL_INTERVAL);
};
const stopPolling = () => {
if (intervalId === null) return;
clearInterval(intervalId);
intervalId = null;
};
const init = async () => { const init = async () => {
await fetchEnabledHosts(); await fetchEnabledHosts();
await refreshStatuses(); await refreshStatuses();
if (
mountedRef.current &&
(typeof document === "undefined" ||
document.visibilityState !== "hidden")
) {
startPolling();
}
}; };
init(); void init();
const intervalId = setInterval(refreshStatuses, POLL_INTERVAL); const onVisibility = () => {
if (document.visibilityState === "hidden") {
stopPolling();
return;
}
void refreshStatuses();
startPolling();
};
document.addEventListener("visibilitychange", onVisibility);
return () => { return () => {
mountedRef.current = false; mountedRef.current = false;
clearInterval(intervalId); stopPolling();
document.removeEventListener("visibilitychange", onVisibility);
}; };
}, [fetchEnabledHosts, refreshStatuses]); }, [fetchEnabledHosts, refreshStatuses]);
@@ -181,21 +223,36 @@ export function ServerStatusProvider({
}; };
}, [fetchEnabledHosts, refreshStatuses]); }, [fetchEnabledHosts, refreshStatuses]);
return ( const contextValue = useMemo(
<ServerStatusContext.Provider () => ({
value={{ statuses: store.getStatuses(),
statuses, isLoading: store.getIsLoading(),
isLoading, initialLoadComplete: store.getInitialLoadComplete(),
initialLoadComplete,
refreshStatuses, refreshStatuses,
getStatus, getStatus,
}} }),
> // version refreshes statuses/isLoading/initialLoadComplete snapshots
// eslint-disable-next-line react-hooks/exhaustive-deps
[version, refreshStatuses, getStatus],
);
return (
<StatusStoreContext.Provider value={store}>
<ServerStatusContext.Provider value={contextValue}>
{children} {children}
</ServerStatusContext.Provider> </ServerStatusContext.Provider>
</StatusStoreContext.Provider>
); );
} }
function useStatusStore(): ServerStatusStore {
const store = useContext(StatusStoreContext);
if (!store) {
throw new Error("Server status store hooks require ServerStatusProvider");
}
return store;
}
export function useServerStatus() { export function useServerStatus() {
const context = useContext(ServerStatusContext); const context = useContext(ServerStatusContext);
if (!context) { if (!context) {
@@ -206,15 +263,44 @@ export function useServerStatus() {
return context; return context;
} }
/**
* Subscribe to a single host's status. Only re-renders when that host's
* status value changes (or its enabled flag flips). Does not re-render when
* other hosts update.
*/
export function useHostStatus( export function useHostStatus(
hostId: number, hostId: number,
statusCheckEnabled: boolean = true, statusCheckEnabled: boolean = true,
) { ): StatusValue | null {
const { getStatus } = useServerStatus(); const store = useStatusStore();
const status = useSyncExternalStore(
(onChange) => store.subscribeHost(hostId, onChange),
() => store.getHostSnapshot(hostId),
() => store.getHostSnapshot(hostId),
);
if (!statusCheckEnabled) { if (!statusCheckEnabled) {
return "offline" as StatusValue; return null;
} }
return status;
return getStatus(hostId); }
/** Meta flags without depending on the full status map. */
export function useServerStatusMeta(): {
initialLoadComplete: boolean;
isLoading: boolean;
} {
const store = useStatusStore();
useSyncExternalStore(
(onChange) => store.subscribeMeta(onChange),
() => store.getMetaSnapshot(),
() => store.getMetaSnapshot(),
);
return {
initialLoadComplete: store.getInitialLoadComplete(),
isLoading: store.getIsLoading(),
};
} }
+53
View File
@@ -0,0 +1,53 @@
import type { SSHHost } from "@/types/index";
import type { ServerStatus } from "@/main-axios";
import { createTtlRequestCache } from "./ttl-request-cache";
/** Host list changes less often than status; keep a short shared window. */
const HOSTS_TTL_MS = 10_000;
/** Status polls stack from many UI surfaces; short TTL + inflight dedupe. */
const STATUS_TTL_MS = 3_000;
const hostsCache = createTtlRequestCache<SSHHost[]>(HOSTS_TTL_MS);
const statusCache =
createTtlRequestCache<Record<number, ServerStatus>>(STATUS_TTL_MS);
let listenersBound = false;
function bindInvalidationListeners(): void {
if (listenersBound || typeof window === "undefined") return;
listenersBound = true;
const invalidateHosts = () => {
hostsCache.invalidate();
};
window.addEventListener("ssh-hosts:changed", invalidateHosts);
window.addEventListener("hosts:refresh", invalidateHosts);
}
export function getCachedSSHHosts(
loader: () => Promise<SSHHost[]>,
): Promise<SSHHost[]> {
bindInvalidationListeners();
return hostsCache.get(loader);
}
export function getCachedServerStatuses(
loader: () => Promise<Record<number, ServerStatus>>,
): Promise<Record<number, ServerStatus>> {
return statusCache.get(loader);
}
export function invalidateSSHHostsCache(): void {
hostsCache.invalidate();
}
export function invalidateServerStatusCache(): void {
statusCache.invalidate();
}
/** Drop both caches after host mutations so the next read is fresh. */
export function invalidateHostsAndStatusCaches(): void {
hostsCache.invalidate();
statusCache.invalidate();
}
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, it, vi } from "vitest";
import { ServerStatusStore } from "./server-status-store";
describe("ServerStatusStore", () => {
it("notifies only listeners for hosts whose status value changed", () => {
const store = new ServerStatusStore();
store.setEnabledHostIds(new Set([1, 2, 3]));
const on1 = vi.fn();
const on2 = vi.fn();
const onAll = vi.fn();
store.subscribeHost(1, on1);
store.subscribeHost(2, on2);
store.subscribeAll(onAll);
store.applyStatuses(
new Map([
[1, { status: "online", lastChecked: "t1" }],
[2, { status: "offline", lastChecked: "t1" }],
[3, { status: "online", lastChecked: "t1" }],
]),
);
expect(on1).toHaveBeenCalledTimes(1);
expect(on2).toHaveBeenCalledTimes(1);
expect(onAll).toHaveBeenCalledTimes(1);
on1.mockClear();
on2.mockClear();
onAll.mockClear();
// Only host 2 flips; host 1 lastChecked changes but status stays online.
store.applyStatuses(
new Map([
[1, { status: "online", lastChecked: "t2" }],
[2, { status: "online", lastChecked: "t2" }],
[3, { status: "online", lastChecked: "t2" }],
]),
);
expect(on1).not.toHaveBeenCalled();
expect(on2).toHaveBeenCalledTimes(1);
expect(onAll).toHaveBeenCalledTimes(1);
});
it("does not notify when the status map is unchanged", () => {
const store = new ServerStatusStore();
store.setEnabledHostIds(new Set([1]));
store.applyStatuses(
new Map([[1, { status: "online", lastChecked: "t1" }]]),
);
const on1 = vi.fn();
store.subscribeHost(1, on1);
store.applyStatuses(
new Map([[1, { status: "online", lastChecked: "t9" }]]),
);
expect(on1).not.toHaveBeenCalled();
expect(store.getStatus(1)).toBe("online");
});
it("returns offline for disabled hosts", () => {
const store = new ServerStatusStore();
store.applyStatuses(new Map([[5, { status: "online", lastChecked: "t" }]]));
expect(store.getStatus(5)).toBe("offline");
store.setEnabledHostIds(new Set([5]));
expect(store.getStatus(5)).toBe("online");
});
it("emits meta listeners when initial load completes", () => {
const store = new ServerStatusStore();
const onMeta = vi.fn();
store.subscribeMeta(onMeta);
store.setInitialLoadComplete(true);
expect(onMeta).toHaveBeenCalledTimes(1);
store.setInitialLoadComplete(true);
expect(onMeta).toHaveBeenCalledTimes(1);
});
});
+184
View File
@@ -0,0 +1,184 @@
export type StatusValue = "online" | "offline" | "degraded";
export interface ServerStatusEntry {
status: StatusValue;
lastChecked: string;
}
type Listener = () => void;
/**
* Mutable store for per-host status so list rows can subscribe to a single host
* via useSyncExternalStore instead of re-rendering the whole tree on every poll.
*/
export class ServerStatusStore {
private statuses = new Map<number, ServerStatusEntry>();
private enabledHostIds = new Set<number>();
private initialLoadComplete = false;
private isLoading = false;
private readonly hostListeners = new Map<number, Set<Listener>>();
private readonly allListeners = new Set<Listener>();
private readonly metaListeners = new Set<Listener>();
getStatus(hostId: number): StatusValue {
if (!this.enabledHostIds.has(hostId)) {
return "offline";
}
return this.statuses.get(hostId)?.status || "degraded";
}
getStatuses(): Map<number, ServerStatusEntry> {
return this.statuses;
}
getEnabledHostIds(): Set<number> {
return this.enabledHostIds;
}
getInitialLoadComplete(): boolean {
return this.initialLoadComplete;
}
getIsLoading(): boolean {
return this.isLoading;
}
/** Snapshot string for a host — used as useSyncExternalStore getSnapshot. */
getHostSnapshot(hostId: number): StatusValue {
return this.getStatus(hostId);
}
getMetaSnapshot(): string {
return `${this.initialLoadComplete ? 1 : 0}:${this.isLoading ? 1 : 0}:${this.enabledHostIds.size}`;
}
subscribeHost(hostId: number, listener: Listener): () => void {
let set = this.hostListeners.get(hostId);
if (!set) {
set = new Set();
this.hostListeners.set(hostId, set);
}
set.add(listener);
return () => {
set!.delete(listener);
if (set!.size === 0) {
this.hostListeners.delete(hostId);
}
};
}
subscribeAll(listener: Listener): () => void {
this.allListeners.add(listener);
return () => {
this.allListeners.delete(listener);
};
}
subscribeMeta(listener: Listener): () => void {
this.metaListeners.add(listener);
return () => {
this.metaListeners.delete(listener);
};
}
setEnabledHostIds(next: Set<number>): void {
if (setsEqual(this.enabledHostIds, next)) return;
const prev = this.enabledHostIds;
this.enabledHostIds = next;
this.emitMeta();
// Hosts that entered/left the enabled set need a status re-read.
for (const id of next) {
if (!prev.has(id)) this.emitHost(id);
}
for (const id of prev) {
if (!next.has(id)) this.emitHost(id);
}
this.emitAll();
}
setLoading(loading: boolean): void {
if (this.isLoading === loading) return;
this.isLoading = loading;
this.emitMeta();
this.emitAll();
}
setInitialLoadComplete(complete: boolean): void {
if (this.initialLoadComplete === complete) return;
this.initialLoadComplete = complete;
this.emitMeta();
this.emitAll();
}
/**
* Replace status map. Notifies only hosts whose status value changed,
* plus allListeners when any change occurred.
*/
applyStatuses(next: Map<number, ServerStatusEntry>): void {
const changedIds: number[] = [];
for (const [id, entry] of next) {
const prev = this.statuses.get(id);
if (!prev || prev.status !== entry.status) {
changedIds.push(id);
}
}
for (const id of this.statuses.keys()) {
if (!next.has(id)) {
changedIds.push(id);
}
}
if (changedIds.length === 0) {
// Still refresh lastChecked silently without notifying.
this.statuses = next;
return;
}
this.statuses = next;
for (const id of changedIds) {
this.emitHost(id);
}
this.emitAll();
}
markDegraded(enabledIds: Iterable<number>): void {
const next = new Map(this.statuses);
let changed = false;
const now = new Date().toISOString();
for (const id of enabledIds) {
const existing = next.get(id);
if (existing?.status === "degraded") continue;
changed = true;
next.set(id, {
status: "degraded",
lastChecked: existing?.lastChecked || now,
});
}
if (changed) {
this.applyStatuses(next);
}
}
private emitHost(hostId: number): void {
const set = this.hostListeners.get(hostId);
if (!set) return;
for (const listener of set) listener();
}
private emitAll(): void {
for (const listener of this.allListeners) listener();
}
private emitMeta(): void {
for (const listener of this.metaListeners) listener();
}
}
function setsEqual(a: Set<number>, b: Set<number>): boolean {
if (a.size !== b.size) return false;
for (const id of a) {
if (!b.has(id)) return false;
}
return true;
}
+72
View File
@@ -0,0 +1,72 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createTtlRequestCache } from "./ttl-request-cache";
afterEach(() => {
vi.useRealTimers();
});
describe("createTtlRequestCache", () => {
it("dedupes concurrent loaders into a single request", async () => {
const cache = createTtlRequestCache<string>(5_000);
let calls = 0;
const loader = vi.fn(async () => {
calls += 1;
await new Promise((r) => setTimeout(r, 20));
return "ok";
});
const [a, b] = await Promise.all([cache.get(loader), cache.get(loader)]);
expect(a).toBe("ok");
expect(b).toBe("ok");
expect(calls).toBe(1);
expect(loader).toHaveBeenCalledTimes(1);
});
it("serves cached value within TTL without reloading", async () => {
const cache = createTtlRequestCache<number>(10_000);
const loader = vi.fn(async () => 42);
await cache.get(loader);
await cache.get(loader);
expect(loader).toHaveBeenCalledTimes(1);
expect(cache.peek()).toBe(42);
});
it("reloads after TTL expires", async () => {
vi.useFakeTimers();
const cache = createTtlRequestCache<number>(1_000);
const loader = vi.fn(async () => Date.now());
await cache.get(loader);
expect(loader).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1_001);
await cache.get(loader);
expect(loader).toHaveBeenCalledTimes(2);
});
it("does not cache rejected loaders", async () => {
const cache = createTtlRequestCache<string>(5_000);
const loader = vi
.fn()
.mockRejectedValueOnce(new Error("boom"))
.mockResolvedValueOnce("recovered");
await expect(cache.get(loader)).rejects.toThrow("boom");
await expect(cache.get(loader)).resolves.toBe("recovered");
expect(loader).toHaveBeenCalledTimes(2);
});
it("invalidate drops the cached entry", async () => {
const cache = createTtlRequestCache<string>(5_000);
const loader = vi.fn(async () => "v1");
await cache.get(loader);
cache.invalidate();
expect(cache.peek()).toBeNull();
loader.mockResolvedValueOnce("v2");
await expect(cache.get(loader)).resolves.toBe("v2");
expect(loader).toHaveBeenCalledTimes(2);
});
});
+48
View File
@@ -0,0 +1,48 @@
/**
* Short-lived in-memory cache with in-flight request deduplication.
* Concurrent callers share one loader promise; failures are not cached.
*/
export type TtlRequestCache<T> = {
get(loader: () => Promise<T>): Promise<T>;
invalidate(): void;
peek(): T | null;
};
export function createTtlRequestCache<T>(ttlMs: number): TtlRequestCache<T> {
let cached: { value: T; expiresAt: number } | null = null;
let inflight: Promise<T> | null = null;
return {
get(loader: () => Promise<T>): Promise<T> {
if (cached && Date.now() < cached.expiresAt) {
return Promise.resolve(cached.value);
}
if (inflight) {
return inflight;
}
inflight = (async () => {
try {
const value = await loader();
cached = { value, expiresAt: Date.now() + ttlMs };
return value;
} finally {
inflight = null;
}
})();
return inflight;
},
invalidate(): void {
cached = null;
},
peek(): T | null {
if (cached && Date.now() < cached.expiresAt) {
return cached.value;
}
return null;
},
};
}
+106 -34
View File
@@ -15,32 +15,86 @@ import {
TerminalSquare, TerminalSquare,
Layers, // --- tmux-monitor --- Layers, // --- tmux-monitor ---
} from "lucide-react"; } from "lucide-react";
import { lazy, Suspense } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CommandHistoryProvider } from "@/features/terminal/command-history/CommandHistoryContext";
import { Serial } from "@/features/serial/Serial";
import type { SerialHandle } from "@/features/serial/serial-types"; import type { SerialHandle } from "@/features/serial/serial-types";
import { Terminal as TerminalFeature } from "@/features/terminal/Terminal";
import type { import type {
TerminalHandle, TerminalHandle,
TerminalHostConfig, TerminalHostConfig,
} from "@/features/terminal/Terminal"; } from "@/features/terminal/Terminal";
import { MobileTerminalKeyboard } from "@/features/terminal/MobileTerminalKeyboard";
import { useIsMobile } from "@/hooks/use-mobile";
import { FileManager } from "@/features/file-manager/FileManager";
import { DockerManager } from "@/features/docker/DockerManager";
import { HostMetricsTab } from "@/features/host-metrics/HostMetricsTab";
// --- tmux-monitor ---
import { TmuxMonitor } from "@/features/tmux-monitor/TmuxMonitor";
import GuacamoleApp from "@/features/guacamole/GuacamoleApp";
import type { GuacamoleAppHandle } from "@/features/guacamole/GuacamoleApp"; import type { GuacamoleAppHandle } from "@/features/guacamole/GuacamoleApp";
import { DashboardTab } from "@/dashboard/DashboardTab"; import { useIsMobile } from "@/hooks/use-mobile";
import { HomepageCanvas } from "@/features/homepage/HomepageCanvas";
import { TunnelTab } from "@/features/tunnel/TunnelTab";
import { NetworkGraphCard } from "@/dashboard/cards/NetworkGraphCard";
import type { Tab, TabType, Host } from "@/types/ui-types"; import type { Tab, TabType, Host } from "@/types/ui-types";
import type { SSHHost } from "@/types"; import type { SSHHost } from "@/types";
import { useTabsSafe } from "@/shell/TabContext"; import { useTabsSafe } from "@/shell/TabContext";
// Heavy tab surfaces — keep out of the AppShell critical path.
const CommandHistoryProvider = lazy(() =>
import("@/features/terminal/command-history/CommandHistoryContext").then(
(m) => ({ default: m.CommandHistoryProvider }),
),
);
const TerminalFeature = lazy(() =>
import("@/features/terminal/Terminal").then((m) => ({
default: m.Terminal,
})),
);
const MobileTerminalKeyboard = lazy(() =>
import("@/features/terminal/MobileTerminalKeyboard").then((m) => ({
default: m.MobileTerminalKeyboard,
})),
);
const FileManager = lazy(() =>
import("@/features/file-manager/FileManager").then((m) => ({
default: m.FileManager,
})),
);
const DockerManager = lazy(() =>
import("@/features/docker/DockerManager").then((m) => ({
default: m.DockerManager,
})),
);
const HostMetricsTab = lazy(() =>
import("@/features/host-metrics/HostMetricsTab").then((m) => ({
default: m.HostMetricsTab,
})),
);
const TmuxMonitor = lazy(() =>
import("@/features/tmux-monitor/TmuxMonitor").then((m) => ({
default: m.TmuxMonitor,
})),
);
const GuacamoleApp = lazy(() =>
import("@/features/guacamole/GuacamoleApp").then((m) => ({
default: m.default,
})),
);
const DashboardTab = lazy(() =>
import("@/dashboard/DashboardTab").then((m) => ({
default: m.DashboardTab,
})),
);
const HomepageCanvas = lazy(() =>
import("@/features/homepage/HomepageCanvas").then((m) => ({
default: m.HomepageCanvas,
})),
);
const TunnelTab = lazy(() =>
import("@/features/tunnel/TunnelTab").then((m) => ({
default: m.TunnelTab,
})),
);
const NetworkGraphCard = lazy(() =>
import("@/dashboard/cards/NetworkGraphCard").then((m) => ({
default: m.NetworkGraphCard,
})),
);
const Serial = lazy(() =>
import("@/features/serial/Serial").then((m) => ({
default: m.Serial,
})),
);
function hostToSSHHost(h: Host): SSHHost { function hostToSSHHost(h: Host): SSHHost {
return { return {
id: parseInt(h.id, 10), id: parseInt(h.id, 10),
@@ -95,6 +149,18 @@ function EmptyState({
); );
} }
function TabChunkFallback() {
return (
<div className="flex h-full w-full items-center justify-center bg-background">
<div className="size-5 rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground/70 animate-spin" />
</div>
);
}
function withTabSuspense(node: React.ReactNode) {
return <Suspense fallback={<TabChunkFallback />}>{node}</Suspense>;
}
export function tabIcon(type: TabType) { export function tabIcon(type: TabType) {
switch (type) { switch (type) {
case "dashboard": case "dashboard":
@@ -154,7 +220,7 @@ function TerminalTabContent({
}) { }) {
const { previewTerminalTheme } = useTabsSafe(); const { previewTerminalTheme } = useTabsSafe();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
return ( return withTabSuspense(
<CommandHistoryProvider> <CommandHistoryProvider>
<div className="flex flex-col h-full w-full"> <div className="flex flex-col h-full w-full">
<div className="flex-1 min-h-0"> <div className="flex-1 min-h-0">
@@ -192,7 +258,7 @@ function TerminalTabContent({
/> />
)} )}
</div> </div>
</CommandHistoryProvider> </CommandHistoryProvider>,
); );
} }
@@ -211,11 +277,12 @@ export function renderTabContent(
switch (tab.type) { switch (tab.type) {
case "dashboard": case "dashboard":
return ( return withTabSuspense(
<DashboardTab <DashboardTab
onOpenSingletonTab={onOpenSingletonTab!} onOpenSingletonTab={onOpenSingletonTab!}
onOpenTab={onOpenTab!} onOpenTab={onOpenTab!}
/> isVisible={isVisible}
/>,
); );
case "terminal": case "terminal":
@@ -253,29 +320,30 @@ export function renderTabContent(
messageKey="fileManager.noHostSelected" messageKey="fileManager.noHostSelected"
/> />
); );
return ( return withTabSuspense(
<FileManager <FileManager
initialHost={hostToSSHHost(host)} initialHost={hostToSSHHost(host)}
initialFilePath={tab.initialFilePath} initialFilePath={tab.initialFilePath}
isVisible={isVisible}
onOpenTerminalTab={ onOpenTerminalTab={
onOpenTerminalTab onOpenTerminalTab
? (path) => onOpenTerminalTab(host, path) ? (path) => onOpenTerminalTab(host, path)
: undefined : undefined
} }
/> />,
); );
case "docker": case "docker":
if (!host) if (!host)
return <EmptyState icon={Box} messageKey="docker.noHostSelected" />; return <EmptyState icon={Box} messageKey="docker.noHostSelected" />;
return ( return withTabSuspense(
<DockerManager <DockerManager
hostConfig={hostToSSHHost(host)} hostConfig={hostToSSHHost(host)}
title={label} title={label}
isVisible={isVisible} isVisible={isVisible}
isTopbarOpen={false} isTopbarOpen={false}
embedded={true} embedded={true}
/> />,
); );
case "host-metrics": case "host-metrics":
@@ -283,18 +351,20 @@ export function renderTabContent(
return ( return (
<EmptyState icon={Activity} messageKey="hostMetrics.noHostSelected" /> <EmptyState icon={Activity} messageKey="hostMetrics.noHostSelected" />
); );
return ( return withTabSuspense(
<HostMetricsTab <HostMetricsTab
hostConfig={hostToSSHHost(host)} hostConfig={hostToSSHHost(host)}
title={label} title={label}
isVisible={isVisible} isVisible={isVisible}
isTopbarOpen={false} isTopbarOpen={false}
embedded={true} embedded={true}
/> />,
); );
case "tunnel": case "tunnel":
return <TunnelTab label={label} host={host} />; return withTabSuspense(
<TunnelTab label={label} host={host} isVisible={isVisible} />,
);
case "rdp": case "rdp":
case "vnc": case "vnc":
@@ -303,41 +373,43 @@ export function renderTabContent(
return ( return (
<EmptyState icon={Monitor} messageKey="guacamole.noHostSelected" /> <EmptyState icon={Monitor} messageKey="guacamole.noHostSelected" />
); );
return ( return withTabSuspense(
<GuacamoleApp <GuacamoleApp
ref={tab.terminalRef as React.Ref<GuacamoleAppHandle>} ref={tab.terminalRef as React.Ref<GuacamoleAppHandle>}
hostId={host.id} hostId={host.id}
tabId={tab.id} tabId={tab.id}
protocol={tab.type as "rdp" | "vnc" | "telnet"} protocol={tab.type as "rdp" | "vnc" | "telnet"}
/> />,
); );
case "network_graph": case "network_graph":
return <NetworkGraphCard embedded={false} />; return withTabSuspense(
<NetworkGraphCard embedded={false} isVisible={isVisible} />,
);
// --- tmux-monitor --- // --- tmux-monitor ---
case "tmux_monitor": case "tmux_monitor":
return ( return withTabSuspense(
<TmuxMonitor <TmuxMonitor
initialHostId={host ? parseInt(host.id, 10) : undefined} initialHostId={host ? parseInt(host.id, 10) : undefined}
isVisible={isVisible} isVisible={isVisible}
/> />,
); );
case "serial": case "serial":
if (!tab.serialConfig) if (!tab.serialConfig)
return <EmptyState icon={Usb} messageKey="serial.notSupportedTitle" />; return <EmptyState icon={Usb} messageKey="serial.notSupportedTitle" />;
return ( return withTabSuspense(
<Serial <Serial
ref={tab.terminalRef as React.Ref<SerialHandle>} ref={tab.terminalRef as React.Ref<SerialHandle>}
config={tab.serialConfig} config={tab.serialConfig}
isVisible={isVisible} isVisible={isVisible}
instanceId={tab.instanceId} instanceId={tab.instanceId}
/> />,
); );
case "homepage": case "homepage":
return <HomepageCanvas />; return withTabSuspense(<HomepageCanvas />);
case "host-manager": case "host-manager":
case "user-profile": case "user-profile":
+27 -2
View File
@@ -171,18 +171,43 @@ export function AppRail({
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
let intervalId: ReturnType<typeof setInterval> | null = null;
const poll = () => { const poll = () => {
if (document.visibilityState === "hidden") return;
getAlertFirings({ acknowledged: false, limit: 50 }) getAlertFirings({ acknowledged: false, limit: 50 })
.then((firings) => { .then((firings) => {
if (!cancelled) setUnreadAlerts(firings.length); if (!cancelled) setUnreadAlerts(firings.length);
}) })
.catch(() => {}); .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(); poll();
const iv = setInterval(poll, 30000); start();
};
poll();
if (document.visibilityState !== "hidden") start();
document.addEventListener("visibilitychange", onVisibility);
return () => { return () => {
cancelled = true; cancelled = true;
clearInterval(iv); stop();
document.removeEventListener("visibilitychange", onVisibility);
}; };
}, []); }, []);
const [hiddenTabs, setHiddenTabs] = useState<Set<string>>(() => { const [hiddenTabs, setHiddenTabs] = useState<Set<string>>(() => {
+30 -13
View File
@@ -17,6 +17,7 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/tooltip"; } from "@/components/tooltip";
import { usePageVisibleInterval } from "@/hooks/use-page-visible-interval";
const CONNECTION_TAB_TYPES: TabType[] = [ const CONNECTION_TAB_TYPES: TabType[] = [
"terminal", "terminal",
@@ -49,6 +50,28 @@ function formatDuration(ms: number): string {
return `${h}h ${m % 60}m`; 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 { function formatExpiry(updatedAt: string): string {
const TTL_MS = 30 * 60 * 1000; const TTL_MS = 30 * 60 * 1000;
const elapsed = Date.now() - new Date(updatedAt).getTime(); const elapsed = Date.now() - new Date(updatedAt).getTime();
@@ -285,7 +308,6 @@ export function ConnectionsPanel({
const [now, setNow] = useState(Date.now()); const [now, setNow] = useState(Date.now());
const [activeSessions, setActiveSessions] = useState<ActiveSessionInfo[]>([]); const [activeSessions, setActiveSessions] = useState<ActiveSessionInfo[]>([]);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Drag-to-reorder state // Drag-to-reorder state
const [dragTabId, setDragTabId] = useState<string | null>(null); const [dragTabId, setDragTabId] = useState<string | null>(null);
@@ -322,17 +344,15 @@ export function ConnectionsPanel({
}) })
: backgroundTabs; : backgroundTabs;
useEffect(() => { // Duration labels only need minute-level freshness; 1s ticks re-render the whole panel.
const id = setInterval(() => setNow(Date.now()), 1000); usePageVisibleInterval(() => setNow(Date.now()), 15_000);
return () => clearInterval(id);
}, []);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
try { try {
const sessions = await getActiveSessions(); const sessions = await getActiveSessions();
setActiveSessions((prev) => { setActiveSessions((prev) => {
const next = Array.isArray(sessions) ? sessions : []; const next = Array.isArray(sessions) ? sessions : [];
if (JSON.stringify(prev) === JSON.stringify(next)) return prev; if (sessionsUnchanged(prev, next)) return prev;
return next; return next;
}); });
} catch { } catch {
@@ -340,13 +360,10 @@ export function ConnectionsPanel({
} }
}, []); }, []);
useEffect(() => { // Initial fetch + visibility-aware poll (hook fires once on mount).
refresh(); usePageVisibleInterval(() => {
pollTimerRef.current = setInterval(refresh, 5000); void refresh();
return () => { }, 5_000);
if (pollTimerRef.current) clearInterval(pollTimerRef.current);
};
}, [refresh]);
// Global pointer listeners for drag reorder // Global pointer listeners for drag reorder
useEffect(() => { useEffect(() => {
+17 -1
View File
@@ -292,9 +292,25 @@ export function HostsPanel({
} }
useEffect(() => { useEffect(() => {
let cancelled = false;
const load = () => {
getSSHHosts() getSSHHosts()
.then(setRawHosts) .then((hosts) => {
if (!cancelled) setRawHosts(hosts);
})
.catch(() => {}); .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) { function handleEditingChange(editing: boolean) {
+14 -5
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { copyToClipboard } from "@/lib/clipboard"; import { copyToClipboard } from "@/lib/clipboard";
import { usePageVisibleInterval } from "@/hooks/use-page-visible-interval";
import { Input } from "@/components/input"; import { Input } from "@/components/input";
import { import {
Tooltip, Tooltip,
@@ -190,10 +191,11 @@ export function SessionLogsPanel() {
.then((fresh) => { .then((fresh) => {
setLogs((prev) => { setLogs((prev) => {
if ( if (
JSON.stringify(prev.map((l) => l.id)) === prev.length === fresh.length &&
JSON.stringify(fresh.map((l) => l.id)) prev.every((log, i) => log.id === fresh[i]?.id)
) ) {
return prev; return prev;
}
return fresh; return fresh;
}); });
}) })
@@ -212,10 +214,17 @@ export function SessionLogsPanel() {
getSessionRecordingRetention() getSessionRecordingRetention()
.then(setRetentionDays) .then(setRetentionDays)
.catch(() => setRetentionDays(null)); .catch(() => setRetentionDays(null));
const interval = setInterval(() => load(false), 5000);
return () => clearInterval(interval);
}, [load]); }, [load]);
usePageVisibleInterval(
() => {
void load(false);
},
5_000,
true,
{ runOnMount: false },
);
const q = filter.trim().toLowerCase(); const q = filter.trim().toLowerCase();
const filtered = q const filtered = q
? logs.filter((l) => ? logs.filter((l) =>
+110 -34
View File
@@ -1,6 +1,13 @@
/* eslint-disable react-refresh/only-export-components */ /* 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 { useTranslation } from "react-i18next";
import { useVirtualizer } from "@tanstack/react-virtual";
import { import {
Box, Box,
Boxes, Boxes,
@@ -62,7 +69,11 @@ import {
useStatusColorScheme, useStatusColorScheme,
getStatusClasses, getStatusClasses,
} from "@/hooks/use-status-color-scheme"; } from "@/hooks/use-status-color-scheme";
import { useServerStatus } from "@/lib/ServerStatusContext"; import {
useHostStatus,
useServerStatus,
useServerStatusMeta,
} from "@/lib/ServerStatusContext";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -271,6 +282,7 @@ export function HostItem({
onTrayOpenChange, onTrayOpenChange,
onDragStart, onDragStart,
onDragEnd, onDragEnd,
depth = 0,
}: { }: {
host: Host; host: Host;
onOpenTab: (type: TabType) => void; onOpenTab: (type: TabType) => void;
@@ -290,6 +302,8 @@ export function HostItem({
onProxmoxDiscover?: () => void; onProxmoxDiscover?: () => void;
onDragStart?: () => void; onDragStart?: () => void;
onDragEnd?: () => void; onDragEnd?: () => void;
/** Nesting level when rendered in a flattened virtual list. */
depth?: number;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const metricsEnabled = const metricsEnabled =
@@ -305,10 +319,11 @@ export function HostItem({
() => localStorage.getItem("compactHostView") === "true", () => localStorage.getItem("compactHostView") === "true",
); );
const statusScheme = useStatusColorScheme(); const statusScheme = useStatusColorScheme();
const { initialLoadComplete, getStatus } = useServerStatus(); const { initialLoadComplete } = useServerStatusMeta();
const statusCheckOn = statusCheckEnabled(host); const statusCheckOn = statusCheckEnabled(host);
const statusLoading = !initialLoadComplete && statusCheckOn; 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 isOnline = liveStatus != null ? liveStatus === "online" : host.online;
const isTouchOnly = const isTouchOnly =
typeof window !== "undefined" && window.matchMedia("(hover: none)").matches; typeof window !== "undefined" && window.matchMedia("(hover: none)").matches;
@@ -372,6 +387,9 @@ export function HostItem({
if (query && !hostMatchesQuery(host, query)) return null; if (query && !hostMatchesQuery(host, query)) return null;
const depthStyle =
depth > 0 ? ({ paddingLeft: depth * 12 } as const) : undefined;
if (compactHostView) { if (compactHostView) {
return ( return (
<div <div
@@ -381,6 +399,7 @@ export function HostItem({
onDragStart?.(); onDragStart?.();
}} }}
onDragEnd={() => onDragEnd?.()} onDragEnd={() => onDragEnd?.()}
style={depthStyle}
className={`group relative flex items-stretch cursor-pointer select-none transition-colors hover:bg-muted/40 ${ className={`group relative flex items-stretch cursor-pointer select-none transition-colors hover:bg-muted/40 ${
selected selected
? "bg-accent-brand/5" ? "bg-accent-brand/5"
@@ -882,6 +901,7 @@ export function HostItem({
onDragStart?.(); onDragStart?.();
}} }}
onDragEnd={() => onDragEnd?.()} onDragEnd={() => onDragEnd?.()}
style={depthStyle}
className={`group relative flex items-stretch cursor-pointer select-none transition-colors hover:bg-muted/40 ${ className={`group relative flex items-stretch cursor-pointer select-none transition-colors hover:bg-muted/40 ${
selected selected
? "bg-accent-brand/5" ? "bg-accent-brand/5"
@@ -1483,6 +1503,9 @@ export function FolderItem({
draggedHostIds, draggedHostIds,
onDragHostStart, onDragHostStart,
onDragEnd, onDragEnd,
/** When true, only render the folder header (children come from the virtual list). */
flat = false,
stripeIndex: stripeIndexProp,
}: { }: {
folder: HostFolder; folder: HostFolder;
depth?: number; depth?: number;
@@ -1493,7 +1516,7 @@ export function FolderItem({
onDuplicateHost: (host: Host) => void; onDuplicateHost: (host: Host) => void;
onProxmoxDiscover?: (host: Host) => void; onProxmoxDiscover?: (host: Host) => void;
query?: string; query?: string;
stripeMap: Map<Host | HostFolder, number>; stripeMap?: Map<Host | HostFolder, number>;
openFolders: Set<string>; openFolders: Set<string>;
onToggleFolder: (name: string) => void; onToggleFolder: (name: string) => void;
selectionMode: boolean; selectionMode: boolean;
@@ -1510,6 +1533,8 @@ export function FolderItem({
draggedHostIds: string[] | null; draggedHostIds: string[] | null;
onDragHostStart: (hostId: string) => void; onDragHostStart: (hostId: string) => void;
onDragEnd: () => void; onDragEnd: () => void;
flat?: boolean;
stripeIndex?: number;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { getStatus, initialLoadComplete } = useServerStatus(); const { getStatus, initialLoadComplete } = useServerStatus();
@@ -1525,13 +1550,14 @@ export function FolderItem({
const folderPath = folder.path ?? folder.name; const folderPath = folder.path ?? folder.name;
const isOpen = query ? true : openFolders.has(folderPath); 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 // Synthetic group headers (group-by tag/status/etc.) are not real folders, so
// they can't be edited, deleted, or used as drop targets. // they can't be edited, deleted, or used as drop targets.
const isGroup = folderPath.startsWith("__group__:"); const isGroup = folderPath.startsWith("__group__:");
return ( return (
<div <div
style={depth > 0 ? { paddingLeft: depth * 12 } : undefined}
onDragOver={(e) => { onDragOver={(e) => {
if (draggedHostIds && !isGroup) { if (draggedHostIds && !isGroup) {
e.preventDefault(); e.preventDefault();
@@ -1611,7 +1637,7 @@ export function FolderItem({
</> </>
} }
</button> </button>
{isOpen && ( {!flat && isOpen && (
<div className="border-l border-border/40 ml-[30px]"> <div className="border-l border-border/40 ml-[30px]">
{folder.children.map((child, i) => {folder.children.map((child, i) =>
isFolder(child) ? ( isFolder(child) ? (
@@ -1657,7 +1683,7 @@ export function FolderItem({
onDelete={() => onDeleteHost(child)} onDelete={() => onDeleteHost(child)}
onDuplicate={() => onDuplicateHost(child)} onDuplicate={() => onDuplicateHost(child)}
query={query} query={query}
stripeIndex={stripeMap.get(child) ?? 0} stripeIndex={stripeMap?.get(child) ?? 0}
selectionMode={selectionMode} selectionMode={selectionMode}
selected={selectedHostIds.has(child.id)} selected={selectedHostIds.has(child.id)}
onToggleSelect={() => onToggleSelect(child.id)} onToggleSelect={() => onToggleSelect(child.id)}
@@ -1961,9 +1987,33 @@ export function SidebarTree({
const allFolderPaths = collectAllFolderPaths(children); const allFolderPaths = collectAllFolderPaths(children);
const visibleRows = collectVisibleRows(children, query, openFolders); const visibleRows = collectVisibleRows(children, query, openFolders);
const stripeMap = new Map<Host | HostFolder, number>( const parentRef = useRef<HTMLDivElement>(null);
visibleRows.map((r, i) => [r.item, i]),
); 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) { if (loading) {
return ( return (
@@ -1993,6 +2043,7 @@ export function SidebarTree({
return ( return (
<div className="relative flex flex-col flex-1 min-h-0"> <div className="relative flex flex-col flex-1 min-h-0">
<div <div
ref={parentRef}
className={`flex-1 min-h-0 overflow-y-auto ${rootDragOver ? "ring-1 ring-inset ring-accent-brand/50" : ""}`} className={`flex-1 min-h-0 overflow-y-auto ${rootDragOver ? "ring-1 ring-inset ring-accent-brand/50" : ""}`}
onDragOver={(e) => { onDragOver={(e) => {
if (draggedHostIds) { if (draggedHostIds) {
@@ -2019,11 +2070,29 @@ export function SidebarTree({
</span> </span>
</div> </div>
) : ( ) : (
children.map((child, i) => <div
isFolder(child) ? ( 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 <FolderItem
key={i} folder={item}
folder={child} depth={depth}
flat
onOpenTab={onOpenTab} onOpenTab={onOpenTab}
onEditHost={onEditHost} onEditHost={onEditHost}
onShareHost={onShareHost} onShareHost={onShareHost}
@@ -2031,7 +2100,6 @@ export function SidebarTree({
onDuplicateHost={handleDuplicateHost} onDuplicateHost={handleDuplicateHost}
onProxmoxDiscover={onProxmoxDiscover} onProxmoxDiscover={onProxmoxDiscover}
query={query} query={query}
stripeMap={stripeMap}
openFolders={openFolders} openFolders={openFolders}
onToggleFolder={toggleFolder} onToggleFolder={toggleFolder}
selectionMode={selectionMode} selectionMode={selectionMode}
@@ -2048,37 +2116,45 @@ export function SidebarTree({
draggedHostIds={draggedHostIds} draggedHostIds={draggedHostIds}
onDragHostStart={handleDragHostStart} onDragHostStart={handleDragHostStart}
onDragEnd={() => setDraggedHostIds(null)} onDragEnd={() => setDraggedHostIds(null)}
stripeIndex={vItem.index}
/> />
) : ( ) : (
<HostItem <HostItem
key={i} host={item}
host={child} depth={depth}
onOpenTab={(type) => onOpenTab(child, type)} onOpenTab={(type) => onOpenTab(item, type)}
onEditHost={() => onEditHost(child)} onEditHost={() => onEditHost(item)}
onShareHost={onShareHost ? () => onShareHost(child) : undefined} onShareHost={
onShareHost ? () => onShareHost(item) : undefined
}
onProxmoxDiscover={ onProxmoxDiscover={
onProxmoxDiscover ? () => onProxmoxDiscover(child) : undefined onProxmoxDiscover
? () => onProxmoxDiscover(item)
: undefined
} }
onDelete={() => handleDeleteHost(child)} onDelete={() => handleDeleteHost(item)}
onDuplicate={() => handleDuplicateHost(child)} onDuplicate={() => handleDuplicateHost(item)}
query={query} query={query}
stripeIndex={stripeMap.get(child) ?? 0} stripeIndex={vItem.index}
selectionMode={selectionMode} selectionMode={selectionMode}
selected={selectedHostIds.has(child.id)} selected={selectedHostIds.has(item.id)}
onToggleSelect={() => toggleSelect(child.id)} onToggleSelect={() => toggleSelect(item.id)}
isMenuOpen={openMenuHostId === child.id} isMenuOpen={openMenuHostId === item.id}
onMenuOpenChange={(open) => onMenuOpenChange={(open) =>
setOpenMenuHostId(open ? child.id : null) setOpenMenuHostId(open ? item.id : null)
} }
isTrayOpen={openTrayHostId === child.id} isTrayOpen={openTrayHostId === item.id}
onTrayOpenChange={(open) => onTrayOpenChange={(open) =>
setOpenTrayHostId(open ? child.id : null) setOpenTrayHostId(open ? item.id : null)
} }
onDragStart={() => handleDragHostStart(child.id)} onDragStart={() => handleDragHostStart(item.id)}
onDragEnd={() => setDraggedHostIds(null)} onDragEnd={() => setDraggedHostIds(null)}
/> />
), )}
) </div>
);
})}
</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"]);
});
});