diff --git a/package-lock.json b/package-lock.json index 2d67c56f..bfa255ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/server": "^13.3.2", + "@tanstack/react-virtual": "^3.14.6", "@types/ldapjs": "^3.0.6", "axios": "^1.18.1", "bcryptjs": "^3.0.3", @@ -6694,6 +6695,33 @@ "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": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", diff --git a/package.json b/package.json index 36978ce3..45b8ad3a 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "dependencies": { "@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/server": "^13.3.2", + "@tanstack/react-virtual": "^3.14.6", "@types/ldapjs": "^3.0.6", "axios": "^1.18.1", "bcryptjs": "^3.0.3", diff --git a/src/backend/ssh/host-metrics-state.test.ts b/src/backend/ssh/host-metrics-state.test.ts new file mode 100644 index 00000000..4f007fff --- /dev/null +++ b/src/backend/ssh/host-metrics-state.test.ts @@ -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(); + }); +}); diff --git a/src/backend/ssh/host-metrics-state.ts b/src/backend/ssh/host-metrics-state.ts index f1140bd3..7a2e4ebf 100644 --- a/src/backend/ssh/host-metrics-state.ts +++ b/src/backend/ssh/host-metrics-state.ts @@ -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(fn: () => Promise): Promise { + if (this.active >= this.maxConcurrent) { + await new Promise((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 { + 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 metricsCache = new MetricsCache(); export const authFailureTracker = new AuthFailureTracker(); diff --git a/src/backend/ssh/host-metrics.ts b/src/backend/ssh/host-metrics.ts index 07dd9298..7b0fdf0c 100644 --- a/src/backend/ssh/host-metrics.ts +++ b/src/backend/ssh/host-metrics.ts @@ -62,9 +62,12 @@ import { } from "./host-metrics-sessions.js"; import { authFailureTracker, + hostPollCache, metricsCache, + metricsPollLimiter, pollingBackoff, requestQueue, + statusPollLimiter, } from "./host-metrics-state.js"; const authManager = AuthManager.getInstance(); @@ -169,6 +172,9 @@ class PollingManager { private activeViewers = new Map>(); private viewerDetails = new Map(); private viewerCleanupInterval: NodeJS.Timeout; + /** Skip stacking another status/metrics poll while one is already running. */ + private statusInFlight = new Set(); + private metricsInFlight = new Set(); constructor() { this.viewerCleanupInterval = setInterval(() => { @@ -176,6 +182,81 @@ class PollingManager { }, 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 { + 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(): { statusCheckInterval: number; metricsInterval: number; @@ -309,14 +390,17 @@ class PollingManager { this.pollingConfigs.set(host.id, config); 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(() => { const latestConfig = this.pollingConfigs.get(host.id); if (latestConfig && isTcpPingEnabled(latestConfig.statsConfig)) { - this.pollHostStatus(latestConfig.host, latestConfig.viewerUserId); + this.scheduleStatusPoll(latestConfig.host, latestConfig.viewerUserId); } }, intervalMs); } else { @@ -324,9 +408,27 @@ class PollingManager { } 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(() => { const latestConfig = this.pollingConfigs.get(host.id); @@ -335,15 +437,10 @@ class PollingManager { latestConfig.statsConfig.metricsEnabled && supportsMetrics(latestConfig.host) ) { - this.pollHostMetrics( + this.scheduleMetricsPoll( latestConfig.host, latestConfig.viewerUserId, - ).catch((err) => { - statsLogger.error("Metrics polling failed", err, { - operation: "metrics_poll_unhandled", - hostId: host.id, - }); - }); + ); } }, intervalMs); } else { @@ -356,7 +453,7 @@ class PollingManager { viewerUserId?: string, ): Promise { const userId = viewerUserId || host.userId; - const refreshedHost = await fetchHostById(host.id, userId); + const refreshedHost = await this.resolveHostForPoll(host, userId); if (!refreshedHost) { return; } @@ -426,7 +523,7 @@ class PollingManager { viewerUserId?: string, ): Promise { const userId = viewerUserId || host.userId; - const refreshedHost = await fetchHostById(host.id, userId); + const refreshedHost = await this.resolveHostForPoll(host, userId); if (!refreshedHost) { return; } @@ -581,6 +678,9 @@ class PollingManager { this.metricsStore.delete(hostId); } } + hostPollCache.invalidate(hostId); + this.statusInFlight.delete(hostId); + this.metricsInFlight.delete(hostId); } stopMetricsOnly(hostId: number): void { @@ -1843,6 +1943,7 @@ app.post("/host-updated", async (req, res) => { } try { + hostPollCache.invalidate(hostId); const host = await fetchHostById(hostId, userId); if (host) { connectionPool.clearKeyConnections(getPoolKey(host)); diff --git a/src/backend/ssh/ssh-connection-pool.ts b/src/backend/ssh/ssh-connection-pool.ts index b521842b..0f0691b7 100644 --- a/src/backend/ssh/ssh-connection-pool.ts +++ b/src/backend/ssh/ssh-connection-pool.ts @@ -8,18 +8,29 @@ interface PooledConnection { hostKey: string; } +interface ConnectionWaiter { + resolve: (client: Client) => void; + reject: (error: Error) => void; + factory: () => Promise; + 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 { private connections = new Map(); - private maxConnectionsPerHost = 3; + private waiters = new Map(); + private maxConnectionsPerHost = DEFAULT_MAX_CONNECTIONS_PER_HOST; + private maxWaitMs = DEFAULT_MAX_WAIT_MS; private cleanupInterval: NodeJS.Timeout; constructor() { - this.cleanupInterval = setInterval( - () => { - this.cleanup(); - }, - 2 * 60 * 1000, - ); + this.cleanupInterval = setInterval(() => { + this.cleanup(); + }, CLEANUP_INTERVAL_MS); } private isConnectionHealthy(client: Client): boolean { @@ -38,6 +49,126 @@ class SSHConnectionPool { } } + private removeUnhealthy( + key: string, + connections: PooledConnection[], + target: PooledConnection, + ): PooledConnection[] { + sshLogger.warn("Removing unhealthy connection from pool", { + operation: "pool_remove_dead", + hostKey: key, + }); + try { + target.client.end(); + } catch { + // expected + } + const filtered = connections.filter((c) => c !== target); + this.connections.set(key, filtered); + return filtered; + } + + private async createPooledClient( + key: string, + factory: () => Promise, + existing: PooledConnection[], + ): Promise { + const client = await factory(); + const pooled: PooledConnection = { + client, + lastUsed: Date.now(), + inUse: true, + hostKey: key, + }; + existing.push(pooled); + this.connections.set(key, existing); + + client.on("end", () => { + this.removeConnection(key, client); + }); + client.on("close", () => { + this.removeConnection(key, client); + }); + + return client; + } + + private enqueueWaiter( + key: string, + factory: () => Promise, + ): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const queue = this.waiters.get(key); + if (queue) { + const idx = queue.findIndex((w) => w.timer === timer); + if (idx >= 0) queue.splice(idx, 1); + if (queue.length === 0) this.waiters.delete(key); + } + const err = new Error( + `SSH connection pool wait timed out after ${this.maxWaitMs}ms (${key})`, + ); + sshLogger.warn("Connection pool wait timeout", { + operation: "pool_wait_timeout", + hostKey: key, + maxWaitMs: this.maxWaitMs, + }); + reject(err); + }, this.maxWaitMs); + + const waiter: ConnectionWaiter = { resolve, reject, factory, timer }; + const queue = this.waiters.get(key) || []; + queue.push(waiter); + this.waiters.set(key, queue); + }); + } + + /** 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, @@ -47,17 +178,7 @@ class SSHConnectionPool { const available = connections.find((conn) => !conn.inUse); if (available) { if (!this.isConnectionHealthy(available.client)) { - sshLogger.warn("Removing unhealthy connection from pool", { - operation: "pool_remove_dead", - hostKey: key, - }); - try { - available.client.end(); - } catch { - // expected - } - connections = connections.filter((c) => c !== available); - this.connections.set(key, connections); + connections = this.removeUnhealthy(key, connections, available); } else { available.inUse = true; available.lastUsed = Date.now(); @@ -66,63 +187,10 @@ class SSHConnectionPool { } if (connections.length < this.maxConnectionsPerHost) { - const client = await factory(); - const pooled: PooledConnection = { - client, - lastUsed: Date.now(), - inUse: true, - hostKey: key, - }; - connections.push(pooled); - this.connections.set(key, connections); - - client.on("end", () => { - this.removeConnection(key, client); - }); - client.on("close", () => { - this.removeConnection(key, client); - }); - - return client; + return this.createPooledClient(key, factory, connections); } - return new Promise((resolve) => { - const checkAvailable = () => { - const conns = this.connections.get(key) || []; - const avail = conns.find((conn) => !conn.inUse); - if (avail) { - if (!this.isConnectionHealthy(avail.client)) { - try { - avail.client.end(); - } catch { - // expected - } - const filtered = conns.filter((c) => c !== avail); - this.connections.set(key, filtered); - factory().then((client) => { - const pooled: PooledConnection = { - client, - lastUsed: Date.now(), - inUse: true, - hostKey: key, - }; - 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 { - avail.inUse = true; - avail.lastUsed = Date.now(); - resolve(avail.client); - } - } else { - setTimeout(checkAvailable, 100); - } - }; - checkAvailable(); - }); + return this.enqueueWaiter(key, factory); } releaseConnection(key: string, client: Client): void { @@ -131,6 +199,7 @@ class SSHConnectionPool { if (pooled) { pooled.inUse = false; pooled.lastUsed = Date.now(); + this.wakeWaiter(key); } } @@ -143,6 +212,8 @@ class SSHConnectionPool { } else { this.connections.set(key, filtered); } + // A slot or idle connection may now be free for waiters. + this.wakeWaiter(key); } clearKeyConnections(key: string): void { @@ -155,15 +226,15 @@ class SSHConnectionPool { } } this.connections.delete(key); + this.rejectWaiters(key, `SSH connection pool cleared for ${key}`); } private cleanup(): void { const now = Date.now(); - const maxAge = 10 * 60 * 1000; for (const [hostKey, connections] of this.connections.entries()) { const activeConnections = connections.filter((conn) => { - if (!conn.inUse && now - conn.lastUsed > maxAge) { + if (!conn.inUse && now - conn.lastUsed > IDLE_MAX_AGE_MS) { try { conn.client.end(); } catch { @@ -187,10 +258,14 @@ class SSHConnectionPool { } else { this.connections.set(hostKey, activeConnections); } + this.wakeWaiter(hostKey); } } 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 conn of connections) { try { diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index 019c1f1f..392c0295 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -6,30 +6,101 @@ import { Separator } from "@/components/separator"; import { Button } from "@/components/button"; import { Sheet, SheetContent } from "@/components/sheet"; 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 { useIsMobile } from "@/hooks/use-mobile"; import { MobileBottomBar } from "@/shell/MobileBottomBar"; -import { CommandPalette } from "@/shell/CommandPalette"; import { AppRail } 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 { renderTabContent } from "@/shell/tabUtils"; -import { AlertManager } from "@/dashboard/panels/alerts/AlertManager"; 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 ( +
+
+
+ ); +} import type { Tab, TabType, @@ -59,7 +130,6 @@ import { import { dbHealthMonitor } from "@/lib/db-health-monitor"; import type { SSHHostWithStatus } from "@/main-axios"; import { ServerStatusProvider } from "@/lib/ServerStatusContext"; -import { ConnectionsPanel } from "@/sidebar/ConnectionsPanel"; import { TransferMonitor } from "@/features/file-manager/TransferMonitor.tsx"; import { sshHostToHost } from "@/sidebar/HostManagerData"; import { resolveHostTabType } from "@/lib/host-connection-tabs"; @@ -738,8 +808,17 @@ export function AppShell({ }, [loadHosts]); useEffect(() => { - window.addEventListener("termix:hosts-changed", loadHosts); - return () => window.removeEventListener("termix:hosts-changed", loadHosts); + const onHostsChanged = () => { + 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]); // Sync tab host data when allHosts updates (e.g. after editing terminal theme in host settings) @@ -1437,169 +1516,174 @@ export function AppShell({ // Sidebar panel content — shared between desktop inline sidebar and mobile sheet const sidebarPanelContent = ( -
-
- { - connectHost(host, type); - if (isMobile) setSidebarOpen(false); - }} - onEditHost={editHostInManager} - hostTree={realHostTree ?? undefined} - loading={hostsLoading} - onEditingChange={setSidebarEditing} - active={railView === "hosts"} - /> -
- -
- -
- - {railView === "termix-id" && ( -
- -
- )} - - {railView === "serial" && ( - { - openSerialTab(config); - if (isMobile) setSidebarOpen(false); - }} - /> - )} - - {railView === "quick-connect" && ( - { - openTab(host, type); - if (isMobile) setSidebarOpen(false); - }} - /> - )} - - {railView === "ssh-tools" && ( -
- -
- )} - - {railView === "snippets" && ( -
- -
- )} - - {railView === "history" && ( -
- -
- )} - - {railView === "split-screen" && ( -
- -
- )} - - {railView === "connections" && ( -
- { - setActiveTabId(tabId); + }> +
+
+ { + connectHost(host, type); if (isMobile) setSidebarOpen(false); }} - onCloseTab={closeTab} - onReopenTab={(record, restoredSessionId) => { - const host = record.hostId - ? allHosts.find((h) => h.id === String(record.hostId)) - : undefined; - const hostlessTypes: TabType[] = ["tunnel"]; - if (!host && !hostlessTypes.includes(record.tabType as TabType)) - return; - setBackgroundTabRecords((prev) => - prev.filter((r) => r.id !== record.id), - ); - if (host) { - const effectiveSessionId = - restoredSessionId ?? record.backendSessionId ?? null; - openTab(host, record.tabType as TabType, { - instanceId: record.id, - restoredSessionId: effectiveSessionId, - savedLabel: record.label, - }); - } else { - openSingletonTab(record.tabType as TabType); - } + onEditHost={editHostInManager} + hostTree={realHostTree ?? undefined} + loading={hostsLoading} + onEditingChange={setSidebarEditing} + active={railView === "hosts"} + /> +
+ +
+ +
+ + {railView === "termix-id" && ( +
+ +
+ )} + + {railView === "serial" && ( + { + openSerialTab(config); if (isMobile) setSidebarOpen(false); }} - onForgetBackground={(recordId) => { - setBackgroundTabRecords((prev) => - prev.filter((r) => r.id !== recordId), - ); + /> + )} + + {railView === "quick-connect" && ( + { + openTab(host, type); + if (isMobile) setSidebarOpen(false); }} - onRenameTab={renameTab} - onReorderTabs={setTabs} /> -
- )} + )} - {railView === "session-logs" && ( -
- -
- )} + {railView === "ssh-tools" && ( +
+ +
+ )} - {railView === "user-profile" && ( -
- -
- )} + {railView === "snippets" && ( +
+ +
+ )} - {railView === "admin-settings" && isAdmin && ( -
- -
- )} + {railView === "history" && ( +
+ +
+ )} - {railView === "alerts" && ( -
- -
- )} -
+ {railView === "split-screen" && ( +
+ +
+ )} + + {railView === "connections" && ( +
+ { + setActiveTabId(tabId); + if (isMobile) setSidebarOpen(false); + }} + onCloseTab={closeTab} + onReopenTab={(record, restoredSessionId) => { + const host = record.hostId + ? allHosts.find((h) => h.id === String(record.hostId)) + : undefined; + const hostlessTypes: TabType[] = ["tunnel"]; + if (!host && !hostlessTypes.includes(record.tabType as TabType)) + return; + setBackgroundTabRecords((prev) => + prev.filter((r) => r.id !== record.id), + ); + if (host) { + const effectiveSessionId = + restoredSessionId ?? record.backendSessionId ?? null; + openTab(host, record.tabType as TabType, { + instanceId: record.id, + restoredSessionId: effectiveSessionId, + savedLabel: record.label, + }); + } else { + openSingletonTab(record.tabType as TabType); + } + if (isMobile) setSidebarOpen(false); + }} + onForgetBackground={(recordId) => { + setBackgroundTabRecords((prev) => + prev.filter((r) => r.id !== recordId), + ); + }} + onRenameTab={renameTab} + onReorderTabs={setTabs} + /> +
+ )} + + {railView === "session-logs" && ( +
+ +
+ )} + + {railView === "user-profile" && ( +
+ +
+ )} + + {railView === "admin-settings" && isAdmin && ( +
+ +
+ )} + + {railView === "alerts" && ( +
+ +
+ )} +
+ ); // Sidebar header — shared @@ -1812,35 +1896,41 @@ export function AppShell({
- { - if ( - [ - "dashboard", - "host-manager", - "user-profile", - "admin-settings", - ].includes(type) - ) { - openSingletonTab(type, pendingEvent); - } else if (type === "tmux_monitor") { - // --- tmux-monitor --- singleton tab, optionally preselecting a host - openSingletonTab( - type, - undefined, - label ? allHosts.find((h) => h.name === label) : undefined, - ); - } else if (label) { - const host = allHosts.find((h) => h.name === label); - if (host) openTab(host, type); - } - }} - /> + {commandPaletteOpen && ( + + { + if ( + [ + "dashboard", + "host-manager", + "user-profile", + "admin-settings", + ].includes(type) + ) { + openSingletonTab(type, pendingEvent); + } else if (type === "tmux_monitor") { + // --- tmux-monitor --- singleton tab, optionally preselecting a host + openSingletonTab( + type, + undefined, + label ? allHosts.find((h) => h.name === label) : undefined, + ); + } else if (label) { + const host = allHosts.find((h) => h.name === label); + if (host) openTab(host, type); + } + }} + /> + + )} - + + + ); } diff --git a/src/ui/api/host-metrics-status-api.ts b/src/ui/api/host-metrics-status-api.ts index 53b31ac4..0a6eebe7 100644 --- a/src/ui/api/host-metrics-status-api.ts +++ b/src/ui/api/host-metrics-status-api.ts @@ -1,6 +1,7 @@ import axios, { type AxiosRequestConfig } from "axios"; import { handleApiError, statsApi } from "@/main-axios"; import type { ServerMetrics, ServerStatus } from "@/main-axios"; +import { getCachedServerStatuses } from "@/lib/hosts-request-cache"; type ApiConnectionLog = { type: "info" | "success" | "warning" | "error"; @@ -73,34 +74,37 @@ function isTransientStatusError(error: unknown): boolean { export async function getAllServerStatuses(): Promise< Record > { - let lastError: unknown = null; + return getCachedServerStatuses(async () => { + let lastError: unknown = null; - for (let i = 0; i < STATUS_RETRY_SCHEDULE.length; i++) { - const { timeoutMs, pauseAfterMs } = STATUS_RETRY_SCHEDULE[i]; - const isFinalAttempt = i === STATUS_RETRY_SCHEDULE.length - 1; + for (let i = 0; i < STATUS_RETRY_SCHEDULE.length; i++) { + const { timeoutMs, pauseAfterMs } = STATUS_RETRY_SCHEDULE[i]; + const isFinalAttempt = i === STATUS_RETRY_SCHEDULE.length - 1; - try { - const response = await statsApi.get("/status", { - timeout: timeoutMs, - // Silence per-attempt interceptor logging & health-monitor side - // effects on all attempts except the final one, so background - // blips don't look like real outages. - __silentRetry: !isFinalAttempt, - } as AxiosRequestConfig & { __silentRetry?: boolean }); - return response.data || {}; - } catch (error) { - lastError = error; - if (!isTransientStatusError(error)) { - break; + try { + const response = await statsApi.get("/status", { + timeout: timeoutMs, + // Silence per-attempt interceptor logging & health-monitor side + // effects on all attempts except the final one, so background + // blips don't look like real outages. + __silentRetry: !isFinalAttempt, + } as AxiosRequestConfig & { __silentRetry?: boolean }); + return response.data || {}; + } catch (error) { + lastError = error; + if (!isTransientStatusError(error)) { + break; + } + if (pauseAfterMs === null) { + break; + } + await new Promise((resolve) => setTimeout(resolve, pauseAfterMs)); } - if (pauseAfterMs === null) { - break; - } - await new Promise((resolve) => setTimeout(resolve, pauseAfterMs)); } - } - handleApiError(lastError, "fetch server statuses"); + handleApiError(lastError, "fetch server statuses"); + return {}; + }); } export async function getServerStatusById(id: number): Promise { diff --git a/src/ui/api/open-tabs-api.ts b/src/ui/api/open-tabs-api.ts index c64f2e06..e9eb5a7c 100644 --- a/src/ui/api/open-tabs-api.ts +++ b/src/ui/api/open-tabs-api.ts @@ -1,4 +1,5 @@ import { authApi } from "@/main-axios"; +import { createTtlRequestCache } from "@/lib/ttl-request-cache"; // OPEN TABS API // ============================================================================ @@ -42,6 +43,8 @@ export interface ActiveSessionInfo { createdAt: number; } +const activeSessionsCache = createTtlRequestCache(2_000); + export async function getOpenTabs(): Promise { const response = await authApi.get("/open-tabs"); return response.data; @@ -69,8 +72,10 @@ export async function addOpenTab(tab: OpenTabUpsertPayload): Promise { } export async function getActiveSessions(): Promise { - const response = await authApi.get("/open-tabs/active-sessions"); - return response.data; + return activeSessionsCache.get(async () => { + const response = await authApi.get("/open-tabs/active-sessions"); + return Array.isArray(response.data) ? response.data : []; + }); } // ============================================================================ diff --git a/src/ui/api/ssh-host-management-api.ts b/src/ui/api/ssh-host-management-api.ts index 9b1c574a..e299a8c1 100644 --- a/src/ui/api/ssh-host-management-api.ts +++ b/src/ui/api/ssh-host-management-api.ts @@ -8,16 +8,38 @@ import { import type { SSHHost, SSHHostData, ProxyNode } from "@/types/index"; import type { ServerStatus, SSHHostWithStatus } from "@/main-axios"; import type { ProxmoxDiscoverResult } from "@/types/proxmox"; +import { + getCachedSSHHosts, + invalidateHostsAndStatusCaches, +} from "@/lib/hosts-request-cache"; // SSH HOST MANAGEMENT // ============================================================================ -export async function getSSHHosts(): Promise { +export type GetSSHHostsOptions = { + /** When false, skip the status service call (host config only). Default true. */ + includeStatus?: boolean; +}; + +async function loadSSHHostsFromApi(): Promise { + const hostsResponse = await sshHostApi.get("/db/host"); + return Array.isArray(hostsResponse.data) ? hostsResponse.data : []; +} + +export async function getSSHHosts( + options: GetSSHHostsOptions = {}, +): Promise { + const includeStatus = options.includeStatus !== false; + try { - const hostsResponse = await sshHostApi.get("/db/host"); - const hosts: SSHHost[] = Array.isArray(hostsResponse.data) - ? hostsResponse.data - : []; + const hosts = await getCachedSSHHosts(loadSSHHostsFromApi); + + if (!includeStatus) { + return hosts.map((host) => ({ + ...host, + status: "unknown", + })); + } let statuses: Record = {}; try { @@ -45,9 +67,11 @@ export async function createSSHHost(hostData: SSHHostData): Promise { const response = await sshHostApi.post("/db/host", formData, { headers: { "Content-Type": "multipart/form-data" }, }); + invalidateHostsAndStatusCaches(); return response.data; } const response = await sshHostApi.post("/db/host", hostData); + invalidateHostsAndStatusCaches(); return response.data; } catch (error) { throw handleApiError(error, "create SSH host"); @@ -67,9 +91,11 @@ export async function updateSSHHost( const response = await sshHostApi.put(`/db/host/${hostId}`, formData, { headers: { "Content-Type": "multipart/form-data" }, }); + invalidateHostsAndStatusCaches(); return response.data; } const response = await sshHostApi.put(`/db/host/${hostId}`, hostData); + invalidateHostsAndStatusCaches(); return response.data; } catch (error) { throw handleApiError(error, "update SSH host"); @@ -103,6 +129,7 @@ export async function bulkImportSSHHosts( overwrite, ...(credentials ? { credentials } : {}), }); + invalidateHostsAndStatusCaches(); return response.data; } catch (error) { handleApiError(error, "bulk import SSH hosts"); @@ -125,6 +152,7 @@ export async function importSSHConfigHosts( content, overwrite, }); + invalidateHostsAndStatusCaches(); return response.data; } catch (error) { handleApiError(error, "import SSH config hosts"); @@ -155,6 +183,7 @@ export async function bulkUpdateSSHHosts( hostIds, updates, }); + invalidateHostsAndStatusCaches(); return response.data; } catch (error) { handleApiError(error, "bulk update SSH hosts"); @@ -166,6 +195,7 @@ export async function deleteSSHHost( ): Promise> { try { const response = await sshHostApi.delete(`/db/host/${hostId}`); + invalidateHostsAndStatusCaches(); return response.data; } catch (error) { handleApiError(error, "delete SSH host"); diff --git a/src/ui/dashboard/DashboardTab.tsx b/src/ui/dashboard/DashboardTab.tsx index 9ffe7afe..50154591 100644 --- a/src/ui/dashboard/DashboardTab.tsx +++ b/src/ui/dashboard/DashboardTab.tsx @@ -801,6 +801,7 @@ function CardItem({ onAddServiceLink, onDeleteServiceLink, statusLoading, + isVisible = true, }: { slot: CardSlot; editMode: boolean; @@ -830,6 +831,7 @@ function CardItem({ onAddServiceLink: (label: string, url: string) => Promise; onDeleteServiceLink: (id: number) => Promise; statusLoading?: boolean; + isVisible?: boolean; }) { const cardRef = useRef(null); @@ -926,6 +928,7 @@ function CardItem({ {slot.id === "network_graph" && ( onOpenSingletonTab("network_graph")} /> )} @@ -1055,6 +1058,7 @@ type PanelColumnProps = { onAddServiceLink: (label: string, url: string) => Promise; onDeleteServiceLink: (id: number) => Promise; statusLoading: boolean; + isVisible?: boolean; }; function PanelColumn({ @@ -1086,6 +1090,7 @@ function PanelColumn({ onAddServiceLink, onDeleteServiceLink, statusLoading, + isVisible = true, }: PanelColumnProps) { const { t } = useTranslation(); const sorted = [...slots].sort((a, b) => a.order - b.order); @@ -1142,6 +1147,7 @@ function PanelColumn({ onAddServiceLink={onAddServiceLink} onDeleteServiceLink={onDeleteServiceLink} statusLoading={statusLoading} + isVisible={isVisible} /> ))} @@ -1192,9 +1198,12 @@ function ColumnDivider({ export function DashboardTab({ onOpenSingletonTab, onOpenTab, + isVisible = true, }: { onOpenSingletonTab: (type: TabType, pendingEvent?: string) => 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 { initialLoadComplete } = useServerStatus(); @@ -1349,7 +1358,9 @@ export function DashboardTab({ const mapped = raw.map(sshHostToHost); const statusHosts = mapped.filter(isStatusCheckEnabled); if (mounted) setHosts(mapped); - fetchMetrics(statusHosts).catch(() => {}); + if (isVisible) { + fetchMetrics(statusHosts).catch(() => {}); + } }; load(); @@ -1399,7 +1410,14 @@ export function DashboardTab({ }) .catch(() => {}); + if (!isVisible) { + return () => { + mounted = false; + }; + } + const metricsInterval = setInterval(async () => { + if (document.visibilityState === "hidden") return; const raw = await getSSHHosts().catch(() => []); const mapped = raw.map(sshHostToHost); const statusHosts = mapped.filter(isStatusCheckEnabled); @@ -1411,17 +1429,18 @@ export function DashboardTab({ mounted = false; clearInterval(metricsInterval); }; - }, [fetchMetrics]); + }, [fetchMetrics, isVisible]); useEffect(() => { - if (viewerSessionsRef.current.size === 0) return; + if (!isVisible || viewerSessionsRef.current.size === 0) return; const heartbeat = setInterval(async () => { + if (document.visibilityState === "hidden") return; for (const [, sessionId] of viewerSessionsRef.current) { sendMetricsHeartbeat(sessionId).catch(() => {}); } }, 30000); return () => clearInterval(heartbeat); - }, [hostMetrics]); + }, [hostMetrics, isVisible]); const handleClearActivity = async () => { try { @@ -1594,6 +1613,7 @@ export function DashboardTab({ onAddServiceLink: handleAddServiceLink, onDeleteServiceLink: handleDeleteServiceLink, statusLoading, + isVisible, }; const isMobile = useIsMobile(); @@ -1733,6 +1753,7 @@ export function DashboardTab({ {slot.id === "network_graph" && ( onOpenSingletonTab("network_graph")} /> )} diff --git a/src/ui/dashboard/cards/NetworkGraphCard.tsx b/src/ui/dashboard/cards/NetworkGraphCard.tsx index 74fb2ce0..5495ea13 100644 --- a/src/ui/dashboard/cards/NetworkGraphCard.tsx +++ b/src/ui/dashboard/cards/NetworkGraphCard.tsx @@ -92,6 +92,8 @@ interface NetworkGraphCardProps { rightSidebarWidth?: number; embedded?: boolean; onOpenInNewTab?: () => void; + /** When false, pause status refresh while the surface stays mounted. */ + isVisible?: boolean; } type NetworkElement = NetworkTopologyNode | NetworkTopologyEdge; @@ -195,6 +197,7 @@ function buildNodeSvg( export function NetworkGraphCard({ embedded = true, onOpenInNewTab, + isVisible = true, }: NetworkGraphCardProps): React.ReactElement { const { t } = useTranslation(); const { addTab } = useTabsSafe(); @@ -269,8 +272,19 @@ export function NetworkGraphCard({ }, [hostMap]); useEffect(() => { + if (!isVisible) { + if (statusIntervalRef.current) { + clearInterval(statusIntervalRef.current); + statusIntervalRef.current = null; + } + return; + } + loadData(); - statusIntervalRef.current = setInterval(updateHostStatuses, 30000); + statusIntervalRef.current = setInterval(() => { + if (document.visibilityState === "hidden") return; + updateHostStatuses(); + }, 30000); const onClickOutside = (e: MouseEvent) => { if ( contextMenuRef.current && @@ -293,7 +307,7 @@ export function NetworkGraphCard({ document.removeEventListener("mousedown", onClickOutside, true); themeObserver.disconnect(); }; - }, []); + }, [isVisible]); const loadData = async () => { setLoading(true); diff --git a/src/ui/features/docker/DockerManager.tsx b/src/ui/features/docker/DockerManager.tsx index ebac94b9..70ee1b2f 100644 --- a/src/ui/features/docker/DockerManager.tsx +++ b/src/ui/features/docker/DockerManager.tsx @@ -335,7 +335,10 @@ function DockerManagerInner({ }; pollContainers(); - const interval = setInterval(pollContainers, 5000); + const interval = setInterval(() => { + if (document.visibilityState === "hidden") return; + void pollContainers(); + }, 5000); return () => { cancelled = true; diff --git a/src/ui/features/docker/components/ContainerStats.tsx b/src/ui/features/docker/components/ContainerStats.tsx index 2749dd31..c4dba44a 100644 --- a/src/ui/features/docker/components/ContainerStats.tsx +++ b/src/ui/features/docker/components/ContainerStats.tsx @@ -50,7 +50,10 @@ export function ContainerStats({ React.useEffect(() => { fetchStats(); - const interval = setInterval(fetchStats, 2000); + const interval = setInterval(() => { + if (document.visibilityState === "hidden") return; + void fetchStats(); + }, 2000); return () => clearInterval(interval); }, [fetchStats]); diff --git a/src/ui/features/docker/components/LogViewer.tsx b/src/ui/features/docker/components/LogViewer.tsx index bca7b51e..95e35eaf 100644 --- a/src/ui/features/docker/components/LogViewer.tsx +++ b/src/ui/features/docker/components/LogViewer.tsx @@ -90,7 +90,10 @@ export function LogViewer({ React.useEffect(() => { if (!autoRefresh) return; - const interval = setInterval(fetchLogs, 3000); + const interval = setInterval(() => { + if (document.visibilityState === "hidden") return; + void fetchLogs(); + }, 3000); return () => clearInterval(interval); }, [autoRefresh, fetchLogs]); diff --git a/src/ui/features/file-manager/FileManager.tsx b/src/ui/features/file-manager/FileManager.tsx index 70ff98e3..a8bced7d 100644 --- a/src/ui/features/file-manager/FileManager.tsx +++ b/src/ui/features/file-manager/FileManager.tsx @@ -87,6 +87,7 @@ function FileManagerContent({ initialPath, onClose, onOpenTerminalTab, + isVisible = true, }: FileManagerProps) { const { openWindow } = useWindowManager(); const { t } = useTranslation(); @@ -268,7 +269,7 @@ function FileManagerContent({ }, [currentHost]); useEffect(() => { - if (sshSessionId) { + if (sshSessionId && isVisible) { startKeepalive(); } else { stopKeepalive(); @@ -277,7 +278,7 @@ function FileManagerContent({ return () => { stopKeepalive(); }; - }, [sshSessionId, startKeepalive, stopKeepalive]); + }, [sshSessionId, isVisible, startKeepalive, stopKeepalive]); const initialFileOpenedRef = useRef(false); useEffect(() => { @@ -3105,6 +3106,7 @@ function FileManagerInner({ initialPath, onClose, onOpenTerminalTab, + isVisible = true, }: FileManagerProps) { return ( @@ -3114,6 +3116,7 @@ function FileManagerInner({ initialPath={initialPath} onClose={onClose} onOpenTerminalTab={onOpenTerminalTab} + isVisible={isVisible} /> ); diff --git a/src/ui/features/file-manager/FileManagerGrid.tsx b/src/ui/features/file-manager/FileManagerGrid.tsx index 6656220b..c2ecd3ec 100644 --- a/src/ui/features/file-manager/FileManagerGrid.tsx +++ b/src/ui/features/file-manager/FileManagerGrid.tsx @@ -1,6 +1,14 @@ /* 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 { useVirtualizer } from "@tanstack/react-virtual"; import { cn } from "@/lib/utils.ts"; import { Folder, @@ -185,6 +193,12 @@ export function FileManagerGrid({ const { t } = useTranslation(); const gridRef = useRef(null); 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({ type: "none", @@ -192,6 +206,52 @@ export function FileManagerGrid({ 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(() => { const handleGlobalMouseMove = (e: MouseEvent) => { if (dragState.type === "internal" && dragState.files.length > 0) { @@ -442,6 +502,77 @@ export function FileManagerGrid({ setSelectionRect({ x, y, width, height }); 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 = gridRef.current.querySelectorAll("[data-file-path]"); const selectedPaths: string[] = []; @@ -457,13 +588,6 @@ export function FileManagerGrid({ bottom: elementRect.bottom - containerRect.top, }; - const selectionBox = { - left: x, - top: y, - right: x + width, - bottom: y + height, - }; - const intersects = !( relativeElementRect.right < selectionBox.left || 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( @@ -819,89 +952,126 @@ export function FileManagerGrid({ ) : viewMode === "grid" ? ( -
+
{createIntent && ( - +
+ +
)} - {files.map((file) => { - const isSelected = selectedFiles.some( - (f) => f.path === file.path, - ); - - return ( -
f.path === file.path) && - "opacity-50", - )} - title={file.name} - onClick={(e) => handleFileClick(file, e)} - onContextMenu={(e) => { - e.preventDefault(); - e.stopPropagation(); - onContextMenu?.(e, file); - }} - onDragStart={(e) => handleFileDragStart(e, file)} - onDragOver={(e) => handleFileDragOver(e, file)} - onDragLeave={(e) => handleFileDragLeave(e, file)} - onDrop={(e) => handleFileDrop(e, file)} - onDragEnd={handleFileDragEnd} - > -
- {getFileIcon(file, viewMode)} +
+ {gridVirtualizer.getVirtualItems().map((vRow) => { + const start = vRow.index * gridCols; + const rowFiles = files.slice(start, start + gridCols); + return ( +
+
+ {rowFiles.map((file) => { + const isSelected = selectedFiles.some( + (f) => f.path === file.path, + ); + return ( +
f.path === file.path, + ) && "opacity-50", + )} + title={file.name} + onClick={(e) => handleFileClick(file, e)} + onContextMenu={(e) => { + e.preventDefault(); + e.stopPropagation(); + onContextMenu?.(e, file); + }} + onDragStart={(e) => handleFileDragStart(e, file)} + onDragOver={(e) => handleFileDragOver(e, file)} + onDragLeave={(e) => handleFileDragLeave(e, file)} + onDrop={(e) => handleFileDrop(e, file)} + onDragEnd={handleFileDragEnd} + > +
+ {getFileIcon(file, viewMode)} +
+
+ {editingFile?.path === file.path ? ( + + setEditingName(e.target.value) + } + onKeyDown={handleEditKeyDown} + 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" + onClick={(e) => e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + /> + ) : ( +

+ {file.name} +

+ )} + {file.type === "file" && + file.size !== undefined && + file.size !== null && ( +

+ {formatFileSize(file.size)} +

+ )} + {file.type === "link" && file.linkTarget && ( +

+ → {file.linkTarget} +

+ )} +
+
+ ); + })} +
- -
- {editingFile?.path === file.path ? ( - setEditingName(e.target.value)} - onKeyDown={handleEditKeyDown} - 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" - onClick={(e) => e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - /> - ) : ( -

- {file.name} -

- )} - {file.type === "file" && - file.size !== undefined && - file.size !== null && ( -

- {formatFileSize(file.size)} -

- )} - {file.type === "link" && file.linkTarget && ( -

- → {file.linkTarget} -

- )} -
-
- ); - })} + ); + })} +
) : (
@@ -952,91 +1122,106 @@ export function FileManagerGrid({ onCancel={onCancelCreate} /> )} - {files.map((file) => { - const isSelected = selectedFiles.some( - (f) => f.path === file.path, - ); - - return ( -
f.path === file.path) && - "opacity-50", - )} - onClick={(e) => handleFileClick(file, e)} - onContextMenu={(e) => { - e.preventDefault(); - e.stopPropagation(); - onContextMenu?.(e, file); - }} - onDragStart={(e) => handleFileDragStart(e, file)} - onDragOver={(e) => handleFileDragOver(e, file)} - onDragLeave={(e) => handleFileDragLeave(e, file)} - onDrop={(e) => handleFileDrop(e, file)} - onDragEnd={handleFileDragEnd} - > -
-
- {getFileIcon(file, viewMode)} -
- {editingFile?.path === file.path ? ( - setEditingName(e.target.value)} - onKeyDown={handleEditKeyDown} - onBlur={handleEditConfirm} - className="flex-1 min-w-0 max-w-[200px] 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 pointer-events-auto" - onClick={(e) => e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - /> - ) : ( - - {file.name} - {file.type === "link" && file.linkTarget && ( - - → {file.linkTarget} +
+ {listVirtualizer.getVirtualItems().map((vItem) => { + const file = files[vItem.index]; + if (!file) return null; + const isSelected = selectedFiles.some( + (f) => f.path === file.path, + ); + return ( +
+
f.path === file.path) && + "opacity-50", + )} + onClick={(e) => handleFileClick(file, e)} + onContextMenu={(e) => { + e.preventDefault(); + e.stopPropagation(); + onContextMenu?.(e, file); + }} + onDragStart={(e) => handleFileDragStart(e, file)} + onDragOver={(e) => handleFileDragOver(e, file)} + onDragLeave={(e) => handleFileDragLeave(e, file)} + onDrop={(e) => handleFileDrop(e, file)} + onDragEnd={handleFileDragEnd} + > +
+
+ {getFileIcon(file, viewMode)} +
+ {editingFile?.path === file.path ? ( + setEditingName(e.target.value)} + onKeyDown={handleEditKeyDown} + onBlur={handleEditConfirm} + className="flex-1 min-w-0 max-w-[200px] 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 pointer-events-auto" + onClick={(e) => e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + /> + ) : ( + + {file.name} + {file.type === "link" && file.linkTarget && ( + + → {file.linkTarget} + + )} )} +
+ + + {file.modified || "—"} - )} + + + {file.owner + ? `${file.owner}${file.group ? `:${file.group}` : ""}` + : "—"} + + + + {file.type === "file" && + file.size !== undefined && + file.size !== null + ? formatFileSize(file.size) + : "—"} + + + + {file.permissions || "—"} + +
- - - {file.modified || "—"} - - - - {file.owner - ? `${file.owner}${file.group ? `:${file.group}` : ""}` - : "—"} - - - - {file.type === "file" && - file.size !== undefined && - file.size !== null - ? formatFileSize(file.size) - : "—"} - - - - {file.permissions || "—"} - -
- ); - })} + ); + })} +
)} diff --git a/src/ui/features/file-manager/TransferMonitor.tsx b/src/ui/features/file-manager/TransferMonitor.tsx index fade734b..b85a84e0 100644 --- a/src/ui/features/file-manager/TransferMonitor.tsx +++ b/src/ui/features/file-manager/TransferMonitor.tsx @@ -71,9 +71,34 @@ export function TransferMonitor() { } }; + let intervalId: ReturnType | 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(); + start(); + }; + void reconcileTransfers(); - const interval = setInterval(reconcileTransfers, POLL_INTERVAL_MS); - return () => clearInterval(interval); + if (document.visibilityState !== "hidden") start(); + document.addEventListener("visibilitychange", onVisibility); + + return () => { + stop(); + document.removeEventListener("visibilitychange", onVisibility); + }; }, [t, formatTransferMetrics]); return null; diff --git a/src/ui/features/file-manager/file-manager-types.ts b/src/ui/features/file-manager/file-manager-types.ts index 185396dc..537d29a6 100644 --- a/src/ui/features/file-manager/file-manager-types.ts +++ b/src/ui/features/file-manager/file-manager-types.ts @@ -7,6 +7,8 @@ export interface FileManagerProps { initialPath?: string; onClose?: () => void; onOpenTerminalTab?: (path?: string) => void; + /** When false, pause keepalive while the tab stays mounted in the background. */ + isVisible?: boolean; } export type ConnectionLogPayload = Omit; diff --git a/src/ui/features/homepage/use-visible-interval.ts b/src/ui/features/homepage/use-visible-interval.ts new file mode 100644 index 00000000..00896d29 --- /dev/null +++ b/src/ui/features/homepage/use-visible-interval.ts @@ -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 | 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); + } + }; +} diff --git a/src/ui/features/homepage/widgets/AlertFeedWidget.tsx b/src/ui/features/homepage/widgets/AlertFeedWidget.tsx index 3cde3f76..ceb81117 100644 --- a/src/ui/features/homepage/widgets/AlertFeedWidget.tsx +++ b/src/ui/features/homepage/widgets/AlertFeedWidget.tsx @@ -61,8 +61,35 @@ function AlertFeedWidget({ useEffect(() => { fetchData(); - const iv = setInterval(fetchData, 30_000); - return () => clearInterval(iv); + + let intervalId: ReturnType | 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]); const handleAck = async (id: number) => { diff --git a/src/ui/features/homepage/widgets/CalendarWidget.tsx b/src/ui/features/homepage/widgets/CalendarWidget.tsx index 6e388706..dc9e37c3 100644 --- a/src/ui/features/homepage/widgets/CalendarWidget.tsx +++ b/src/ui/features/homepage/widgets/CalendarWidget.tsx @@ -8,6 +8,7 @@ import type { } from "@/types/homepage-types"; import { GRID_SIZE } from "@/types/homepage-types"; import { WidgetTitle } from "./WidgetTitle"; +import { runVisibleInterval } from "../use-visible-interval"; function getMonthData(year: number, month: number, startOnMonday: boolean) { const firstDay = new Date(year, month, 1).getDay(); @@ -27,8 +28,7 @@ function CalendarWidget({ const [viewMonth, setViewMonth] = useState(now.getMonth()); useEffect(() => { - const iv = setInterval(() => setNow(new Date()), 60_000); - return () => clearInterval(iv); + return runVisibleInterval(() => setNow(new Date()), 60_000); }, []); const { daysInMonth, offset } = getMonthData( diff --git a/src/ui/features/homepage/widgets/ClockWidget.tsx b/src/ui/features/homepage/widgets/ClockWidget.tsx index 67b0a1a1..d5cb3787 100644 --- a/src/ui/features/homepage/widgets/ClockWidget.tsx +++ b/src/ui/features/homepage/widgets/ClockWidget.tsx @@ -1,18 +1,20 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import { Clock } from "lucide-react"; import { registerWidget } from "./WidgetRegistry"; import type { ClockConfig, WidgetComponentProps } from "@/types/homepage-types"; import { GRID_SIZE } from "@/types/homepage-types"; import { WidgetTitle } from "./WidgetTitle"; +import { usePageVisibleInterval } from "@/hooks/use-page-visible-interval"; function ClockWidget({ widget, config }: WidgetComponentProps) { const { timezone, showSeconds, format } = config; const [now, setNow] = useState(new Date()); - useEffect(() => { - const iv = setInterval(() => setNow(new Date()), 1000); - return () => clearInterval(iv); - }, []); + // Minute-level is enough without seconds; pause when the tab is backgrounded. + usePageVisibleInterval( + () => setNow(new Date()), + showSeconds ? 1_000 : 30_000, + ); const opts: Intl.DateTimeFormatOptions = { hour: "2-digit", diff --git a/src/ui/features/homepage/widgets/CountdownWidget.tsx b/src/ui/features/homepage/widgets/CountdownWidget.tsx index 40c74136..09e362d7 100644 --- a/src/ui/features/homepage/widgets/CountdownWidget.tsx +++ b/src/ui/features/homepage/widgets/CountdownWidget.tsx @@ -69,8 +69,32 @@ function CountdownWidget({ } }; update(); - const iv = setInterval(update, 1000); - return () => clearInterval(iv); + + let intervalId: ReturnType | 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]); const titleBar = ( diff --git a/src/ui/features/homepage/widgets/CustomApiWidget.tsx b/src/ui/features/homepage/widgets/CustomApiWidget.tsx index c170a5d4..b8001c66 100644 --- a/src/ui/features/homepage/widgets/CustomApiWidget.tsx +++ b/src/ui/features/homepage/widgets/CustomApiWidget.tsx @@ -9,6 +9,7 @@ import type { import { GRID_SIZE } from "@/types/homepage-types"; import { homepageApi } from "@/main-axios"; import { WidgetTitle } from "./WidgetTitle"; +import { runVisibleInterval } from "../use-visible-interval"; function resolvePath(obj: unknown, path: string): unknown { return path.split(".").reduce((acc: unknown, key) => { @@ -70,8 +71,9 @@ function CustomApiWidget({ useEffect(() => { fetchData(); - const iv = setInterval(fetchData, interval * 1000); - return () => clearInterval(iv); + return runVisibleInterval(() => { + void fetchData(); + }, interval * 1000); }, [url, interval]); const accent = diff --git a/src/ui/features/homepage/widgets/DockerActivityWidget.tsx b/src/ui/features/homepage/widgets/DockerActivityWidget.tsx index ebc187bd..980509ae 100644 --- a/src/ui/features/homepage/widgets/DockerActivityWidget.tsx +++ b/src/ui/features/homepage/widgets/DockerActivityWidget.tsx @@ -10,6 +10,7 @@ import { GRID_SIZE } from "@/types/homepage-types"; import { getRecentActivity } from "@/api/dashboard-api"; import type { RecentActivityItem } from "@/api/dashboard-api"; import { WidgetTitle } from "./WidgetTitle"; +import { runVisibleInterval } from "../use-visible-interval"; function relativeTime(ts: string): string { const diff = Date.now() - new Date(ts).getTime(); @@ -43,8 +44,9 @@ function DockerActivityWidget({ useEffect(() => { fetchData(); - const iv = setInterval(fetchData, 30_000); - return () => clearInterval(iv); + return runVisibleInterval(() => { + void fetchData(); + }, 30_000); }, [maxItems]); if (loading) { diff --git a/src/ui/features/homepage/widgets/HostGridWidget.tsx b/src/ui/features/homepage/widgets/HostGridWidget.tsx index 9b575177..fb69d886 100644 --- a/src/ui/features/homepage/widgets/HostGridWidget.tsx +++ b/src/ui/features/homepage/widgets/HostGridWidget.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { LayoutGrid } from "lucide-react"; import { useTranslation } from "react-i18next"; import { registerWidget } from "./WidgetRegistry"; @@ -8,9 +8,9 @@ import type { } from "@/types/homepage-types"; import { GRID_SIZE } from "@/types/homepage-types"; import { getSSHHosts } from "@/api/ssh-host-management-api"; -import { getAllServerStatuses } from "@/api/host-metrics-status-api"; import type { SSHHostWithStatus } from "@/main-axios"; import { WidgetTitle } from "./WidgetTitle"; +import { usePageVisibleInterval } from "@/hooks/use-page-visible-interval"; function getAccentColor(): string { return ( @@ -44,39 +44,27 @@ function HostGridWidget({ const [hosts, setHosts] = useState([]); const [loading, setLoading] = useState(true); - const fetchData = async () => { + // getSSHHosts already attaches cached server status — no second /status call. + const fetchData = useCallback(async () => { try { - const [allHosts, statuses] = await Promise.all([ - getSSHHosts(), - getAllServerStatuses().catch( - () => ({}) as Record, - ), - ]); + const allHosts = await getSSHHosts(); const filtered = hostIds.length > 0 ? allHosts.filter((h) => hostIds.includes(h.id)) : allHosts; - const withStatus = filtered.map((h) => ({ - ...h, - status: - (statuses as Record)[h.id]?.status ?? - h.status ?? - "unknown", - })); - setHosts(withStatus); + setHosts(filtered); } catch { /* ignore */ } finally { setLoading(false); } - }; - - useEffect(() => { - fetchData(); - const iv = setInterval(fetchData, 10_000); - return () => clearInterval(iv); }, [hostIds.join(",")]); + // Align with global status cadence; pause when the tab is hidden. + usePageVisibleInterval(() => { + void fetchData(); + }, 30_000); + if (loading) { return (
diff --git a/src/ui/features/homepage/widgets/HostStatusWidget.tsx b/src/ui/features/homepage/widgets/HostStatusWidget.tsx index 282730fb..c6bd6df7 100644 --- a/src/ui/features/homepage/widgets/HostStatusWidget.tsx +++ b/src/ui/features/homepage/widgets/HostStatusWidget.tsx @@ -130,10 +130,37 @@ function HostStatusWidget({ }; poll(); - const iv = setInterval(poll, 10000); + + let intervalId: ReturnType | 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 () => { cancelled = true; - clearInterval(iv); + stop(); + document.removeEventListener("visibilitychange", onVisibility); }; }, [hostId, needsMetrics]); diff --git a/src/ui/features/homepage/widgets/MetricsChartWidget.tsx b/src/ui/features/homepage/widgets/MetricsChartWidget.tsx index d20de2f5..39338365 100644 --- a/src/ui/features/homepage/widgets/MetricsChartWidget.tsx +++ b/src/ui/features/homepage/widgets/MetricsChartWidget.tsx @@ -12,6 +12,7 @@ import { getMetricsHistory } from "@/api/host-metrics-api"; import { getSSHHosts } from "@/api/ssh-host-management-api"; import type { MetricsHistoryRow } from "@/api/host-metrics-api"; import { WidgetTitle } from "./WidgetTitle"; +import { runVisibleInterval } from "../use-visible-interval"; function getAccentColor(): string { return ( @@ -161,8 +162,9 @@ function MetricsChartWidget({ useEffect(() => { fetchData(); - const iv = setInterval(fetchData, 60_000); - return () => clearInterval(iv); + return runVisibleInterval(() => { + void fetchData(); + }, 60_000); }, [hostId, metric, range]); const accent = getAccentColor(); diff --git a/src/ui/features/homepage/widgets/PingStatusWidget.tsx b/src/ui/features/homepage/widgets/PingStatusWidget.tsx index 500758d1..26c36442 100644 --- a/src/ui/features/homepage/widgets/PingStatusWidget.tsx +++ b/src/ui/features/homepage/widgets/PingStatusWidget.tsx @@ -9,6 +9,7 @@ import type { import { GRID_SIZE } from "@/types/homepage-types"; import { homepageApi } from "@/main-axios"; import { WidgetTitle } from "./WidgetTitle"; +import { runVisibleInterval } from "../use-visible-interval"; interface PingResult { ok: boolean; @@ -72,8 +73,9 @@ function PingStatusWidget({ useEffect(() => { fetchAll(); - const iv = setInterval(fetchAll, interval * 1000); - return () => clearInterval(iv); + return runVisibleInterval(() => { + void fetchAll(); + }, interval * 1000); }, [JSON.stringify(urls), interval]); if (urls.length === 0) { diff --git a/src/ui/features/homepage/widgets/RecentActivityWidget.tsx b/src/ui/features/homepage/widgets/RecentActivityWidget.tsx index 0b939f33..512980d0 100644 --- a/src/ui/features/homepage/widgets/RecentActivityWidget.tsx +++ b/src/ui/features/homepage/widgets/RecentActivityWidget.tsx @@ -18,6 +18,7 @@ import { GRID_SIZE } from "@/types/homepage-types"; import { getRecentActivity } from "@/api/dashboard-api"; import type { RecentActivityItem } from "@/api/dashboard-api"; import { WidgetTitle } from "./WidgetTitle"; +import { runVisibleInterval } from "../use-visible-interval"; function relativeTime(ts: string): string { const diff = Date.now() - new Date(ts).getTime(); @@ -75,8 +76,9 @@ function RecentActivityWidget({ useEffect(() => { fetchData(); - const iv = setInterval(fetchData, 60_000); - return () => clearInterval(iv); + return runVisibleInterval(() => { + void fetchData(); + }, 60_000); }, [maxItems, filterTypes.join(",")]); if (loading) { diff --git a/src/ui/features/homepage/widgets/SshQuickConnectWidget.tsx b/src/ui/features/homepage/widgets/SshQuickConnectWidget.tsx index fa26721e..caab1c1c 100644 --- a/src/ui/features/homepage/widgets/SshQuickConnectWidget.tsx +++ b/src/ui/features/homepage/widgets/SshQuickConnectWidget.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { Zap, Terminal, FolderOpen, Container } from "lucide-react"; import { useTranslation } from "react-i18next"; import { registerWidget } from "./WidgetRegistry"; @@ -8,9 +8,9 @@ import type { } from "@/types/homepage-types"; import { GRID_SIZE } from "@/types/homepage-types"; import { getSSHHosts } from "@/api/ssh-host-management-api"; -import { getAllServerStatuses } from "@/api/host-metrics-status-api"; import type { SSHHostWithStatus } from "@/main-axios"; import { WidgetTitle } from "./WidgetTitle"; +import { usePageVisibleInterval } from "@/hooks/use-page-visible-interval"; function getAccentColor(): string { return ( @@ -51,40 +51,25 @@ function SshQuickConnectWidget({ const [hosts, setHosts] = useState([]); const [loading, setLoading] = useState(true); - const fetchData = async () => { + const fetchData = useCallback(async () => { try { - const [allHosts, statuses] = await Promise.all([ - getSSHHosts(), - getAllServerStatuses().catch( - () => ({}) as Record, - ), - ]); + const allHosts = await getSSHHosts(); const filtered = hostIds.length > 0 ? allHosts.filter((h) => hostIds.includes(h.id)) : allHosts; - setHosts( - filtered.map((h) => ({ - ...h, - status: - (statuses as Record)[h.id]?.status ?? - h.status ?? - "unknown", - })), - ); + setHosts(filtered); } catch { /* ignore */ } finally { setLoading(false); } - }; - - useEffect(() => { - fetchData(); - const iv = setInterval(fetchData, 10_000); - return () => clearInterval(iv); }, [hostIds.join(",")]); + usePageVisibleInterval(() => { + void fetchData(); + }, 30_000); + if (loading) { return (
diff --git a/src/ui/features/homepage/widgets/SystemOverviewWidget.tsx b/src/ui/features/homepage/widgets/SystemOverviewWidget.tsx index 20924944..89e03213 100644 --- a/src/ui/features/homepage/widgets/SystemOverviewWidget.tsx +++ b/src/ui/features/homepage/widgets/SystemOverviewWidget.tsx @@ -10,6 +10,7 @@ import { GRID_SIZE } from "@/types/homepage-types"; import { getVersionInfo, getDatabaseHealth } from "@/api/system-status-api"; import { getUptime } from "@/api/dashboard-api"; import { WidgetTitle } from "./WidgetTitle"; +import { runVisibleInterval } from "../use-visible-interval"; interface InfoRowProps { label: string; @@ -65,8 +66,9 @@ function SystemOverviewWidget({ useEffect(() => { fetchData(); - const iv = setInterval(fetchData, 300_000); - return () => clearInterval(iv); + return runVisibleInterval(() => { + void fetchData(); + }, 300_000); }, [showVersion, showDbHealth, showUptime]); const accent = diff --git a/src/ui/features/homepage/widgets/TermixUptimeWidget.tsx b/src/ui/features/homepage/widgets/TermixUptimeWidget.tsx index c7ca1119..77e0e2cd 100644 --- a/src/ui/features/homepage/widgets/TermixUptimeWidget.tsx +++ b/src/ui/features/homepage/widgets/TermixUptimeWidget.tsx @@ -9,6 +9,7 @@ import type { import { GRID_SIZE } from "@/types/homepage-types"; import { getUptime } from "@/api/dashboard-api"; import { WidgetTitle } from "./WidgetTitle"; +import { runVisibleInterval } from "../use-visible-interval"; function formatUptime(seconds: number): { days: number; @@ -43,14 +44,12 @@ function TermixUptimeWidget({ }) .catch(() => setError(true)); - const iv = setInterval(() => { + return runVisibleInterval(() => { if (baseRef.current !== null) { const elapsed = (Date.now() - startRef.current) / 1000; setSeconds(Math.floor(baseRef.current + elapsed)); } }, 1000); - - return () => clearInterval(iv); }, []); if (error) { diff --git a/src/ui/features/homepage/widgets/WeatherWidget.tsx b/src/ui/features/homepage/widgets/WeatherWidget.tsx index 7473c34d..7af81e1e 100644 --- a/src/ui/features/homepage/widgets/WeatherWidget.tsx +++ b/src/ui/features/homepage/widgets/WeatherWidget.tsx @@ -14,6 +14,7 @@ import type { } from "@/types/homepage-types"; import { GRID_SIZE } from "@/types/homepage-types"; import { WidgetTitle } from "./WidgetTitle"; +import { runVisibleInterval } from "../use-visible-interval"; interface WttrCurrent { temp_C: string; @@ -77,10 +78,15 @@ function WeatherWidget({ } }; fetchWeather(); - const interval = setInterval(fetchWeather, 10 * 60 * 1000); + const stop = runVisibleInterval( + () => { + void fetchWeather(); + }, + 10 * 60 * 1000, + ); return () => { cancelled = true; - clearInterval(interval); + stop(); }; }, [location]); diff --git a/src/ui/features/host-metrics/HostMetricsTab.tsx b/src/ui/features/host-metrics/HostMetricsTab.tsx index 4b9b8802..a237eb39 100644 --- a/src/ui/features/host-metrics/HostMetricsTab.tsx +++ b/src/ui/features/host-metrics/HostMetricsTab.tsx @@ -286,8 +286,8 @@ function HostMetricsInner({ }, [hostConfig?.id]); React.useEffect(() => { - if (!statusCheckEnabled || !currentHostConfig?.id) { - setServerStatus("offline"); + if (!statusCheckEnabled || !currentHostConfig?.id || !isActuallyVisible) { + if (!statusCheckEnabled) setServerStatus("offline"); return; } let cancelled = false; @@ -314,6 +314,7 @@ function HostMetricsInner({ currentHostConfig?.id, statusCheckEnabled, statsConfig.statusCheckInterval, + isActuallyVisible, ]); React.useEffect(() => { diff --git a/src/ui/features/terminal/Terminal.tsx b/src/ui/features/terminal/Terminal.tsx index 3e975b3a..e844916a 100644 --- a/src/ui/features/terminal/Terminal.tsx +++ b/src/ui/features/terminal/Terminal.tsx @@ -432,20 +432,8 @@ const TerminalInner = forwardRef( }, [isVisible]); useEffect(() => { - const checkAuth = () => { - setIsAuthenticated((prev) => { - if (!prev) { - return true; - } - return prev; - }); - }; - - checkAuth(); - - const authCheckInterval = setInterval(checkAuth, 5000); - - return () => clearInterval(authCheckInterval); + // One-shot: historical code polled every 5s but only ever flipped false→true. + setIsAuthenticated(true); }, []); function hardRefresh() { @@ -2288,9 +2276,11 @@ const TerminalInner = forwardRef( element?.addEventListener("keydown", handleTabCapture, true); const resizeObserver = new ResizeObserver(() => { + // Background keep-alive tabs still observe layout; skip fit work. + if (!isVisibleRef.current) return; if (resizeTimeout.current) clearTimeout(resizeTimeout.current); resizeTimeout.current = setTimeout(() => { - if (isVisible) { + if (isVisibleRef.current) { performFit(); } }, 50); diff --git a/src/ui/features/tmux-monitor/TmuxMonitor.tsx b/src/ui/features/tmux-monitor/TmuxMonitor.tsx index ea0124ef..aef211bb 100644 --- a/src/ui/features/tmux-monitor/TmuxMonitor.tsx +++ b/src/ui/features/tmux-monitor/TmuxMonitor.tsx @@ -241,9 +241,10 @@ export function TmuxMonitor({ // -- relative time refresh -------------------------------------------------- useEffect(() => { + if (!isVisible) return; const interval = setInterval(() => setNow(Date.now()), TIME_TICK_MS); return () => clearInterval(interval); - }, []); + }, [isVisible]); // -- overview polling ----------------------------------------------------- const loadOverview = useCallback( diff --git a/src/ui/features/tunnel/TunnelTab.tsx b/src/ui/features/tunnel/TunnelTab.tsx index 7f155d46..0d21efbc 100644 --- a/src/ui/features/tunnel/TunnelTab.tsx +++ b/src/ui/features/tunnel/TunnelTab.tsx @@ -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 [sshHost, setSshHost] = useState(null); const [tunnelStatuses, setTunnelStatuses] = useState< @@ -264,14 +271,40 @@ export function TunnelTab({ host }: { label: string; host?: DemoHost }) { }, [host]); useEffect(() => { + if (!isVisible) return; + fetchHost(); - const interval = setInterval(fetchHost, 5000); window.addEventListener("ssh-hosts:changed", fetchHost); + + let intervalId: ReturnType | 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 () => { - clearInterval(interval); + stop(); + document.removeEventListener("visibilitychange", onVisibility); window.removeEventListener("ssh-hosts:changed", fetchHost); }; - }, [fetchHost]); + }, [fetchHost, isVisible]); useEffect(() => { return subscribeTunnelStatuses(setTunnelStatuses, () => {}); diff --git a/src/ui/hooks/use-page-visible-interval.ts b/src/ui/hooks/use-page-visible-interval.ts new file mode 100644 index 00000000..c01a6465 --- /dev/null +++ b/src/ui/hooks/use-page-visible-interval.ts @@ -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 | 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]); +} diff --git a/src/ui/lib/ServerStatusContext.tsx b/src/ui/lib/ServerStatusContext.tsx index 19eb15b2..7774f016 100644 --- a/src/ui/lib/ServerStatusContext.tsx +++ b/src/ui/lib/ServerStatusContext.tsx @@ -2,21 +2,20 @@ import React, { createContext, useContext, - useState, useEffect, useCallback, useRef, useMemo, + useSyncExternalStore, + useState, } from "react"; import { getAllServerStatuses, getSSHHosts } from "@/main-axios"; import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets"; - -type StatusValue = "online" | "offline" | "degraded"; - -interface ServerStatusEntry { - status: StatusValue; - lastChecked: string; -} +import { + ServerStatusStore, + type ServerStatusEntry, + type StatusValue, +} from "./server-status-store"; interface ServerStatusContextType { statuses: Map; @@ -26,6 +25,8 @@ interface ServerStatusContextType { getStatus: (hostId: number) => StatusValue; } +/** Stable for the provider lifetime — fine-grained hooks only need this. */ +const StatusStoreContext = createContext(null); const ServerStatusContext = createContext(null); const POLL_INTERVAL = 30000; @@ -37,34 +38,47 @@ export function ServerStatusProvider({ children: React.ReactNode; isAuthenticated?: boolean; }) { - const [statuses, setStatuses] = useState>( - new Map(), - ); - const [isLoading, setIsLoading] = useState(false); - const [initialLoadComplete, setInitialLoadComplete] = useState(false); - const [enabledHostIds, setEnabledHostIds] = useState>(new Set()); + const storeRef = useRef(null); + if (!storeRef.current) { + storeRef.current = new ServerStatusStore(); + } + const store = storeRef.current; + + // Bumps only full-context consumers (dashboard, folder counts, etc.). + const [version, setVersion] = useState(0); const mountedRef = useRef(true); - const enabledHostIdsRef = useRef(enabledHostIds); + const refreshInFlightRef = useRef | null>(null); useEffect(() => { - enabledHostIdsRef.current = enabledHostIds; - }, [enabledHostIds]); + return store.subscribeAll(() => { + setVersion((v) => v + 1); + }); + }, [store]); + + useEffect(() => { + return store.subscribeMeta(() => { + setVersion((v) => v + 1); + }); + }, [store]); const fetchEnabledHosts = useCallback(async () => { if (!isAuthenticated) { + store.setEnabledHostIds(new Set()); return new Set(); } try { - const hosts = await getSSHHosts(); + const hosts = await getSSHHosts({ includeStatus: false }); const enabled = new Set(); hosts.forEach((host) => { const statsConfig = (() => { try { - return host.statsConfig - ? JSON.parse(host.statsConfig) - : DEFAULT_STATS_CONFIG; + if (!host.statsConfig) return DEFAULT_STATS_CONFIG; + if (typeof host.statsConfig === "string") { + return JSON.parse(host.statsConfig); + } + return host.statsConfig; } catch { return DEFAULT_STATS_CONFIG; } @@ -75,94 +89,122 @@ export function ServerStatusProvider({ } }); - setEnabledHostIds((prev) => { - if (prev.size !== enabled.size) return enabled; - for (const id of enabled) { - if (!prev.has(id)) return enabled; - } - return prev; - }); + store.setEnabledHostIds(enabled); return enabled; } catch { - return new Set(); + return store.getEnabledHostIds(); } - }, [isAuthenticated]); + }, [isAuthenticated, store]); const refreshStatuses = useCallback(async () => { if (!mountedRef.current || !isAuthenticated) return; - - setIsLoading(true); - try { - const data = await getAllServerStatuses(); - if (!mountedRef.current) return; - - const newStatuses = new Map(); - const now = new Date().toISOString(); - - if (data && typeof data === "object") { - Object.entries(data).forEach(([idStr, statusData]) => { - const id = parseInt(idStr, 10); - if (!isNaN(id)) { - const status = - statusData?.status === "online" ? "online" : "offline"; - newStatuses.set(id, { - status, - lastChecked: statusData?.lastChecked || now, - }); - } - }); - } - - setStatuses(newStatuses); - } catch { - if (mountedRef.current) { - setStatuses((prev) => { - 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 { - if (mountedRef.current) { - setIsLoading(false); - setInitialLoadComplete(true); - } + if ( + typeof document !== "undefined" && + document.visibilityState === "hidden" + ) { + return; } - }, [isAuthenticated]); - const stableEnabledHostIds = useMemo(() => enabledHostIds, [enabledHostIds]); + if (refreshInFlightRef.current) { + return refreshInFlightRef.current; + } + + const showLoading = !store.getInitialLoadComplete(); + if (showLoading) store.setLoading(true); + + const run = (async () => { + try { + const data = await getAllServerStatuses(); + if (!mountedRef.current) return; + + const newStatuses = new Map(); + const now = new Date().toISOString(); + + if (data && typeof data === "object") { + Object.entries(data).forEach(([idStr, statusData]) => { + const id = parseInt(idStr, 10); + if (!isNaN(id)) { + const status = + statusData?.status === "online" ? "online" : "offline"; + newStatuses.set(id, { + status, + lastChecked: statusData?.lastChecked || now, + }); + } + }); + } + + store.applyStatuses(newStatuses); + } catch { + if (mountedRef.current) { + store.markDegraded(store.getEnabledHostIds()); + } + } finally { + if (mountedRef.current) { + if (showLoading) store.setLoading(false); + store.setInitialLoadComplete(true); + } + } + })(); + + refreshInFlightRef.current = run.finally(() => { + refreshInFlightRef.current = null; + }); + return refreshInFlightRef.current; + }, [isAuthenticated, store]); const getStatus = useCallback( - (hostId: number): StatusValue => { - if (!stableEnabledHostIds.has(hostId)) { - return "offline"; - } - return statuses.get(hostId)?.status || "degraded"; - }, - [statuses, stableEnabledHostIds], + (hostId: number): StatusValue => store.getStatus(hostId), + [store], ); useEffect(() => { mountedRef.current = true; + let intervalId: ReturnType | 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 () => { await fetchEnabledHosts(); 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 () => { mountedRef.current = false; - clearInterval(intervalId); + stopPolling(); + document.removeEventListener("visibilitychange", onVisibility); }; }, [fetchEnabledHosts, refreshStatuses]); @@ -181,19 +223,34 @@ export function ServerStatusProvider({ }; }, [fetchEnabledHosts, refreshStatuses]); - return ( - - {children} - + const contextValue = useMemo( + () => ({ + statuses: store.getStatuses(), + isLoading: store.getIsLoading(), + initialLoadComplete: store.getInitialLoadComplete(), + refreshStatuses, + getStatus, + }), + // version refreshes statuses/isLoading/initialLoadComplete snapshots + // eslint-disable-next-line react-hooks/exhaustive-deps + [version, refreshStatuses, getStatus], ); + + return ( + + + {children} + + + ); +} + +function useStatusStore(): ServerStatusStore { + const store = useContext(StatusStoreContext); + if (!store) { + throw new Error("Server status store hooks require ServerStatusProvider"); + } + return store; } export function useServerStatus() { @@ -206,15 +263,44 @@ export function useServerStatus() { 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( hostId: number, statusCheckEnabled: boolean = true, -) { - const { getStatus } = useServerStatus(); +): StatusValue | null { + const store = useStatusStore(); + + const status = useSyncExternalStore( + (onChange) => store.subscribeHost(hostId, onChange), + () => store.getHostSnapshot(hostId), + () => store.getHostSnapshot(hostId), + ); if (!statusCheckEnabled) { - return "offline" as StatusValue; + return null; } - - return getStatus(hostId); + return status; +} + +/** 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(), + }; } diff --git a/src/ui/lib/hosts-request-cache.ts b/src/ui/lib/hosts-request-cache.ts new file mode 100644 index 00000000..3b073ffa --- /dev/null +++ b/src/ui/lib/hosts-request-cache.ts @@ -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(HOSTS_TTL_MS); +const statusCache = + createTtlRequestCache>(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, +): Promise { + bindInvalidationListeners(); + return hostsCache.get(loader); +} + +export function getCachedServerStatuses( + loader: () => Promise>, +): Promise> { + 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(); +} diff --git a/src/ui/lib/server-status-store.test.ts b/src/ui/lib/server-status-store.test.ts new file mode 100644 index 00000000..e48501a4 --- /dev/null +++ b/src/ui/lib/server-status-store.test.ts @@ -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); + }); +}); diff --git a/src/ui/lib/server-status-store.ts b/src/ui/lib/server-status-store.ts new file mode 100644 index 00000000..97fa5d64 --- /dev/null +++ b/src/ui/lib/server-status-store.ts @@ -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(); + private enabledHostIds = new Set(); + private initialLoadComplete = false; + private isLoading = false; + private readonly hostListeners = new Map>(); + private readonly allListeners = new Set(); + private readonly metaListeners = new Set(); + + getStatus(hostId: number): StatusValue { + if (!this.enabledHostIds.has(hostId)) { + return "offline"; + } + return this.statuses.get(hostId)?.status || "degraded"; + } + + getStatuses(): Map { + return this.statuses; + } + + getEnabledHostIds(): Set { + 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): 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): 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): 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, b: Set): boolean { + if (a.size !== b.size) return false; + for (const id of a) { + if (!b.has(id)) return false; + } + return true; +} diff --git a/src/ui/lib/ttl-request-cache.test.ts b/src/ui/lib/ttl-request-cache.test.ts new file mode 100644 index 00000000..8c1acf32 --- /dev/null +++ b/src/ui/lib/ttl-request-cache.test.ts @@ -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(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(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(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(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(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); + }); +}); diff --git a/src/ui/lib/ttl-request-cache.ts b/src/ui/lib/ttl-request-cache.ts new file mode 100644 index 00000000..971c34ef --- /dev/null +++ b/src/ui/lib/ttl-request-cache.ts @@ -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 = { + get(loader: () => Promise): Promise; + invalidate(): void; + peek(): T | null; +}; + +export function createTtlRequestCache(ttlMs: number): TtlRequestCache { + let cached: { value: T; expiresAt: number } | null = null; + let inflight: Promise | null = null; + + return { + get(loader: () => Promise): Promise { + 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; + }, + }; +} diff --git a/src/ui/shell/tabUtils.tsx b/src/ui/shell/tabUtils.tsx index ad83c97b..33df0066 100644 --- a/src/ui/shell/tabUtils.tsx +++ b/src/ui/shell/tabUtils.tsx @@ -15,32 +15,86 @@ import { TerminalSquare, Layers, // --- tmux-monitor --- } from "lucide-react"; +import { lazy, Suspense } from "react"; 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 { Terminal as TerminalFeature } from "@/features/terminal/Terminal"; import type { TerminalHandle, TerminalHostConfig, } 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 { DashboardTab } from "@/dashboard/DashboardTab"; -import { HomepageCanvas } from "@/features/homepage/HomepageCanvas"; -import { TunnelTab } from "@/features/tunnel/TunnelTab"; -import { NetworkGraphCard } from "@/dashboard/cards/NetworkGraphCard"; +import { useIsMobile } from "@/hooks/use-mobile"; import type { Tab, TabType, Host } from "@/types/ui-types"; import type { SSHHost } from "@/types"; 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 { return { id: parseInt(h.id, 10), @@ -95,6 +149,18 @@ function EmptyState({ ); } +function TabChunkFallback() { + return ( +
+
+
+ ); +} + +function withTabSuspense(node: React.ReactNode) { + return }>{node}; +} + export function tabIcon(type: TabType) { switch (type) { case "dashboard": @@ -154,7 +220,7 @@ function TerminalTabContent({ }) { const { previewTerminalTheme } = useTabsSafe(); const isMobile = useIsMobile(); - return ( + return withTabSuspense(
@@ -192,7 +258,7 @@ function TerminalTabContent({ /> )}
- + , ); } @@ -211,11 +277,12 @@ export function renderTabContent( switch (tab.type) { case "dashboard": - return ( + return withTabSuspense( + isVisible={isVisible} + />, ); case "terminal": @@ -253,29 +320,30 @@ export function renderTabContent( messageKey="fileManager.noHostSelected" /> ); - return ( + return withTabSuspense( onOpenTerminalTab(host, path) : undefined } - /> + />, ); case "docker": if (!host) return ; - return ( + return withTabSuspense( + />, ); case "host-metrics": @@ -283,18 +351,20 @@ export function renderTabContent( return ( ); - return ( + return withTabSuspense( + />, ); case "tunnel": - return ; + return withTabSuspense( + , + ); case "rdp": case "vnc": @@ -303,41 +373,43 @@ export function renderTabContent( return ( ); - return ( + return withTabSuspense( } hostId={host.id} tabId={tab.id} protocol={tab.type as "rdp" | "vnc" | "telnet"} - /> + />, ); case "network_graph": - return ; + return withTabSuspense( + , + ); // --- tmux-monitor --- case "tmux_monitor": - return ( + return withTabSuspense( + />, ); case "serial": if (!tab.serialConfig) return ; - return ( + return withTabSuspense( } config={tab.serialConfig} isVisible={isVisible} instanceId={tab.instanceId} - /> + />, ); case "homepage": - return ; + return withTabSuspense(); case "host-manager": case "user-profile": diff --git a/src/ui/sidebar/AppRail.tsx b/src/ui/sidebar/AppRail.tsx index 5bf2055d..f1ed073b 100644 --- a/src/ui/sidebar/AppRail.tsx +++ b/src/ui/sidebar/AppRail.tsx @@ -171,18 +171,43 @@ export function AppRail({ useEffect(() => { let cancelled = false; + let intervalId: ReturnType | null = null; + const poll = () => { + if (document.visibilityState === "hidden") return; getAlertFirings({ acknowledged: false, limit: 50 }) .then((firings) => { if (!cancelled) setUnreadAlerts(firings.length); }) .catch(() => {}); }; + + const start = () => { + if (intervalId !== null) return; + intervalId = setInterval(poll, 30000); + }; + const stop = () => { + if (intervalId === null) return; + clearInterval(intervalId); + intervalId = null; + }; + const onVisibility = () => { + if (document.visibilityState === "hidden") { + stop(); + return; + } + poll(); + start(); + }; + poll(); - const iv = setInterval(poll, 30000); + if (document.visibilityState !== "hidden") start(); + document.addEventListener("visibilitychange", onVisibility); + return () => { cancelled = true; - clearInterval(iv); + stop(); + document.removeEventListener("visibilitychange", onVisibility); }; }, []); const [hiddenTabs, setHiddenTabs] = useState>(() => { diff --git a/src/ui/sidebar/ConnectionsPanel.tsx b/src/ui/sidebar/ConnectionsPanel.tsx index 78d10a1e..061effdb 100644 --- a/src/ui/sidebar/ConnectionsPanel.tsx +++ b/src/ui/sidebar/ConnectionsPanel.tsx @@ -17,6 +17,7 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/tooltip"; +import { usePageVisibleInterval } from "@/hooks/use-page-visible-interval"; const CONNECTION_TAB_TYPES: TabType[] = [ "terminal", @@ -49,6 +50,28 @@ function formatDuration(ms: number): string { return `${h}h ${m % 60}m`; } +function sessionsUnchanged( + prev: ActiveSessionInfo[], + next: ActiveSessionInfo[], +): boolean { + if (prev.length !== next.length) return false; + for (let i = 0; i < prev.length; i++) { + const a = prev[i]; + const b = next[i]; + if ( + a.sessionId !== b.sessionId || + a.hostId !== b.hostId || + a.hostName !== b.hostName || + a.tabInstanceId !== b.tabInstanceId || + a.isConnected !== b.isConnected || + a.createdAt !== b.createdAt + ) { + return false; + } + } + return true; +} + function formatExpiry(updatedAt: string): string { const TTL_MS = 30 * 60 * 1000; const elapsed = Date.now() - new Date(updatedAt).getTime(); @@ -285,7 +308,6 @@ export function ConnectionsPanel({ const [now, setNow] = useState(Date.now()); const [activeSessions, setActiveSessions] = useState([]); const [search, setSearch] = useState(""); - const pollTimerRef = useRef | null>(null); // Drag-to-reorder state const [dragTabId, setDragTabId] = useState(null); @@ -322,17 +344,15 @@ export function ConnectionsPanel({ }) : backgroundTabs; - useEffect(() => { - const id = setInterval(() => setNow(Date.now()), 1000); - return () => clearInterval(id); - }, []); + // Duration labels only need minute-level freshness; 1s ticks re-render the whole panel. + usePageVisibleInterval(() => setNow(Date.now()), 15_000); const refresh = useCallback(async () => { try { const sessions = await getActiveSessions(); setActiveSessions((prev) => { const next = Array.isArray(sessions) ? sessions : []; - if (JSON.stringify(prev) === JSON.stringify(next)) return prev; + if (sessionsUnchanged(prev, next)) return prev; return next; }); } catch { @@ -340,13 +360,10 @@ export function ConnectionsPanel({ } }, []); - useEffect(() => { - refresh(); - pollTimerRef.current = setInterval(refresh, 5000); - return () => { - if (pollTimerRef.current) clearInterval(pollTimerRef.current); - }; - }, [refresh]); + // Initial fetch + visibility-aware poll (hook fires once on mount). + usePageVisibleInterval(() => { + void refresh(); + }, 5_000); // Global pointer listeners for drag reorder useEffect(() => { diff --git a/src/ui/sidebar/HostsPanel.tsx b/src/ui/sidebar/HostsPanel.tsx index 220b7228..01d33b76 100644 --- a/src/ui/sidebar/HostsPanel.tsx +++ b/src/ui/sidebar/HostsPanel.tsx @@ -292,9 +292,25 @@ export function HostsPanel({ } useEffect(() => { - getSSHHosts() - .then(setRawHosts) - .catch(() => {}); + let cancelled = false; + const load = () => { + getSSHHosts() + .then((hosts) => { + if (!cancelled) setRawHosts(hosts); + }) + .catch(() => {}); + }; + load(); + const onChanged = () => load(); + window.addEventListener("ssh-hosts:changed", onChanged); + window.addEventListener("hosts:refresh", onChanged); + window.addEventListener("termix:hosts-changed", onChanged); + return () => { + cancelled = true; + window.removeEventListener("ssh-hosts:changed", onChanged); + window.removeEventListener("hosts:refresh", onChanged); + window.removeEventListener("termix:hosts-changed", onChanged); + }; }, []); function handleEditingChange(editing: boolean) { diff --git a/src/ui/sidebar/SessionLogsPanel.tsx b/src/ui/sidebar/SessionLogsPanel.tsx index b6f7a5d5..9fa93074 100644 --- a/src/ui/sidebar/SessionLogsPanel.tsx +++ b/src/ui/sidebar/SessionLogsPanel.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { copyToClipboard } from "@/lib/clipboard"; +import { usePageVisibleInterval } from "@/hooks/use-page-visible-interval"; import { Input } from "@/components/input"; import { Tooltip, @@ -190,10 +191,11 @@ export function SessionLogsPanel() { .then((fresh) => { setLogs((prev) => { if ( - JSON.stringify(prev.map((l) => l.id)) === - JSON.stringify(fresh.map((l) => l.id)) - ) + prev.length === fresh.length && + prev.every((log, i) => log.id === fresh[i]?.id) + ) { return prev; + } return fresh; }); }) @@ -212,10 +214,17 @@ export function SessionLogsPanel() { getSessionRecordingRetention() .then(setRetentionDays) .catch(() => setRetentionDays(null)); - const interval = setInterval(() => load(false), 5000); - return () => clearInterval(interval); }, [load]); + usePageVisibleInterval( + () => { + void load(false); + }, + 5_000, + true, + { runOnMount: false }, + ); + const q = filter.trim().toLowerCase(); const filtered = q ? logs.filter((l) => diff --git a/src/ui/sidebar/SidebarTree.tsx b/src/ui/sidebar/SidebarTree.tsx index 8ab21420..a6ad4938 100644 --- a/src/ui/sidebar/SidebarTree.tsx +++ b/src/ui/sidebar/SidebarTree.tsx @@ -1,6 +1,13 @@ /* eslint-disable react-refresh/only-export-components */ -import { useState, useEffect, type MouseEvent } from "react"; +import { + useState, + useEffect, + useRef, + useLayoutEffect, + type MouseEvent, +} from "react"; import { useTranslation } from "react-i18next"; +import { useVirtualizer } from "@tanstack/react-virtual"; import { Box, Boxes, @@ -62,7 +69,11 @@ import { useStatusColorScheme, getStatusClasses, } from "@/hooks/use-status-color-scheme"; -import { useServerStatus } from "@/lib/ServerStatusContext"; +import { + useHostStatus, + useServerStatus, + useServerStatusMeta, +} from "@/lib/ServerStatusContext"; import { Tooltip, TooltipContent, @@ -271,6 +282,7 @@ export function HostItem({ onTrayOpenChange, onDragStart, onDragEnd, + depth = 0, }: { host: Host; onOpenTab: (type: TabType) => void; @@ -290,6 +302,8 @@ export function HostItem({ onProxmoxDiscover?: () => void; onDragStart?: () => void; onDragEnd?: () => void; + /** Nesting level when rendered in a flattened virtual list. */ + depth?: number; }) { const { t } = useTranslation(); const metricsEnabled = @@ -305,10 +319,11 @@ export function HostItem({ () => localStorage.getItem("compactHostView") === "true", ); const statusScheme = useStatusColorScheme(); - const { initialLoadComplete, getStatus } = useServerStatus(); + const { initialLoadComplete } = useServerStatusMeta(); const statusCheckOn = statusCheckEnabled(host); const statusLoading = !initialLoadComplete && statusCheckOn; - const liveStatus = statusCheckOn ? getStatus(Number(host.id)) : null; + // Per-host subscription — status polls only re-render rows that flipped. + const liveStatus = useHostStatus(Number(host.id), statusCheckOn); const isOnline = liveStatus != null ? liveStatus === "online" : host.online; const isTouchOnly = typeof window !== "undefined" && window.matchMedia("(hover: none)").matches; @@ -372,6 +387,9 @@ export function HostItem({ if (query && !hostMatchesQuery(host, query)) return null; + const depthStyle = + depth > 0 ? ({ paddingLeft: depth * 12 } as const) : undefined; + if (compactHostView) { return (
onDragEnd?.()} + style={depthStyle} className={`group relative flex items-stretch cursor-pointer select-none transition-colors hover:bg-muted/40 ${ selected ? "bg-accent-brand/5" @@ -882,6 +901,7 @@ export function HostItem({ onDragStart?.(); }} onDragEnd={() => onDragEnd?.()} + style={depthStyle} className={`group relative flex items-stretch cursor-pointer select-none transition-colors hover:bg-muted/40 ${ selected ? "bg-accent-brand/5" @@ -1483,6 +1503,9 @@ export function FolderItem({ draggedHostIds, onDragHostStart, onDragEnd, + /** When true, only render the folder header (children come from the virtual list). */ + flat = false, + stripeIndex: stripeIndexProp, }: { folder: HostFolder; depth?: number; @@ -1493,7 +1516,7 @@ export function FolderItem({ onDuplicateHost: (host: Host) => void; onProxmoxDiscover?: (host: Host) => void; query?: string; - stripeMap: Map; + stripeMap?: Map; openFolders: Set; onToggleFolder: (name: string) => void; selectionMode: boolean; @@ -1510,6 +1533,8 @@ export function FolderItem({ draggedHostIds: string[] | null; onDragHostStart: (hostId: string) => void; onDragEnd: () => void; + flat?: boolean; + stripeIndex?: number; }) { const { t } = useTranslation(); const { getStatus, initialLoadComplete } = useServerStatus(); @@ -1525,13 +1550,14 @@ export function FolderItem({ const folderPath = folder.path ?? folder.name; const isOpen = query ? true : openFolders.has(folderPath); - const stripeIndex = stripeMap.get(folder) ?? 0; + const stripeIndex = stripeIndexProp ?? stripeMap?.get(folder) ?? 0; // Synthetic group headers (group-by tag/status/etc.) are not real folders, so // they can't be edited, deleted, or used as drop targets. const isGroup = folderPath.startsWith("__group__:"); return (
0 ? { paddingLeft: depth * 12 } : undefined} onDragOver={(e) => { if (draggedHostIds && !isGroup) { e.preventDefault(); @@ -1611,7 +1637,7 @@ export function FolderItem({ } - {isOpen && ( + {!flat && isOpen && (
{folder.children.map((child, i) => isFolder(child) ? ( @@ -1657,7 +1683,7 @@ export function FolderItem({ onDelete={() => onDeleteHost(child)} onDuplicate={() => onDuplicateHost(child)} query={query} - stripeIndex={stripeMap.get(child) ?? 0} + stripeIndex={stripeMap?.get(child) ?? 0} selectionMode={selectionMode} selected={selectedHostIds.has(child.id)} onToggleSelect={() => onToggleSelect(child.id)} @@ -1961,9 +1987,33 @@ export function SidebarTree({ const allFolderPaths = collectAllFolderPaths(children); const visibleRows = collectVisibleRows(children, query, openFolders); - const stripeMap = new Map( - visibleRows.map((r, i) => [r.item, i]), - ); + const parentRef = useRef(null); + + const virtualizer = useVirtualizer({ + count: visibleRows.length, + getScrollElement: () => parentRef.current, + estimateSize: (index) => { + const row = visibleRows[index]; + if (!row) return 36; + if (isFolder(row.item)) return 36; + // Expanded action tray is taller than a single host row. + if (openTrayHostId === row.item.id) return 88; + return 40; + }, + overscan: 12, + getItemKey: (index) => { + const row = visibleRows[index]; + if (!row) return index; + return isFolder(row.item) + ? `folder:${row.item.path ?? row.item.name}` + : `host:${row.item.id}`; + }, + }); + + // Remeasure when tray open state or tree shape changes (variable row heights). + useLayoutEffect(() => { + virtualizer.measure(); + }, [virtualizer, openTrayHostId, openFolders, query, visibleRows.length]); if (loading) { return ( @@ -1993,6 +2043,7 @@ export function SidebarTree({ return (
{ if (draggedHostIds) { @@ -2019,66 +2070,91 @@ export function SidebarTree({
) : ( - children.map((child, i) => - isFolder(child) ? ( - setDraggedHostIds(null)} - /> - ) : ( - onOpenTab(child, type)} - onEditHost={() => onEditHost(child)} - onShareHost={onShareHost ? () => onShareHost(child) : undefined} - onProxmoxDiscover={ - onProxmoxDiscover ? () => onProxmoxDiscover(child) : undefined - } - onDelete={() => handleDeleteHost(child)} - onDuplicate={() => handleDuplicateHost(child)} - query={query} - stripeIndex={stripeMap.get(child) ?? 0} - selectionMode={selectionMode} - selected={selectedHostIds.has(child.id)} - onToggleSelect={() => toggleSelect(child.id)} - isMenuOpen={openMenuHostId === child.id} - onMenuOpenChange={(open) => - setOpenMenuHostId(open ? child.id : null) - } - isTrayOpen={openTrayHostId === child.id} - onTrayOpenChange={(open) => - setOpenTrayHostId(open ? child.id : null) - } - onDragStart={() => handleDragHostStart(child.id)} - onDragEnd={() => setDraggedHostIds(null)} - /> - ), - ) +
+ {virtualizer.getVirtualItems().map((vItem) => { + const row = visibleRows[vItem.index]; + if (!row) return null; + const { item, depth } = row; + return ( +
+ {isFolder(item) ? ( + setDraggedHostIds(null)} + stripeIndex={vItem.index} + /> + ) : ( + onOpenTab(item, type)} + onEditHost={() => onEditHost(item)} + onShareHost={ + onShareHost ? () => onShareHost(item) : undefined + } + onProxmoxDiscover={ + onProxmoxDiscover + ? () => onProxmoxDiscover(item) + : undefined + } + onDelete={() => handleDeleteHost(item)} + onDuplicate={() => handleDuplicateHost(item)} + query={query} + stripeIndex={vItem.index} + selectionMode={selectionMode} + selected={selectedHostIds.has(item.id)} + onToggleSelect={() => toggleSelect(item.id)} + isMenuOpen={openMenuHostId === item.id} + onMenuOpenChange={(open) => + setOpenMenuHostId(open ? item.id : null) + } + isTrayOpen={openTrayHostId === item.id} + onTrayOpenChange={(open) => + setOpenTrayHostId(open ? item.id : null) + } + onDragStart={() => handleDragHostStart(item.id)} + onDragEnd={() => setDraggedHostIds(null)} + /> + )} +
+ ); + })} +
)}
diff --git a/src/ui/sidebar/sidebar-tree-visible-rows.test.ts b/src/ui/sidebar/sidebar-tree-visible-rows.test.ts new file mode 100644 index 00000000..a13fef40 --- /dev/null +++ b/src/ui/sidebar/sidebar-tree-visible-rows.test.ts @@ -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, + 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"]); + }); +});