mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: finish the host-metrics plugin, move host status into core (B16)
This commit is contained in:
1 parent
14eb558b85
commit
0a3f05d99d
308 files changed
+53073
-8985
No files matched your search
@@ -1,347 +0,0 @@
|
||||
import axios, { type AxiosRequestConfig } from "axios";
|
||||
import {
|
||||
handleApiError,
|
||||
statsApi,
|
||||
getRemoteStatsApi,
|
||||
isElectron,
|
||||
sshHostApi,
|
||||
} from "@/main-axios";
|
||||
import type { ServerMetrics, ServerStatus } from "@/main-axios";
|
||||
import { getCachedServerStatuses } from "@/lib/hosts-request-cache";
|
||||
import { resolveConnectionOrigin } from "@/lib/connection-origin";
|
||||
import type { SSHHost } from "@/types/index";
|
||||
import { createKeyedRequestCache } from "@/lib/keyed-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;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type ConnectErrorResponse = {
|
||||
error?: string;
|
||||
message?: string;
|
||||
connectionLogs?: ApiConnectionLog[];
|
||||
requires_totp?: boolean;
|
||||
requires_warpgate?: boolean;
|
||||
sessionId?: string;
|
||||
prompt?: string;
|
||||
url?: string;
|
||||
securityKey?: string;
|
||||
status?: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
// SERVER STATISTICS
|
||||
// ============================================================================
|
||||
|
||||
const metricsCache = createKeyedRequestCache<ServerMetrics | null>(1_500, 100);
|
||||
|
||||
/**
|
||||
* Progressive retry schedule for the background /status poll.
|
||||
*
|
||||
* Each entry describes one attempt's per-request timeout and the pause to
|
||||
* observe before the next attempt. The pause on the last entry is `null`:
|
||||
* after that final failure we surface the network error, which flows
|
||||
* through the response interceptor + dbHealthMonitor (which decides
|
||||
* between the degraded toast and the full-outage overlay based on whether
|
||||
* any WebSocket is still alive).
|
||||
*
|
||||
* Sequence: try(2s) -> wait 3s -> try(5s) -> wait 5s -> try(8s) -> fail.
|
||||
* Worst-case wall-clock = 23s, which fits inside the 30s ServerStatusContext
|
||||
* poll cadence, so the next tick acts as the next retry without overlap.
|
||||
*/
|
||||
const STATUS_RETRY_SCHEDULE: ReadonlyArray<{
|
||||
timeoutMs: number;
|
||||
pauseAfterMs: number | null;
|
||||
}> = [
|
||||
{ timeoutMs: 2000, pauseAfterMs: 3000 },
|
||||
{ timeoutMs: 5000, pauseAfterMs: 5000 },
|
||||
{ timeoutMs: 8000, pauseAfterMs: null },
|
||||
];
|
||||
|
||||
function isTransientStatusError(error: unknown): boolean {
|
||||
if (!axios.isAxiosError(error)) return false;
|
||||
if (error.response) {
|
||||
// Definitive server response (even 5xx) is not something more retries
|
||||
// will fix in a useful timeframe; bail out and report it normally.
|
||||
return false;
|
||||
}
|
||||
const code = error.code;
|
||||
if (!code) {
|
||||
// No code + no response means classic network error (offline / DNS / TCP)
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
code === "ECONNABORTED" ||
|
||||
code === "ETIMEDOUT" ||
|
||||
code === "ERR_NETWORK" ||
|
||||
code === "ECONNREFUSED" ||
|
||||
code === "ECONNRESET"
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAllServerStatuses(): Promise<
|
||||
Record<number, ServerStatus>
|
||||
> {
|
||||
return getCachedServerStatuses(async () => {
|
||||
let lastError: unknown = null;
|
||||
let localStatuses: Record<number, ServerStatus> = {};
|
||||
let localHostIds: number[] | null = null;
|
||||
|
||||
if (isElectron()) {
|
||||
try {
|
||||
const response = await sshHostApi.get<SSHHost[]>("/db/host");
|
||||
const defaultOrigin = await resolveConnectionOrigin({
|
||||
connectionOrigin: null,
|
||||
});
|
||||
localHostIds = (response.data || [])
|
||||
.filter(
|
||||
(host) => (host.connectionOrigin ?? defaultOrigin) === "local",
|
||||
)
|
||||
.map((host) => host.id);
|
||||
} catch {
|
||||
// A host-list failure must not start local probes for hosts whose
|
||||
// configured origin may be remote.
|
||||
localHostIds = [];
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < STATUS_RETRY_SCHEDULE.length; i++) {
|
||||
const { timeoutMs, pauseAfterMs } = STATUS_RETRY_SCHEDULE[i];
|
||||
const isFinalAttempt = i === STATUS_RETRY_SCHEDULE.length - 1;
|
||||
|
||||
try {
|
||||
const response = await statsApi.get("/status", {
|
||||
timeout: timeoutMs,
|
||||
...(localHostIds === null
|
||||
? {}
|
||||
: { params: { hostIds: localHostIds.join(",") } }),
|
||||
// Silence per-attempt interceptor logging & health-monitor side
|
||||
// effects on all attempts except the final one, so background
|
||||
// blips don't look like real outages.
|
||||
__silentRetry: !isFinalAttempt,
|
||||
} as AxiosRequestConfig & { __silentRetry?: boolean });
|
||||
localStatuses = response.data || {};
|
||||
lastError = null;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!isTransientStatusError(error)) {
|
||||
break;
|
||||
}
|
||||
if (pauseAfterMs === null) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, pauseAfterMs));
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
export async function getServerStatusById(id: number): Promise<ServerStatus> {
|
||||
try {
|
||||
const response = await statsApi.get(`/status/${id}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch server status");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getServerMetricsById(
|
||||
id: number,
|
||||
): Promise<ServerMetrics | null> {
|
||||
return metricsCache.get(String(id), async () => {
|
||||
try {
|
||||
const response = await statsApi.get(`/metrics/${id}`, {
|
||||
// Treat 404 as an expected "no metrics yet / disabled" signal rather
|
||||
// than an error so we don't spam warn logs on the client.
|
||||
validateStatus: (status) => status === 200 || status === 404,
|
||||
});
|
||||
if (response.status === 404) return null;
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
// If a 404 still slips through (e.g. intercepted before reaching here),
|
||||
// swallow it quietly; everything else still flows through handleApiError.
|
||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||
return null;
|
||||
}
|
||||
handleApiError(error, "fetch server metrics");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function startMetricsPolling(hostId: number): Promise<{
|
||||
success: boolean;
|
||||
requires_totp?: boolean;
|
||||
sessionId?: string;
|
||||
prompt?: string;
|
||||
viewerSessionId?: string;
|
||||
connectionLogs?: ApiConnectionLog[];
|
||||
}> {
|
||||
try {
|
||||
const response = await statsApi.post(`/metrics/start/${hostId}`);
|
||||
metricsCache.invalidate(String(hostId));
|
||||
return response.data;
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
axios.isAxiosError<ConnectErrorResponse>(error) &&
|
||||
error.response?.data?.connectionLogs
|
||||
) {
|
||||
const data = error.response.data;
|
||||
const errorWithLogs = new Error(
|
||||
data.error || data.message || error.message,
|
||||
);
|
||||
Object.assign(errorWithLogs, {
|
||||
connectionLogs: data.connectionLogs,
|
||||
});
|
||||
throw errorWithLogs;
|
||||
}
|
||||
handleApiError(error, "start metrics polling");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopMetricsPolling(
|
||||
hostId: number,
|
||||
viewerSessionId?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await statsApi.post(`/metrics/stop/${hostId}`, { viewerSessionId });
|
||||
} catch (error) {
|
||||
handleApiError(error, "stop metrics polling");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendMetricsHeartbeat(
|
||||
viewerSessionId: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const response = await statsApi.post(
|
||||
"/metrics/heartbeat",
|
||||
{ viewerSessionId },
|
||||
{ validateStatus: (status) => status === 200 || status === 404 },
|
||||
);
|
||||
return response.status !== 404;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||
return false;
|
||||
}
|
||||
handleApiError(error, "send metrics heartbeat");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerMetricsViewer(hostId: number): Promise<{
|
||||
success: boolean;
|
||||
viewerSessionId?: string;
|
||||
skipped?: boolean;
|
||||
reason?: string;
|
||||
}> {
|
||||
try {
|
||||
const response = await statsApi.post("/metrics/register-viewer", {
|
||||
hostId,
|
||||
});
|
||||
metricsCache.invalidate(String(hostId));
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "register metrics viewer");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function unregisterMetricsViewer(
|
||||
hostId: number,
|
||||
viewerSessionId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await statsApi.post("/metrics/unregister-viewer", {
|
||||
hostId,
|
||||
viewerSessionId,
|
||||
});
|
||||
} catch (error) {
|
||||
handleApiError(error, "unregister metrics viewer");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function submitMetricsTOTP(
|
||||
sessionId: string,
|
||||
totpCode: string,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
viewerSessionId?: string;
|
||||
}> {
|
||||
try {
|
||||
const response = await statsApi.post("/metrics/connect-totp", {
|
||||
sessionId,
|
||||
totpCode,
|
||||
});
|
||||
metricsCache.invalidate();
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "submit metrics TOTP");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshServerPolling(): Promise<void> {
|
||||
try {
|
||||
await statsApi.post("/refresh");
|
||||
} catch (error) {
|
||||
console.warn("Failed to refresh server polling:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function notifyHostCreatedOrUpdated(
|
||||
hostId: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await statsApi.post("/host-updated", { hostId });
|
||||
metricsCache.invalidate(String(hostId));
|
||||
} catch (error) {
|
||||
console.warn("Failed to notify stats server of host update:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -0,0 +1,190 @@
|
||||
import axios, { type AxiosRequestConfig } from "axios";
|
||||
import {
|
||||
handleApiError,
|
||||
getRemoteCoreApi,
|
||||
isElectron,
|
||||
sshHostApi,
|
||||
} from "@/main-axios";
|
||||
import type { ServerStatus } from "@/main-axios";
|
||||
import { getCachedServerStatuses } from "@/lib/hosts-request-cache";
|
||||
import { resolveConnectionOrigin } from "@/lib/connection-origin";
|
||||
import type { SSHHost } from "@/types/index";
|
||||
|
||||
// Core's host status checks (/host/status). On the desktop app the aggregate
|
||||
// read is merged across the local backend and the connected remote server.
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// HOST STATUS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Progressive retry schedule for the background /status poll.
|
||||
*
|
||||
* Each entry describes one attempt's per-request timeout and the pause to
|
||||
* observe before the next attempt. The pause on the last entry is `null`:
|
||||
* after that final failure we surface the network error, which flows
|
||||
* through the response interceptor + dbHealthMonitor (which decides
|
||||
* between the degraded toast and the full-outage overlay based on whether
|
||||
* any WebSocket is still alive).
|
||||
*
|
||||
* Sequence: try(2s) -> wait 3s -> try(5s) -> wait 5s -> try(8s) -> fail.
|
||||
* Worst-case wall-clock = 23s, which fits inside the 30s ServerStatusContext
|
||||
* poll cadence, so the next tick acts as the next retry without overlap.
|
||||
*/
|
||||
const STATUS_RETRY_SCHEDULE: ReadonlyArray<{
|
||||
timeoutMs: number;
|
||||
pauseAfterMs: number | null;
|
||||
}> = [
|
||||
{ timeoutMs: 2000, pauseAfterMs: 3000 },
|
||||
{ timeoutMs: 5000, pauseAfterMs: 5000 },
|
||||
{ timeoutMs: 8000, pauseAfterMs: null },
|
||||
];
|
||||
|
||||
function isTransientStatusError(error: unknown): boolean {
|
||||
if (!axios.isAxiosError(error)) return false;
|
||||
if (error.response) {
|
||||
// Definitive server response (even 5xx) is not something more retries
|
||||
// will fix in a useful timeframe; bail out and report it normally.
|
||||
return false;
|
||||
}
|
||||
const code = error.code;
|
||||
if (!code) {
|
||||
// No code + no response means classic network error (offline / DNS / TCP)
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
code === "ECONNABORTED" ||
|
||||
code === "ETIMEDOUT" ||
|
||||
code === "ERR_NETWORK" ||
|
||||
code === "ECONNREFUSED" ||
|
||||
code === "ECONNRESET"
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAllServerStatuses(): Promise<
|
||||
Record<number, ServerStatus>
|
||||
> {
|
||||
return getCachedServerStatuses(async () => {
|
||||
let lastError: unknown = null;
|
||||
let localStatuses: Record<number, ServerStatus> = {};
|
||||
let localHostIds: number[] | null = null;
|
||||
|
||||
if (isElectron()) {
|
||||
try {
|
||||
const response = await sshHostApi.get<SSHHost[]>("/db/host");
|
||||
const defaultOrigin = await resolveConnectionOrigin({
|
||||
connectionOrigin: null,
|
||||
});
|
||||
localHostIds = (response.data || [])
|
||||
.filter(
|
||||
(host) => (host.connectionOrigin ?? defaultOrigin) === "local",
|
||||
)
|
||||
.map((host) => host.id);
|
||||
} catch {
|
||||
// A host-list failure must not start local probes for hosts whose
|
||||
// configured origin may be remote.
|
||||
localHostIds = [];
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < STATUS_RETRY_SCHEDULE.length; i++) {
|
||||
const { timeoutMs, pauseAfterMs } = STATUS_RETRY_SCHEDULE[i];
|
||||
const isFinalAttempt = i === STATUS_RETRY_SCHEDULE.length - 1;
|
||||
|
||||
try {
|
||||
const response = await sshHostApi.get("/status", {
|
||||
timeout: timeoutMs,
|
||||
...(localHostIds === null
|
||||
? {}
|
||||
: { params: { hostIds: localHostIds.join(",") } }),
|
||||
// Silence per-attempt interceptor logging & health-monitor side
|
||||
// effects on all attempts except the final one, so background
|
||||
// blips don't look like real outages.
|
||||
__silentRetry: !isFinalAttempt,
|
||||
} as AxiosRequestConfig & { __silentRetry?: boolean });
|
||||
localStatuses = response.data || {};
|
||||
lastError = null;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!isTransientStatusError(error)) {
|
||||
break;
|
||||
}
|
||||
if (pauseAfterMs === null) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, pauseAfterMs));
|
||||
}
|
||||
}
|
||||
|
||||
if (lastError) {
|
||||
handleApiError(lastError, "fetch server statuses");
|
||||
return {};
|
||||
}
|
||||
|
||||
if (await isRemoteSyncConnected()) {
|
||||
try {
|
||||
const remoteResult = await getRemoteCoreApi().get("/host/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;
|
||||
});
|
||||
}
|
||||
|
||||
export async function getServerStatusById(id: number): Promise<ServerStatus> {
|
||||
try {
|
||||
const response = await sshHostApi.get(`/status/${id}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch server status");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshServerPolling(): Promise<void> {
|
||||
try {
|
||||
await sshHostApi.post("/status/refresh");
|
||||
} catch (error) {
|
||||
console.warn("Failed to refresh status checks:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStatusCheckSettings(): Promise<{
|
||||
statusCheckInterval: number;
|
||||
}> {
|
||||
try {
|
||||
const response = await sshHostApi.get("/status/settings");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch status check settings");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateStatusCheckSettings(settings: {
|
||||
statusCheckInterval: number;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await sshHostApi.put("/status/settings", settings);
|
||||
} catch (error) {
|
||||
handleApiError(error, "update status check settings");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -1,32 +1,5 @@
|
||||
import { authApi, handleApiError, statsApi } from "@/main-axios";
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
|
||||
// GLOBAL MONITORING SETTINGS
|
||||
// ============================================================================
|
||||
|
||||
export async function getGlobalMonitoringSettings(): Promise<{
|
||||
statusCheckInterval: number;
|
||||
metricsInterval: number;
|
||||
}> {
|
||||
try {
|
||||
const response = await statsApi.get("/global-settings");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch global monitoring settings");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateGlobalMonitoringSettings(settings: {
|
||||
statusCheckInterval?: number;
|
||||
metricsInterval?: number;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await statsApi.post("/global-settings", settings);
|
||||
} catch (error) {
|
||||
handleApiError(error, "update global monitoring settings");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LOG LEVEL SETTINGS
|
||||
// ============================================================================
|
||||
|
||||
@@ -184,7 +157,6 @@ export type HostDefaults = {
|
||||
socks5Username?: string;
|
||||
socks5Password?: string;
|
||||
credentialId?: number | null;
|
||||
metricsEnabled?: boolean;
|
||||
statusCheckEnabled?: boolean;
|
||||
fontSize?: number;
|
||||
fontFamily?: string;
|
||||
|
||||
Reference in new issue
Block a user