mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
feat: rework Electron desktop app to run standalone-first with optional two-way sync to a remote Termix server
This commit is contained in:
@@ -1,6 +1,15 @@
|
||||
import { handleApiError, statsApi } from "@/main-axios";
|
||||
import type { HostMetricsLayout } from "@/types/host-metrics";
|
||||
|
||||
// Every function below is keyed by a host's numeric database id, and the
|
||||
// receiving backend must own that host in its own database -- a synced
|
||||
// host has a different numeric id on each side (only its syncId matches
|
||||
// across them). These calls always target the embedded local backend; see
|
||||
// getAllServerStatuses in host-metrics-status-api.ts for the one metrics
|
||||
// call that IS safely merged across local + remote (a process-local,
|
||||
// in-memory aggregate keyed by whichever host ids that process happens to
|
||||
// know about, not a per-host lookup).
|
||||
|
||||
export interface MetricsHistoryRow {
|
||||
ts: string;
|
||||
cpu_percent: number | null;
|
||||
|
||||
@@ -1,8 +1,31 @@
|
||||
import axios, { type AxiosRequestConfig } from "axios";
|
||||
import { handleApiError, statsApi } from "@/main-axios";
|
||||
import {
|
||||
handleApiError,
|
||||
statsApi,
|
||||
getRemoteStatsApi,
|
||||
isElectron,
|
||||
} from "@/main-axios";
|
||||
import type { ServerMetrics, ServerStatus } from "@/main-axios";
|
||||
import { getCachedServerStatuses } from "@/lib/hosts-request-cache";
|
||||
|
||||
// Metrics collection/viewer registration below (startMetricsPolling,
|
||||
// registerMetricsViewer, etc.) is NOT origin-routed: the backend that
|
||||
// receives the call must own the target host by numeric database id, and a
|
||||
// synced host has a different numeric id in each database (only its
|
||||
// syncId matches across them). Only the aggregate status read is merged
|
||||
// across local + remote, same as tunnel status.
|
||||
async function isRemoteSyncConnected(): Promise<boolean> {
|
||||
if (!isElectron()) return false;
|
||||
try {
|
||||
const config = (await window.electronAPI?.invoke?.(
|
||||
"get-remote-sync-config",
|
||||
)) as { serverUrl?: string } | null;
|
||||
return !!config?.serverUrl;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
type ApiConnectionLog = {
|
||||
type: "info" | "success" | "warning" | "error";
|
||||
stage: string;
|
||||
@@ -76,6 +99,7 @@ export async function getAllServerStatuses(): Promise<
|
||||
> {
|
||||
return getCachedServerStatuses(async () => {
|
||||
let lastError: unknown = null;
|
||||
let localStatuses: Record<number, ServerStatus> = {};
|
||||
|
||||
for (let i = 0; i < STATUS_RETRY_SCHEDULE.length; i++) {
|
||||
const { timeoutMs, pauseAfterMs } = STATUS_RETRY_SCHEDULE[i];
|
||||
@@ -89,7 +113,9 @@ export async function getAllServerStatuses(): Promise<
|
||||
// blips don't look like real outages.
|
||||
__silentRetry: !isFinalAttempt,
|
||||
} as AxiosRequestConfig & { __silentRetry?: boolean });
|
||||
return response.data || {};
|
||||
localStatuses = response.data || {};
|
||||
lastError = null;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!isTransientStatusError(error)) {
|
||||
@@ -102,8 +128,24 @@ export async function getAllServerStatuses(): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
handleApiError(lastError, "fetch server statuses");
|
||||
return {};
|
||||
if (lastError) {
|
||||
handleApiError(lastError, "fetch server statuses");
|
||||
return {};
|
||||
}
|
||||
|
||||
if (await isRemoteSyncConnected()) {
|
||||
try {
|
||||
const remoteResult = await getRemoteStatsApi().get("/status", {
|
||||
timeout: 8000,
|
||||
__silentRetry: true,
|
||||
} as AxiosRequestConfig & { __silentRetry?: boolean });
|
||||
return { ...localStatuses, ...(remoteResult.data || {}) };
|
||||
} catch {
|
||||
// remote unreachable this tick -- fall back to local-only statuses
|
||||
}
|
||||
}
|
||||
|
||||
return localStatuses;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import axios from "axios";
|
||||
import { getBasePath } from "@/lib/base-path";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { authApi, getServerConfig, handleApiError } from "@/main-axios";
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
|
||||
export interface ResolvedShareLink {
|
||||
protocol: "ssh" | "rdp" | "vnc" | "telnet";
|
||||
@@ -30,17 +30,18 @@ const isDev = (): boolean =>
|
||||
window.location.port === "");
|
||||
|
||||
// Guests have no session/JWT, so this deliberately builds a bare base URL
|
||||
// rather than going through main-axios's authenticated instances.
|
||||
// rather than going through main-axios's authenticated instances. The
|
||||
// desktop app always runs its embedded local backend as the source of
|
||||
// truth, so a share link opened there always resolves against it --
|
||||
// joining a session hosted on someone else's remote server isn't
|
||||
// supported from the desktop app today.
|
||||
async function resolveApiBaseUrl(): Promise<string> {
|
||||
if (isDev()) {
|
||||
const protocol = window.location.protocol === "https:" ? "https" : "http";
|
||||
return `${protocol}://localhost:30001`;
|
||||
}
|
||||
if (isElectron()) {
|
||||
const serverConfig = await getServerConfig();
|
||||
const configuredUrl = serverConfig?.serverUrl;
|
||||
if (configuredUrl) return configuredUrl.replace(/\/$/, "");
|
||||
return "http://localhost:30001";
|
||||
return "http://127.0.0.1:30001";
|
||||
}
|
||||
return getBasePath();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import axios from "axios";
|
||||
import { authApi, fileManagerApi, handleApiError } from "@/main-axios";
|
||||
import {
|
||||
authApi,
|
||||
fileManagerApi,
|
||||
handleApiError,
|
||||
getFileManagerApiForSession,
|
||||
setSessionOrigin,
|
||||
clearSessionOrigin,
|
||||
} from "@/main-axios";
|
||||
import { resolveConnectionOrigin } from "@/lib/connection-origin";
|
||||
import { fileLogger } from "@/lib/frontend-logger";
|
||||
import type { SSHHost } from "@/types/index";
|
||||
|
||||
@@ -72,7 +80,7 @@ export async function connectSSH(
|
||||
},
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post(
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/connect",
|
||||
{ sessionId, ...config },
|
||||
{ timeout: 120000 },
|
||||
@@ -121,12 +129,15 @@ export async function disconnectSSH(
|
||||
sessionId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/ssh/disconnect", {
|
||||
sessionId,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/disconnect",
|
||||
{ sessionId },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "disconnect SSH");
|
||||
} finally {
|
||||
clearSessionOrigin(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,10 +146,10 @@ export async function verifySSHTOTP(
|
||||
totpCode: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/ssh/connect-totp", {
|
||||
sessionId,
|
||||
totpCode,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/connect-totp",
|
||||
{ sessionId, totpCode },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "verify SSH TOTP");
|
||||
@@ -149,9 +160,10 @@ export async function verifySSHWarpgate(
|
||||
sessionId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/ssh/connect-warpgate", {
|
||||
sessionId,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/connect-warpgate",
|
||||
{ sessionId },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "verify SSH Warpgate");
|
||||
@@ -239,9 +251,10 @@ export async function getSSHStatus(
|
||||
sessionId: string,
|
||||
): Promise<{ connected: boolean }> {
|
||||
try {
|
||||
const response = await fileManagerApi.get("/ssh/status", {
|
||||
params: { sessionId },
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).get(
|
||||
"/ssh/status",
|
||||
{ params: { sessionId } },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "get SSH status");
|
||||
@@ -252,9 +265,10 @@ export async function keepSSHAlive(
|
||||
sessionId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/ssh/keepalive", {
|
||||
sessionId,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/keepalive",
|
||||
{ sessionId },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "SSH keepalive");
|
||||
@@ -266,9 +280,10 @@ export async function listSSHFiles(
|
||||
path: string,
|
||||
): Promise<{ files: unknown[]; path: string }> {
|
||||
try {
|
||||
const response = await fileManagerApi.get("/ssh/listFiles", {
|
||||
params: { sessionId, path },
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).get(
|
||||
"/ssh/listFiles",
|
||||
{ params: { sessionId, path } },
|
||||
);
|
||||
return response.data || { files: [], path };
|
||||
} catch (error) {
|
||||
handleApiError(error, "list SSH files");
|
||||
@@ -281,9 +296,10 @@ export async function identifySSHSymlink(
|
||||
path: string,
|
||||
): Promise<{ path: string; target: string; type: "directory" | "file" }> {
|
||||
try {
|
||||
const response = await fileManagerApi.get("/ssh/identifySymlink", {
|
||||
params: { sessionId, path },
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).get(
|
||||
"/ssh/identifySymlink",
|
||||
{ params: { sessionId, path } },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "identify SSH symlink");
|
||||
@@ -295,9 +311,10 @@ export async function resolveSSHPath(
|
||||
path: string,
|
||||
): Promise<string> {
|
||||
try {
|
||||
const response = await fileManagerApi.get("/ssh/resolvePath", {
|
||||
params: { sessionId, path },
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).get(
|
||||
"/ssh/resolvePath",
|
||||
{ params: { sessionId, path } },
|
||||
);
|
||||
return response.data?.resolvedPath || path;
|
||||
} catch {
|
||||
return path;
|
||||
@@ -313,9 +330,10 @@ export async function readSSHFile(
|
||||
encoding?: "base64" | "utf8";
|
||||
}> {
|
||||
try {
|
||||
const response = await fileManagerApi.get("/ssh/readFile", {
|
||||
params: { sessionId, path },
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).get(
|
||||
"/ssh/readFile",
|
||||
{ params: { sessionId, path } },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: unknown) {
|
||||
if (error.response?.status === 404) {
|
||||
@@ -340,13 +358,10 @@ export async function writeSSHFile(
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/ssh/writeFile", {
|
||||
sessionId,
|
||||
path,
|
||||
content,
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/writeFile",
|
||||
{ sessionId, path, content, hostId, userId },
|
||||
);
|
||||
|
||||
if (
|
||||
response.data &&
|
||||
@@ -410,7 +425,7 @@ export async function uploadSSHFile(
|
||||
form.append("totalSize", String(file.size));
|
||||
form.append("chunk", chunkBlob, fileName);
|
||||
|
||||
const response = await fileManagerApi.postForm(
|
||||
const response = await getFileManagerApiForSession(sessionId).postForm(
|
||||
"/ssh/uploadFileChunk",
|
||||
form,
|
||||
{ timeout: 0 },
|
||||
@@ -444,7 +459,7 @@ export async function uploadSSHFile(
|
||||
if (userId !== undefined) form.append("userId", userId);
|
||||
form.append("file", file, fileName);
|
||||
|
||||
const response = await fileManagerApi.postForm(
|
||||
const response = await getFileManagerApiForSession(sessionId).postForm(
|
||||
"/ssh/uploadFileStream",
|
||||
form,
|
||||
{
|
||||
@@ -464,7 +479,7 @@ export async function downloadSSHFile(
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post(
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/downloadFile",
|
||||
{
|
||||
sessionId,
|
||||
@@ -484,7 +499,7 @@ export async function downloadSSHFileStream(
|
||||
sessionId: string,
|
||||
filePath: string,
|
||||
): Promise<void> {
|
||||
const response = await fileManagerApi.post(
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/downloadFileStream",
|
||||
{ sessionId, path: filePath },
|
||||
{ responseType: "blob", timeout: 0 },
|
||||
@@ -503,14 +518,10 @@ export async function createSSHFile(
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/ssh/createFile", {
|
||||
sessionId,
|
||||
path,
|
||||
fileName,
|
||||
content,
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/createFile",
|
||||
{ sessionId, path, fileName, content, hostId, userId },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "create SSH file");
|
||||
@@ -525,13 +536,10 @@ export async function createSSHFolder(
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/ssh/createFolder", {
|
||||
sessionId,
|
||||
path,
|
||||
folderName,
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/createFolder",
|
||||
{ sessionId, path, folderName, hostId, userId },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "create SSH folder");
|
||||
@@ -546,15 +554,18 @@ export async function deleteSSHItem(
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.delete("/ssh/deleteItem", {
|
||||
data: {
|
||||
sessionId,
|
||||
path,
|
||||
isDirectory,
|
||||
hostId,
|
||||
userId,
|
||||
const response = await getFileManagerApiForSession(sessionId).delete(
|
||||
"/ssh/deleteItem",
|
||||
{
|
||||
data: {
|
||||
sessionId,
|
||||
path,
|
||||
isDirectory,
|
||||
hostId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "delete SSH item");
|
||||
@@ -566,7 +577,7 @@ export async function setSudoPassword(
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await fileManagerApi.post("/sudo-password", {
|
||||
await getFileManagerApiForSession(sessionId).post("/sudo-password", {
|
||||
sessionId,
|
||||
password,
|
||||
});
|
||||
@@ -583,7 +594,7 @@ export async function copySSHItem(
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post(
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/copyItem",
|
||||
{
|
||||
sessionId,
|
||||
@@ -611,13 +622,10 @@ export async function renameSSHItem(
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.put("/ssh/renameItem", {
|
||||
sessionId,
|
||||
oldPath,
|
||||
newName,
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).put(
|
||||
"/ssh/renameItem",
|
||||
{ sessionId, oldPath, newName, hostId, userId },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "rename SSH item");
|
||||
@@ -633,7 +641,7 @@ export async function moveSSHItem(
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.put(
|
||||
const response = await getFileManagerApiForSession(sessionId).put(
|
||||
"/ssh/moveItem",
|
||||
{
|
||||
sessionId,
|
||||
@@ -670,13 +678,10 @@ export async function changeSSHPermissions(
|
||||
userId,
|
||||
});
|
||||
|
||||
const response = await fileManagerApi.post("/ssh/changePermissions", {
|
||||
sessionId,
|
||||
path,
|
||||
permissions,
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/changePermissions",
|
||||
{ sessionId, path, permissions, hostId, userId },
|
||||
);
|
||||
|
||||
fileLogger.success("SSH file permissions changed successfully", {
|
||||
operation: "change_permissions",
|
||||
@@ -715,13 +720,10 @@ export async function extractSSHArchive(
|
||||
userId,
|
||||
});
|
||||
|
||||
const response = await fileManagerApi.post("/ssh/extractArchive", {
|
||||
sessionId,
|
||||
archivePath,
|
||||
extractPath,
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/extractArchive",
|
||||
{ sessionId, archivePath, extractPath, hostId, userId },
|
||||
);
|
||||
|
||||
fileLogger.success("Archive extracted successfully", {
|
||||
operation: "extract_archive",
|
||||
@@ -762,14 +764,17 @@ export async function compressSSHFiles(
|
||||
userId,
|
||||
});
|
||||
|
||||
const response = await fileManagerApi.post("/ssh/compressFiles", {
|
||||
sessionId,
|
||||
paths,
|
||||
archiveName,
|
||||
format: format || "zip",
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/compressFiles",
|
||||
{
|
||||
sessionId,
|
||||
paths,
|
||||
archiveName,
|
||||
format: format || "zip",
|
||||
hostId,
|
||||
userId,
|
||||
},
|
||||
);
|
||||
|
||||
fileLogger.success("Files compressed successfully", {
|
||||
operation: "compress_files",
|
||||
@@ -811,6 +816,12 @@ export async function ensureSSHSessionForHost(
|
||||
host: SSHHost,
|
||||
): Promise<EnsureSSHSessionResult> {
|
||||
const sessionId = host.id.toString();
|
||||
const origin = await resolveConnectionOrigin({
|
||||
connectionType: host.connectionType,
|
||||
connectionOrigin: host.connectionOrigin,
|
||||
});
|
||||
setSessionOrigin(sessionId, origin);
|
||||
|
||||
try {
|
||||
const status = await getSSHStatus(sessionId);
|
||||
if (status?.connected) {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import axios from "axios";
|
||||
import { authApi, handleApiError, tunnelApi } from "@/main-axios";
|
||||
import {
|
||||
authApi,
|
||||
handleApiError,
|
||||
tunnelApi,
|
||||
getRemoteTunnelApi,
|
||||
isElectron,
|
||||
} from "@/main-axios";
|
||||
import type {
|
||||
C2STunnelPreset,
|
||||
TunnelConfig,
|
||||
@@ -9,13 +15,46 @@ import type {
|
||||
|
||||
// TUNNEL MANAGEMENT
|
||||
// ============================================================================
|
||||
//
|
||||
// Tunnel status is a process-local, in-memory view (no DB lookup) so it's
|
||||
// safe to read from both the embedded backend and a connected remote server
|
||||
// and merge the results. connectTunnel/disconnectTunnel/cancelTunnel are
|
||||
// NOT origin-routed: they resolve the target host by numeric database id
|
||||
// against whichever backend receives the request, and a synced host has a
|
||||
// different numeric id in each database (only its syncId matches across
|
||||
// them) -- routing those calls to a remote backend would need a
|
||||
// local-id-to-remote-id resolution step that doesn't exist yet. They always
|
||||
// target the embedded local backend for now.
|
||||
|
||||
async function isRemoteSyncConnected(): Promise<boolean> {
|
||||
if (!isElectron()) return false;
|
||||
try {
|
||||
const config = (await window.electronAPI?.invoke?.(
|
||||
"get-remote-sync-config",
|
||||
)) as { serverUrl?: string } | null;
|
||||
return !!config?.serverUrl;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTunnelStatuses(): Promise<
|
||||
Record<string, TunnelStatus>
|
||||
> {
|
||||
try {
|
||||
const response = await tunnelApi.get("/tunnel/status");
|
||||
return response.data || {};
|
||||
const [localResult, remoteConnected] = await Promise.all([
|
||||
tunnelApi.get("/tunnel/status"),
|
||||
isRemoteSyncConnected(),
|
||||
]);
|
||||
const localStatuses = localResult.data || {};
|
||||
if (!remoteConnected) return localStatuses;
|
||||
|
||||
try {
|
||||
const remoteResult = await getRemoteTunnelApi().get("/tunnel/status");
|
||||
return { ...localStatuses, ...(remoteResult.data || {}) };
|
||||
} catch {
|
||||
return localStatuses;
|
||||
}
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch tunnel statuses");
|
||||
}
|
||||
@@ -30,9 +69,18 @@ export function subscribeTunnelStatuses(
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
let latestLocal: Record<string, TunnelStatus> = {};
|
||||
let latestRemote: Record<string, TunnelStatus> = {};
|
||||
let remotePollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const emitMerged = () => {
|
||||
onStatuses({ ...latestLocal, ...latestRemote });
|
||||
};
|
||||
|
||||
source.addEventListener("statuses", (event) => {
|
||||
try {
|
||||
onStatuses(JSON.parse(event.data) as Record<string, TunnelStatus>);
|
||||
latestLocal = JSON.parse(event.data) as Record<string, TunnelStatus>;
|
||||
emitMerged();
|
||||
} catch {
|
||||
onError?.();
|
||||
}
|
||||
@@ -42,7 +90,27 @@ export function subscribeTunnelStatuses(
|
||||
onError?.();
|
||||
};
|
||||
|
||||
return () => source.close();
|
||||
// Remote tunnel status has no SSE stream exposed to the desktop app yet,
|
||||
// so poll it at a modest interval when a remote server is connected.
|
||||
isRemoteSyncConnected().then((connected) => {
|
||||
if (!connected) return;
|
||||
const pollRemote = async () => {
|
||||
try {
|
||||
const result = await getRemoteTunnelApi().get("/tunnel/status");
|
||||
latestRemote = result.data || {};
|
||||
emitMerged();
|
||||
} catch {
|
||||
// remote unreachable this tick -- keep last known remote statuses
|
||||
}
|
||||
};
|
||||
pollRemote();
|
||||
remotePollTimer = setInterval(pollRemote, 5000);
|
||||
});
|
||||
|
||||
return () => {
|
||||
source.close();
|
||||
if (remotePollTimer) clearInterval(remotePollTimer);
|
||||
};
|
||||
}
|
||||
|
||||
export async function getTunnelStatusByName(
|
||||
|
||||
Reference in New Issue
Block a user