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