feat: finish the host-metrics plugin, move host status into core (B16)

This commit is contained in:
LukeGus committed 2026-09-24 11:21:23 -05:00
1 parent 14eb558b85
commit 0a3f05d99d
308 files changed
+53073 -8985

No files matched your search

-347
View File
@@ -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);
}
}
// ============================================================================
+190
View File
@@ -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 -29
View File
@@ -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;