import axios, { AxiosError, type AxiosInstance } from "axios"; import { toast } from "sonner"; import { getBasePath } from "@/lib/base-path"; import { isElectron } from "@/lib/electron"; import { clearTermixSessionStorage } from "@/shell/TabContext"; import type { SSHHost } from "@/types/index"; // ============================================================================ // RBAC TYPE DEFINITIONS // ============================================================================ export interface Role { id: number; name: string; displayName: string; description: string | null; isSystem: boolean; permissions: string | null; createdAt: string; updatedAt: string; } export interface UserRole { userId: string; roleId: number; roleName: string; roleDisplayName: string; grantedBy: string; grantedByUsername: string; grantedAt: string; } export interface AccessRecord { id: number; targetType: "user" | "role"; userId: string | null; roleId: number | null; username: string | null; roleName: string | null; roleDisplayName: string | null; grantedBy: string; grantedByUsername: string; permissionLevel: "view"; expiresAt: string | null; createdAt: string; } import { apiLogger, authLogger, sshLogger, tunnelLogger, fileLogger, statsLogger, systemLogger, dashboardLogger, type LogContext, } from "@/lib/frontend-logger"; import { dbHealthMonitor } from "@/lib/db-health-monitor"; export type ServerStatus = { status: "online" | "offline"; lastChecked: string; }; export type SSHHostWithStatus = SSHHost & { status: "online" | "offline" | "unknown"; }; interface CpuMetrics { percent: number | null; cores: number | null; load: [number, number, number] | null; } interface MemoryMetrics { percent: number | null; usedGiB: number | null; totalGiB: number | null; } interface DiskMetrics { percent: number | null; usedHuman: string | null; totalHuman: string | null; availableHuman?: string | null; } export interface NetworkInterface { name: string; ip: string; state: string; rx?: string | null; tx?: string | null; rxBytes?: string | null; txBytes?: string | null; } export interface ProcessInfo { pid: string; user: string; cpu: string; mem: string; command: string; } export interface LoginRecord { user: string; ip: string; time: string; status: "success" | "failed"; } export interface ListeningPort { protocol: "tcp" | "udp"; localAddress: string; localPort: number; state?: string; pid?: number; process?: string; } export interface FirewallRule { chain: string; target: string; protocol: string; source: string; destination: string; dport?: string; sport?: string; state?: string; interface?: string; extra?: string; } export interface FirewallChain { name: string; policy: string; rules: FirewallRule[]; } export type ServerMetrics = { cpu: CpuMetrics; memory: MemoryMetrics; disk: DiskMetrics; network?: { interfaces?: NetworkInterface[] }; uptime?: { seconds?: number | null; formatted?: string | null }; system?: { hostname?: string | null; os?: string | null; kernel?: string | null; arch?: string | null; }; processes?: { total?: number | null; running?: number | null; top?: ProcessInfo[]; }; login_stats?: { recentLogins?: LoginRecord[]; failedLogins?: LoginRecord[]; totalLogins?: number; uniqueIPs?: number; }; ports?: { source?: "ss" | "netstat" | "none"; ports?: ListeningPort[]; }; firewall?: { type?: "iptables" | "nftables" | "none"; status?: "active" | "inactive" | "unknown"; chains?: FirewallChain[]; }; temperature?: { source?: "sysfs" | "sensors" | "none"; highestCelsius?: number | null; sensors?: Array<{ label: string; celsius: number; }>; }; lastChecked: string; }; export interface AuthResponse { success?: boolean; is_admin?: boolean; username?: string; userId?: string; is_oidc?: boolean; totp_enabled?: boolean; data_unlocked?: boolean; requires_totp?: boolean; temp_token?: string; rememberMe?: boolean; token?: string; } export interface UserInfo { totp_enabled: boolean; userId: string; username: string; is_admin: boolean; is_oidc: boolean; data_unlocked: boolean; password_hash?: string; } interface UserCount { count: number; } interface OIDCAuthorize { auth_url: string; } type ElectronApi = { isElectron?: boolean; getSetting?: (key: string) => Promise; setSetting?: (key: string, value: string) => Promise; }; type ElectronWindow = Window & typeof globalThis & { IS_ELECTRON?: boolean; electronAPI?: ElectronApi; ReactNativeWebView?: unknown; }; // ============================================================================ // UTILITY FUNCTIONS // ============================================================================ export { isElectron }; function getLoggerForService(serviceName: string) { if (serviceName.includes("SSH") || serviceName.includes("ssh")) { return sshLogger; } else if (serviceName.includes("TUNNEL") || serviceName.includes("tunnel")) { return tunnelLogger; } else if (serviceName.includes("FILE") || serviceName.includes("file")) { return fileLogger; } else if (serviceName.includes("STATS") || serviceName.includes("stats")) { return statsLogger; } else if (serviceName.includes("AUTH") || serviceName.includes("auth")) { return authLogger; } else if ( serviceName.includes("DASHBOARD") || serviceName.includes("dashboard") ) { return dashboardLogger; } else { return apiLogger; } } const electronSettingsCache = new Map(); if (isElectron()) { (async () => { try { const electronAPI = (window as ElectronWindow).electronAPI; if (electronAPI?.getSetting) { const settingsToLoad = ["rightClickCopyPaste"]; for (const key of settingsToLoad) { const value = await electronAPI.getSetting(key); if (value !== null && value !== undefined) { // Only populate if not already set to prevent overwriting new values during login if (!localStorage.getItem(key)) { electronSettingsCache.set(key, value); localStorage.setItem(key, value); console.log(`[Electron] Loaded setting ${key} from main process`); } else { // Even if we don't overwrite localStorage, update the cache electronSettingsCache.set(key, localStorage.getItem(key)!); } } } } } catch (error) { console.error("[Electron] Failed to load settings cache:", error); } })(); } export function setCookie( name: string, value: string, days = 7, ): void | Promise { if (isElectron()) { try { if (name === "jwt") { return; } const electronAPI = (window as ElectronWindow).electronAPI; if (electronAPI?.setSetting) { electronSettingsCache.set(name, value); localStorage.setItem(name, value); electronAPI.setSetting(name, value).catch((err: Error) => { console.error(`[Electron] Failed to persist setting ${name}:`, err); }); } console.log(`[Electron] Set setting: ${name}`); } catch (error) { console.error(`[Electron] Failed to set setting: ${name}`, error); } } else { const expires = new Date(Date.now() + days * 864e5).toUTCString(); document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`; } } export function getCookie(name: string): string | undefined { if (isElectron()) { try { if (name === "jwt") { return undefined; } if (electronSettingsCache.has(name)) { return electronSettingsCache.get(name); } const token = localStorage.getItem(name) || undefined; if (token) { electronSettingsCache.set(name, token); } console.log(`[Electron] Get setting: ${name} = ${token}`); return token; } catch (error) { console.error(`[Electron] Failed to get setting: ${name}`, error); return undefined; } } else { const value = `; ${document.cookie}`; const parts = value.split(`; ${name}=`); const encodedToken = parts.length === 2 ? parts.pop()?.split(";").shift() : undefined; const token = encodedToken ? decodeURIComponent(encodedToken) : undefined; return token; } } let userWasAuthenticated = false; let latestAuthSuccessAt = 0; export function markUserAuthenticated(): void { userWasAuthenticated = true; latestAuthSuccessAt = typeof performance !== "undefined" ? performance.now() : Date.now(); } export function isCurrentAuthInvalidationError(error: unknown): boolean { const authError = error as { __staleAuthInvalidation?: boolean; }; if (authError.__staleAuthInvalidation) { return false; } const axiosError = error as AxiosError; const apiError = error as ApiError; const responseData = axiosError.response?.data as | Record | undefined; const errorCode = responseData?.code || apiError.code; const errorMessage = responseData?.error || apiError.message; const status = axiosError.response?.status || apiError.status; const isMissingAuthenticationToken = errorMessage === "Missing authentication token"; return ( status === 401 && (errorCode === "SESSION_EXPIRED" || errorCode === "SESSION_NOT_FOUND" || (errorCode === "AUTH_REQUIRED" && userWasAuthenticated) || errorMessage === "Invalid token" || (errorMessage === "Authentication required" && userWasAuthenticated) || (isMissingAuthenticationToken && userWasAuthenticated)) ); } function createApiInstance( baseURL: string, serviceName: string = "API", ): AxiosInstance { const instance = axios.create({ baseURL, headers: { "Content-Type": "application/json" }, timeout: 30000, withCredentials: true, }); instance.interceptors.request.use((config: AxiosRequestConfig) => { const startTime = performance.now(); const requestId = `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; const configWithMetadata = config as AxiosRequestConfigExtended; configWithMetadata.startTime = startTime; configWithMetadata.requestId = requestId; const method = config.method?.toUpperCase() || "UNKNOWN"; const url = config.url || "UNKNOWN"; const fullUrl = `${config.baseURL}${url}`; const context: LogContext = { requestId, method, url: fullUrl, operation: "request_start", }; const logger = getLoggerForService(serviceName); const isDevMode = process.env.NODE_ENV === "development"; if (isDevMode) { logger.requestStart(method, fullUrl, context); } if (isElectron()) { if (config.headers.set) { config.headers.set("X-Electron-App", "true"); } else { config.headers["X-Electron-App"] = "true"; } const jwt = localStorage.getItem("jwt"); if (jwt) { if (config.headers.set) { config.headers.set("Authorization", `Bearer ${jwt}`); } else { config.headers["Authorization"] = `Bearer ${jwt}`; } } } if ( typeof window !== "undefined" && (window as ElectronWindow).ReactNativeWebView ) { let platform = "Unknown"; if (typeof navigator !== "undefined" && navigator.userAgent) { if (navigator.userAgent.includes("Android")) { platform = "Android"; } else if ( navigator.userAgent.includes("iPhone") || navigator.userAgent.includes("iPad") || navigator.userAgent.includes("iOS") ) { platform = "iOS"; } } if (config.headers.set) { config.headers.set("User-Agent", `Termix-Mobile/${platform}`); } else { config.headers["User-Agent"] = `Termix-Mobile/${platform}`; } } return config; }); instance.interceptors.response.use( (response: AxiosResponse) => { const endTime = performance.now(); const responseConfig = response.config as AxiosRequestConfigExtended; const startTime = responseConfig.startTime; const requestId = responseConfig.requestId; const responseTime = Math.round(endTime - (startTime || endTime)); const method = response.config.method?.toUpperCase() || "UNKNOWN"; const url = response.config.url || "UNKNOWN"; const fullUrl = `${response.config.baseURL}${url}`; const context: LogContext = { requestId, method, url: fullUrl, status: response.status, statusText: response.statusText, responseTime, operation: "request_success", }; const logger = getLoggerForService(serviceName); if (process.env.NODE_ENV === "development") { logger.requestSuccess( method, fullUrl, response.status, responseTime, context, ); } if (responseTime > 3000) { logger.warn(`🐌 Slow request: ${responseTime}ms`, context); } dbHealthMonitor.reportDatabaseSuccess(); return response; }, (error: AxiosErrorExtended) => { const endTime = performance.now(); const startTime = error.config?.startTime; const requestId = error.config?.requestId; const responseTime = startTime ? Math.round(endTime - startTime) : undefined; const method = error.config?.method?.toUpperCase() || "UNKNOWN"; const url = error.config?.url || "UNKNOWN"; const fullUrl = error.config ? `${error.config.baseURL}${url}` : url; const status = error.response?.status; const message = (error.response?.data as { error?: string })?.error || (error as Error).message || "Unknown error"; const errorCode = (error.response?.data as { code?: string })?.code || error.code; const context: LogContext = { requestId, method, url: fullUrl, status, responseTime, errorCode, errorMessage: message, operation: "request_error", }; const logger = getLoggerForService(serviceName); // A caller can mark a request as a silent retry (see progressive /status // retry) so we don't spam error logs / health events on each attempt. const isSilentRetry = !!error.config?.__silentRetry; if (process.env.NODE_ENV === "development" && !isSilentRetry) { if (status === 401) { logger.authError(method, fullUrl, context); } else if (status === 0 || !status) { logger.networkError(method, fullUrl, message, context); } else { logger.requestError( method, fullUrl, status || 0, message, responseTime, context, ); } } if (status === 401) { const errorCode = (error.response?.data as Record) ?.code; const errorMessage = (error.response?.data as Record) ?.error; const isSessionExpired = errorCode === "SESSION_EXPIRED"; const isSessionNotFound = errorCode === "SESSION_NOT_FOUND"; const isMissingAuthenticationToken = errorMessage === "Missing authentication token"; const isInvalidToken = errorCode === "AUTH_REQUIRED" || errorMessage === "Invalid token" || errorMessage === "Authentication required" || (isMissingAuthenticationToken && userWasAuthenticated); if (isSessionExpired || isSessionNotFound || isInvalidToken) { const requestStartedAt = typeof error.config?.startTime === "number" ? error.config.startTime : 0; const isStaleAuthInvalidation = latestAuthSuccessAt > 0 && requestStartedAt > 0 && requestStartedAt < latestAuthSuccessAt; if (isStaleAuthInvalidation) { ( error as { __staleAuthInvalidation?: boolean } ).__staleAuthInvalidation = true; return Promise.reject(error); } if (isElectron()) { const electronAPI = ( window as unknown as { electronAPI?: { clearSessionCookies?: () => Promise }; } ).electronAPI; electronAPI?.clearSessionCookies?.().catch(() => {}); } if (typeof window !== "undefined") { document.cookie = "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; } if (isSessionExpired && typeof window !== "undefined") { console.warn("Session expired - please log in again"); toast.warning("Session expired. Please log in again."); } dbHealthMonitor.reportSessionExpired(); userWasAuthenticated = false; } } else if (!isSilentRetry) { const wasAuthenticated = userWasAuthenticated; dbHealthMonitor.reportDatabaseError(error, wasAuthenticated); } return Promise.reject(error); }, ); return instance; } // ============================================================================ // API INSTANCES // ============================================================================ function isDev(): boolean { if (isElectron()) { return false; } return ( process.env.NODE_ENV === "development" && (window.location.port === "3000" || window.location.port === "5173" || window.location.port === "" || window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1") ); } const apiHost = import.meta.env.VITE_API_HOST || "localhost"; let configuredServerUrl: string | null = null; let embeddedMode = false; export interface ServerConfig { serverUrl: string; lastUpdated: string; allowInvalidCertificate?: boolean; } interface AxiosRequestConfigExtended extends AxiosRequestConfig { startTime?: number; requestId?: string; __silentRetry?: boolean; } interface AxiosErrorExtended extends AxiosError { config?: AxiosRequestConfigExtended; } export async function getServerConfig(): Promise { if (!isElectron()) return null; try { const result = await ( window as Window & typeof globalThis & { IS_ELECTRON?: boolean; electronAPI?: unknown; configuredServerUrl?: string; } ).electronAPI?.invoke("get-server-config"); return result; } catch (error) { console.error("Failed to get server config:", error); return null; } } export async function saveServerConfig(config: ServerConfig): Promise { if (!isElectron()) return false; try { const result = await ( window as Window & typeof globalThis & { IS_ELECTRON?: boolean; electronAPI?: unknown; configuredServerUrl?: string; } ).electronAPI?.invoke("save-server-config", config); if (result?.success) { configuredServerUrl = config.serverUrl; ( window as Window & typeof globalThis & { IS_ELECTRON?: boolean; electronAPI?: unknown; configuredServerUrl?: string; } ).configuredServerUrl = configuredServerUrl; updateApiInstances(); return true; } return false; } catch (error) { console.error("Failed to save server config:", error); return false; } } export function getConfiguredServerUrl(): string | null { return configuredServerUrl; } export async function testServerConnection( serverUrl: string, ): Promise<{ success: boolean; error?: string }> { if (!isElectron()) return { success: false, error: "Not in Electron environment" }; try { const result = await ( window as Window & typeof globalThis & { IS_ELECTRON?: boolean; electronAPI?: unknown; configuredServerUrl?: string; } ).electronAPI?.invoke("test-server-connection", serverUrl); return result; } catch (error) { console.error("Failed to test server connection:", error); return { success: false, error: "Connection test failed" }; } } export async function checkElectronUpdate(): Promise<{ success: boolean; status?: "up_to_date" | "requires_update" | "beta"; localVersion?: string; remoteVersion?: string; latest_release?: { tag_name: string; name: string; published_at: string; html_url: string; body: string; }; cached?: boolean; cache_age?: number; error?: string; }> { if (!isElectron()) return { success: false, error: "Not in Electron environment" }; try { const result = await ( window as Window & typeof globalThis & { IS_ELECTRON?: boolean; electronAPI?: unknown; configuredServerUrl?: string; } ).electronAPI?.invoke("check-electron-update"); return result; } catch (error) { console.error("Failed to check Electron update:", error); return { success: false, error: "Update check failed" }; } } export async function getEmbeddedServerStatus(): Promise<{ running: boolean; embedded: boolean; dataDir: string | null; } | null> { if (!isElectron()) return null; try { const result = await ( window as Window & typeof globalThis & { IS_ELECTRON?: boolean; electronAPI?: { invoke: (channel: string, ...args: unknown[]) => Promise; }; } ).electronAPI?.invoke("get-embedded-server-status"); return result as { running: boolean; embedded: boolean; dataDir: string | null; } | null; } catch { return null; } } export function isEmbeddedMode(): boolean { return embeddedMode; } export function setEmbeddedMode(value: boolean): void { embeddedMode = value; if (value) { configuredServerUrl = null; initializeApiInstances(); } } function getApiUrl(path: string, defaultPort: number): string { const devMode = isDev(); const electronMode = isElectron(); if (electronMode) { if (embeddedMode && !configuredServerUrl) { return `http://localhost:${defaultPort}${path}`; } if (configuredServerUrl) { const baseUrl = configuredServerUrl.replace(/\/$/, ""); const url = `${baseUrl}${path}`; return url; } console.warn("Electron mode but no server configured!"); return "http://no-server-configured"; } else if (devMode) { const protocol = window.location.protocol === "https:" ? "https" : "http"; const sslPort = protocol === "https" ? 8443 : defaultPort; const url = `${protocol}://${apiHost}:${sslPort}${path}`; return url; } else { return getBasePath() + path; } } function initializeApiInstances() { // Host Management API (port 30001) - supports SSH, RDP, VNC, Telnet hostApi = createApiInstance(getApiUrl("/host", 30001), "HOST"); sshHostApi = hostApi; // Tunnel Management API (port 30003) tunnelApi = createApiInstance(getApiUrl("/ssh", 30003), "TUNNEL"); // File Manager Operations API (port 30004) fileManagerApi = createApiInstance( getApiUrl("/ssh/file_manager", 30004), "FILE_MANAGER", ); // Server Statistics API (port 30005) statsApi = createApiInstance(getApiUrl("", 30005), "STATS"); // Authentication API (port 30001) authApi = createApiInstance(getApiUrl("", 30001), "AUTH"); // Dashboard API (port 30006) dashboardApi = createApiInstance(getApiUrl("", 30006), "DASHBOARD"); // RBAC API (port 30001) rbacApi = createApiInstance(getApiUrl("", 30001), "RBAC"); // Docker Management API (port 30007) dockerApi = createApiInstance(getApiUrl("/docker", 30007), "DOCKER"); // Tmux Monitor API (port 30010) --- tmux-monitor --- tmuxMonitorApi = createApiInstance( getApiUrl("/tmux_monitor", 30010), "TMUX_MONITOR", ); // Homepage API (port 30012) homepageApi = createApiInstance(getApiUrl("/homepage", 30012), "HOMEPAGE"); } // Host Management API (port 30001) - supports SSH, RDP, VNC, Telnet export let hostApi: AxiosInstance; // Backward compatibility export let sshHostApi: AxiosInstance; // Tunnel Management API (port 30003) export let tunnelApi: AxiosInstance; // File Manager Operations API (port 30004) export let fileManagerApi: AxiosInstance; // Server Statistics API (port 30005) export let statsApi: AxiosInstance; // Authentication API (port 30001) export let authApi: AxiosInstance; // Dashboard API (port 30006) export let dashboardApi: AxiosInstance; // RBAC API (port 30001) export let rbacApi: AxiosInstance; // Docker Management API (port 30007) export let dockerApi: AxiosInstance; // Tmux Monitor API (port 30010) --- tmux-monitor --- export let tmuxMonitorApi: AxiosInstance; // Homepage API (port 30012) export let homepageApi: AxiosInstance; // Pre-initialize with default values to avoid undefined errors during early mounting initializeApiInstances(); let _resolveAppReady!: () => void; export const appReadyPromise: Promise = new Promise((resolve) => { _resolveAppReady = resolve; }); function initializeApp() { if (isElectron()) { Promise.all([getServerConfig(), getEmbeddedServerStatus()]) .then(([config, status]) => { if (status?.embedded && status?.running && !config?.serverUrl) { embeddedMode = true; } if (config?.serverUrl) { configuredServerUrl = config.serverUrl; ( window as Window & typeof globalThis & { IS_ELECTRON?: boolean; electronAPI?: unknown; configuredServerUrl?: string; } ).configuredServerUrl = configuredServerUrl; } else if (embeddedMode) { // Embedded backend running, no remote server needed } else { console.warn("No server URL in config"); } initializeApiInstances(); }) .catch((error) => { console.error( "Failed to load server config, initializing with default:", error, ); initializeApiInstances(); }) .finally(() => { _resolveAppReady(); }); } else { initializeApiInstances(); _resolveAppReady(); } } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", initializeApp); } else { initializeApp(); } function updateApiInstances() { systemLogger.info("Updating API instances with new server configuration", { operation: "api_instance_update", configuredServerUrl, }); initializeApiInstances(); ( window as Window & typeof globalThis & { IS_ELECTRON?: boolean; electronAPI?: unknown; configuredServerUrl?: string; } ).configuredServerUrl = configuredServerUrl; systemLogger.success("All API instances updated successfully", { operation: "api_instance_update_complete", configuredServerUrl, }); } // ============================================================================ // ERROR HANDLING // ============================================================================ class ApiError extends Error { constructor( message: string, public status?: number, public code?: string, ) { super(message); this.name = "ApiError"; } } export function handleApiError(error: unknown, operation: string): never { const context: LogContext = { operation: "error_handling", errorOperation: operation, }; if (axios.isAxiosError(error)) { const status = error.response?.status; const message = error.response?.data?.message || error.response?.data?.error || error.message; const code = error.response?.data?.code || error.response?.data?.error; const url = error.config?.url; const method = error.config?.method?.toUpperCase(); const errorContext: LogContext = { ...context, method, url, status, errorCode: code, errorMessage: message, }; if (status === 401) { authLogger.warn( `Auth failed: ${method} ${url} - ${message}`, errorContext, ); const isLoginEndpoint = url?.includes("/users/login"); const errorMessage = isLoginEndpoint ? message : "Authentication required. Please log in again."; throw new ApiError(errorMessage, 401, code || "AUTH_REQUIRED"); } else if (status === 403) { authLogger.warn(`Access denied: ${method} ${url}`, errorContext); const apiError = new ApiError( code === "TOTP_REQUIRED" ? message : "Access denied. You do not have permission to perform this action.", 403, code || "ACCESS_DENIED", ); (apiError as ApiError & { response?: unknown }).response = error.response; throw apiError; } else if (status === 404) { apiLogger.warn(`Not found: ${method} ${url}`, errorContext); throw new ApiError( "Resource not found. The requested item may have been deleted.", 404, "NOT_FOUND", ); } else if (status === 409) { apiLogger.warn(`Conflict: ${method} ${url}`, errorContext); throw new ApiError( "Conflict. The resource already exists or is in use.", 409, "CONFLICT", ); } else if (status === 422) { apiLogger.warn( `Validation error: ${method} ${url} - ${message}`, errorContext, ); throw new ApiError( "Validation error. Please check your input and try again.", 422, "VALIDATION_ERROR", ); } else if (status && status >= 500) { apiLogger.error( `Server error: ${method} ${url} - ${message}`, error, errorContext, ); throw new ApiError( "Server error occurred. Please try again later.", status, "SERVER_ERROR", ); } else if (status === 0) { if (url.includes("no-server-configured")) { apiLogger.error( `No server configured: ${method} ${url}`, error, errorContext, ); throw new ApiError( "No server configured. Please configure a Termix server first.", 0, "NO_SERVER_CONFIGURED", ); } apiLogger.error( `Network error: ${method} ${url} - ${message}`, error, errorContext, ); throw new ApiError( "Network error. Please check your connection and try again.", 0, "NETWORK_ERROR", ); } else { apiLogger.error( `Request failed: ${method} ${url} - ${message}`, error, errorContext, ); throw new ApiError(message || `Failed to ${operation}`, status, code); } } if (error instanceof ApiError) { throw error; } const errorMessage = error instanceof Error ? error.message : "Unknown error"; apiLogger.error( `Unexpected error during ${operation}: ${errorMessage}`, error, context, ); throw new ApiError( `Unexpected error during ${operation}: ${errorMessage}`, undefined, "UNKNOWN_ERROR", ); } // ============================================================================ // ============================================================================ // HOST-TO-HOST TRANSFER // ============================================================================ export type TransferMethodPreference = "auto" | "tar" | "item_sftp"; export interface TransferScanSummary { fileCount: number; totalBytes: number; largestFileBytes: number; incompressibleRatio: number; } export interface TransferMethodPreview { methodPreference: TransferMethodPreference; resolvedMethod: "tar" | "item_sftp"; reasonKey: string; sourcePlatform: "unix" | "windows"; destPlatform: "unix" | "windows"; sourceHasTar: boolean; destHasTar: boolean; summary: TransferScanSummary; } export async function getTransferMethodPreview( sourceSessionId: string, sourcePaths: string[], destSessionId: string, destPath: string, methodPreference?: TransferMethodPreference, ): Promise { try { const response = await fileManagerApi.post("/ssh/transferMethodPreview", { sourceSessionId, sourcePaths, destSessionId, destPath, methodPreference: methodPreference ?? "auto", }); return response.data; } catch (error) { handleApiError(error, "preview transfer method"); throw error; } } export interface TransferHopMetrics { id: string; mbPerSec?: number; } export interface TransferTimings { prepareDestMs?: number; compressMs?: number; transferMs?: number; extractMs?: number; sourceDeleteMs?: number; totalMs?: number; transferBytes?: number; endToEndMbPerSec?: number; hops?: TransferHopMetrics[]; } export function getTransferProgressPercent( status: TransferProgressResponse, ): number | undefined { if ( status.bytesTransferred !== undefined && status.totalBytes !== undefined && status.totalBytes > 0 ) { return Math.min( 100, Math.round((status.bytesTransferred / status.totalBytes) * 100), ); } if ( status.itemsCompleted !== undefined && status.totalItems !== undefined && status.totalItems > 0 ) { return Math.min( 100, Math.round((status.itemsCompleted / status.totalItems) * 100), ); } return undefined; } export function formatTransferMbPerSec( mbPerSec?: number, _bytes?: number, _ms?: number, ): string { if (mbPerSec === undefined || mbPerSec <= 0) return ""; if (mbPerSec < 1) return `${(mbPerSec * 1024).toFixed(0)} KB/s`; return `${mbPerSec.toFixed(1)} MB/s`; } export function formatDurationMs(ms?: number): string { if (ms === undefined) return ""; if (ms < 1000) return `${ms}ms`; if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; const minutes = Math.floor(ms / 60000); const seconds = Math.round((ms % 60000) / 1000); return `${minutes}m ${seconds}s`; } interface TransferProgressTracker { update(status: TransferProgressResponse): { rate: number | undefined; stalled: boolean; }; } export function createTransferProgressTracker(): TransferProgressTracker { let lastBytes: number | undefined; let lastTime: number | undefined; let lastRate: number | undefined; let lastActivityTime: number | undefined; const STALL_THRESHOLD_MS = 5000; return { update(status) { const now = Date.now(); const bytes = status.bytesTransferred; if ( bytes !== undefined && lastBytes !== undefined && lastTime !== undefined ) { const deltaBytes = bytes - lastBytes; const deltaMs = now - lastTime; if (deltaMs > 0 && deltaBytes >= 0) { lastRate = (deltaBytes / deltaMs / 1024 / 1024) * 1000; if (deltaBytes > 0) lastActivityTime = now; } } else if (bytes !== undefined) { lastActivityTime = now; } lastBytes = bytes; lastTime = now; const stalled = lastActivityTime !== undefined && now - lastActivityTime > STALL_THRESHOLD_MS && status.status === "running" && status.phase === "transferring"; return { rate: lastRate, stalled }; }, }; } export interface TransferProgressResponse { transferId: string; status: "running" | "success" | "partial" | "error" | "cancelled"; phase: "compressing" | "transferring" | "extracting" | "reconnecting"; bytesTransferred?: number; totalBytes?: number; itemsCompleted?: number; totalItems?: number; failedPaths?: string[]; message?: string; method?: "stream" | "tar" | "item_sftp"; sourcePaths?: string[]; destPath?: string; sourceSessionId?: string; destSessionId?: string; startedAt?: number; timings?: TransferTimings; sourceDeleted?: boolean; moveRequested?: boolean; partialDestRemaining?: boolean; cleanupCompleted?: boolean; retryable?: boolean; parallelSegmentCount?: number; } export async function transferToHost( sourceSessionId: string, sourcePaths: string[], destSessionId: string, destPath: string, move?: boolean, methodPreference?: TransferMethodPreference, parallelSegmentCount = 2, ): Promise<{ transferId: string }> { try { fileLogger.info("Starting host transfer", { operation: "host_transfer", sourceSessionId, destSessionId, sourcePaths, destPath, move, methodPreference, parallelSegmentCount, }); const response = await fileManagerApi.post("/ssh/transferToHost", { sourceSessionId, sourcePaths, destSessionId, destPath, move, methodPreference: methodPreference ?? "auto", parallelSegmentCount, }); return response.data; } catch (error) { fileLogger.error("Failed to start host transfer", error, { operation: "host_transfer", sourceSessionId, destSessionId, sourcePaths, }); handleApiError(error, "transfer to host"); throw error; } } export async function getTransferStatus( transferId: string, ): Promise { try { const response = await fileManagerApi.get( `/ssh/transferStatus/${transferId}`, ); return response.data; } catch (error) { handleApiError(error, "get transfer status"); throw error; } } export async function listActiveTransfers(): Promise<{ transfers: TransferProgressResponse[]; }> { try { const response = await fileManagerApi.get("/ssh/activeTransfers"); return response.data; } catch (error) { handleApiError(error, "list active transfers"); throw error; } } export async function cancelTransferToHost(transferId: string): Promise { try { await fileManagerApi.post(`/ssh/transferCancel/${transferId}`); } catch (error) { fileLogger.warn("Transfer cancel request failed (non-fatal)", { operation: "host_transfer", transferId, error, }); } } export async function cleanupCancelledTransfer( transferId: string, ): Promise<{ removedPaths: string[]; failedPaths: string[] }> { try { const response = await fileManagerApi.post( `/ssh/transferCleanup/${transferId}`, ); return response.data; } catch (error) { handleApiError(error, "clean up cancelled transfer"); throw error; } } export async function retryTransferToHost( transferId: string, ): Promise<{ ok: boolean; transferId: string }> { try { const response = await fileManagerApi.post( `/ssh/transferRetry/${transferId}`, ); return response.data; } catch (error) { handleApiError(error, "retry transfer"); throw error; } } export async function pollTransferUntilComplete( transferId: string, onProgress?: (status: TransferProgressResponse) => void, intervalMs = 500, ): Promise { return new Promise((resolve, reject) => { const poll = async () => { try { const status = await getTransferStatus(transferId); onProgress?.(status); if ( status.status === "success" || status.status === "partial" || status.status === "error" || status.status === "cancelled" ) { resolve(status); return; } setTimeout(poll, intervalMs); } catch (err) { reject(err); } }; void poll(); }); } // ============================================================================ // FILE MANAGER DATA // ============================================================================ export interface TransferDestination { id: number; userId: string; sourceHostId: number; destHostId: number; destPath: string; destPathLabel?: string; lastUsed?: string; } export async function getTransferRecent( sourceHostId: number, ): Promise { try { const response = await authApi.get("/host/transfer/recent", { params: { sourceHostId }, }); return response.data; } catch (error) { handleApiError(error, "get transfer recent destinations"); throw error; } } export async function addTransferRecent( sourceHostId: number, destHostId: number, destPath: string, destPathLabel?: string, ): Promise> { try { const response = await authApi.post("/host/transfer/recent", { sourceHostId, destHostId, destPath, destPathLabel, }); return response.data; } catch (error) { handleApiError(error, "add transfer recent destination"); throw error; } } export { getSSHHosts, createSSHHost, updateSSHHost, wakeOnLan, bulkImportSSHHosts, importSSHConfigHosts, discoverProxmoxGuests, syncProxmoxGuests, bulkUpdateSSHHosts, deleteSSHHost, getSSHHostById, exportSSHHostWithCredentials, exportAllSSHHosts, enableAutoStart, disableAutoStart, getAutoStartStatus, testProxyConnection, } from "@/api/ssh-host-management-api"; export { getTunnelStatuses, subscribeTunnelStatuses, getTunnelStatusByName, connectTunnel, disconnectTunnel, cancelTunnel, getC2STunnelPresets, createC2STunnelPreset, updateC2STunnelPreset, deleteC2STunnelPreset, } from "@/api/tunnel-api"; export { getFileManagerRecent, addFileManagerRecent, removeFileManagerRecent, getFileManagerPinned, addFileManagerPinned, removeFileManagerPinned, getFileManagerShortcuts, addFileManagerShortcut, removeFileManagerShortcut, } from "@/api/file-manager-metadata-api"; export { connectSSH, disconnectSSH, verifySSHTOTP, verifySSHWarpgate, quickConnect, getSSHStatus, keepSSHAlive, listSSHFiles, identifySSHSymlink, resolveSSHPath, readSSHFile, writeSSHFile, uploadSSHFile, downloadSSHFile, downloadSSHFileStream, createSSHFile, createSSHFolder, deleteSSHItem, setSudoPassword, copySSHItem, renameSSHItem, moveSSHItem, changeSSHPermissions, extractSSHArchive, compressSSHFiles, ensureSSHSessionForHost, browseSSHDirectory, type HostConnectionState, type EnsureSSHSessionResult, type BrowseSSHDirectoryResult, } from "@/api/ssh-file-operations-api"; export { getRecentFiles, addRecentFile, removeRecentFile, getPinnedFiles, addPinnedFile, removePinnedFile, getFolderShortcuts, addFolderShortcut, removeFolderShortcut, } from "@/api/file-manager-data-api"; export { getAllServerStatuses, getServerStatusById, getServerMetricsById, startMetricsPolling, stopMetricsPolling, sendMetricsHeartbeat, registerMetricsViewer, unregisterMetricsViewer, submitMetricsTOTP, refreshServerPolling, notifyHostCreatedOrUpdated, } from "@/api/host-metrics-status-api"; export { getHostMetricsLayout, saveHostMetricsLayout, getHostPlatform, managerGet, managerGetSub, managerPost, type PlatformInfo, } from "@/api/host-metrics-api"; export { getGlobalMonitoringSettings, updateGlobalMonitoringSettings, getLogLevel, updateLogLevel, getSessionTimeout, updateSessionTimeout, getGuacamoleSettings, updateGuacamoleSettings, } from "@/api/settings-api"; // ============================================================================ // AUTHENTICATION // ============================================================================ export async function registerUser( username: string, password: string, ): Promise> { try { const response = await authApi.post("/users/create", { username, password, }); return response.data; } catch (error) { handleApiError(error, "register user"); } } export async function adminCreateUser( username: string, password: string, ): Promise> { try { const response = await authApi.post("/users/admin-create", { username, password, }); return response.data; } catch (error) { handleApiError(error, "admin create user"); } } export async function loginUser( username: string, password: string, rememberMe: boolean = false, ): Promise { try { const response = await authApi.post("/users/login", { username, password, rememberMe, }); const isInIframe = typeof window !== "undefined" && window.self !== window.top; if (isInIframe && isElectron() && response.data.success) { try { window.parent.postMessage( { type: "AUTH_SUCCESS", source: "login_api", platform: "desktop", timestamp: Date.now(), }, window.location.origin, ); } catch (e) { console.error("[main-axios] Error posting message to parent:", e); } } if (response.data.token) { localStorage.setItem("jwt", response.data.token); } if (response.data.success && !response.data.requires_totp) { markUserAuthenticated(); } return { success: response.data.success, is_admin: response.data.is_admin, username: response.data.username, requires_totp: response.data.requires_totp, temp_token: response.data.temp_token, rememberMe: response.data.rememberMe, is_oidc: response.data.is_oidc, totp_enabled: response.data.totp_enabled, data_unlocked: response.data.data_unlocked, token: response.data.token, }; } catch (error) { throw handleApiError(error, "login user"); } } export async function logoutUser(): Promise<{ success: boolean; message: string; }> { try { const response = await authApi.post("/users/logout"); clearTermixSessionStorage(); if (isElectron()) { const electronAPI = ( window as unknown as { electronAPI?: { clearSessionCookies?: () => Promise }; } ).electronAPI; electronAPI?.clearSessionCookies?.().catch(() => {}); } else { const isSecure = window.location.protocol === "https:"; const cookieString = isSecure ? "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; Secure; SameSite=Lax" : "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; SameSite=Lax"; document.cookie = cookieString; } return response.data; } catch (error) { clearTermixSessionStorage(); if (isElectron()) { const electronAPI = ( window as unknown as { electronAPI?: { clearSessionCookies?: () => Promise }; } ).electronAPI; electronAPI?.clearSessionCookies?.().catch(() => {}); } else { const isSecure = window.location.protocol === "https:"; const cookieString = isSecure ? "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; Secure; SameSite=Lax" : "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; SameSite=Lax"; document.cookie = cookieString; } handleApiError(error, "logout user"); } } export async function getUserInfo(): Promise { try { const response = await authApi.get("/users/me"); markUserAuthenticated(); return response.data; } catch (error) { handleApiError(error, "fetch user info"); } } export async function getCurrentToken(): Promise { try { const response = await authApi.get("/users/me/token"); return response.data?.token ?? null; } catch { return null; } } export async function unlockUserData( password: string, ): Promise<{ success: boolean; message: string }> { try { const response = await authApi.post("/users/unlock-data", { password }); return response.data; } catch (error) { handleApiError(error, "unlock user data"); } } export async function getRegistrationAllowed(): Promise<{ allowed: boolean }> { try { const response = await authApi.get("/users/registration-allowed"); return response.data; } catch (error) { handleApiError(error, "check registration status"); } } export async function getPasswordLoginAllowed(): Promise<{ allowed: boolean }> { try { const response = await authApi.get("/users/password-login-allowed"); return response.data; } catch (error) { handleApiError(error, "check password login status"); } } export async function getOIDCConfig(): Promise> { try { const response = await authApi.get("/users/oidc-config"); return response.data; } catch (error: unknown) { console.warn( "Failed to fetch OIDC config:", error.response?.data?.error || error.message, ); return null; } } export async function getAdminOIDCConfig(): Promise> { try { const response = await authApi.get("/users/oidc-config/admin"); return response.data; } catch (error) { handleApiError(error, "fetch admin OIDC config"); } } export async function getSetupRequired(): Promise<{ setup_required: boolean }> { try { const response = await authApi.get("/users/setup-required"); return response.data; } catch (error) { handleApiError(error, "check setup status"); } } export async function getUserCount(): Promise { try { const response = await authApi.get("/users/count"); return response.data; } catch (error) { handleApiError(error, "fetch user count"); } } export async function initiatePasswordReset( username: string, ): Promise> { try { const response = await authApi.post("/users/initiate-reset", { username }); return response.data; } catch (error) { handleApiError(error, "initiate password reset"); } } export async function verifyPasswordResetCode( username: string, resetCode: string, ): Promise> { try { const response = await authApi.post("/users/verify-reset-code", { username, resetCode, }); return response.data; } catch (error) { handleApiError(error, "verify reset code"); } } export async function completePasswordReset( username: string, tempToken: string, newPassword: string, ): Promise> { try { const response = await authApi.post("/users/complete-reset", { username, tempToken, newPassword, }); return response.data; } catch (error) { handleApiError(error, "complete password reset"); } } export async function changePassword(oldPassword: string, newPassword: string) { try { const response = await authApi.post("/users/change-password", { oldPassword, newPassword, }); return response.data; } catch (error) { handleApiError(error, "change password"); } } export async function getOIDCAuthorizeUrl( rememberMe = false, desktopCallbackPort?: number, providerId?: number, appCallbackUrl?: string, ): Promise { try { const response = await authApi.get("/users/oidc/authorize", { params: { rememberMe, desktopCallbackPort, providerId, appCallbackUrl }, }); return response.data; } catch (error) { handleApiError(error, "get OIDC authorize URL"); } } // ============================================================================ export { getUserList, getSessions, revokeSession, revokeAllUserSessions, createApiKey, getApiKeys, deleteApiKey, makeUserAdmin, removeAdminStatus, deleteUser, deleteAccount, updateRegistrationAllowed, getOidcAutoProvision, updateOidcAutoProvision, getOidcSilentLoginDefault, updateOidcSilentLoginDefault, updatePasswordLoginAllowed, getPasswordResetAllowed, updatePasswordResetAllowed, updateOIDCConfig, disableOIDCConfig, getCommandHistoryEnabled, updateCommandHistoryEnabled, type ApiKey, type CreatedApiKey, } from "@/api/user-management-api"; export { setupTOTP, enableTOTP, disableTOTP, verifyTOTPLogin, generateBackupCodes, getUserAlerts, dismissAlert, getReleasesRSS, getVersionInfo, getDatabaseHealth, } from "@/api/system-status-api"; // SSH CREDENTIALS MANAGEMENT // ============================================================================ export { getCredentials, getCredentialDetails, createCredential, updateCredential, deleteCredential, getCredentialHosts, getCredentialFolders, getSSHHostWithCredentials, getHostPassword, applyCredentialToHost, removeCredentialFromHost, migrateHostToCredential, getFoldersWithStats, renameFolder, getSSHFolders, updateFolderMetadata, deleteAllHostsInFolder, renameCredentialFolder, detectKeyType, detectPublicKeyType, validateKeyPair, generatePublicKeyFromPrivate, generateKeyPair, deployCredentialToHost, } from "@/api/credentials-api"; export { getVaultProfiles, createVaultProfile, updateVaultProfile, deleteVaultProfile, type VaultProfilePayload, } from "@/api/vault-profiles-api"; // ============================================================================ // SNIPPETS API // ============================================================================ export type { NetworkTopologyNode, NetworkTopologyEdge, NetworkTopologyData, } from "@/api/snippets-api"; export { getSnippets, createSnippet, updateSnippet, deleteSnippet, executeSnippet, getNetworkTopology, saveNetworkTopology, getSnippetFolders, createSnippetFolder, updateSnippetFolderMetadata, renameSnippetFolder, deleteSnippetFolder, reorderSnippets, } from "@/api/snippets-api"; // ============================================================================ export type { UptimeInfo, RecentActivityItem, ServiceLink, } from "@/api/dashboard-api"; export { getUptime, getRecentActivity, logActivity, resetRecentActivity, getServiceLinks, createServiceLink, deleteServiceLink, updateServiceLink, } from "@/api/dashboard-api"; // ============================================================================ export { saveCommandToHistory, getCommandHistory, deleteCommandFromHistory, clearCommandHistory, } from "@/api/command-history-api"; export { linkOIDCToPasswordAccount, unlinkOIDCFromPasswordAccount, } from "@/api/oidc-account-api"; export type { GuacamoleTokenRequest, GuacamoleTokenResponse, } from "@/api/guacamole-api"; export { getGuacamoleDpi, getGuacamoleToken, getGuacamoleTokenFromHost, getGuacdStatus, } from "@/api/guacamole-api"; // ============================================================================ // RBAC MANAGEMENT // ============================================================================ export { getRoles, createRole, updateRole, deleteRole, getUserRoles, assignRoleToUser, removeRoleFromUser, shareHost, getHostAccess, revokeHostAccess, shareSnippet, getSnippetAccess, revokeSnippetAccess, getSharedSnippets, } from "@/api/rbac-api"; // ============================================================================ // DOCKER MANAGEMENT API // ============================================================================ export { connectDockerSession, verifyDockerTOTP, verifyDockerWarpgate, disconnectDockerSession, keepaliveDockerSession, getDockerSessionStatus, validateDockerAvailability, listDockerContainers, getDockerContainerDetails, startDockerContainer, stopDockerContainer, restartDockerContainer, pauseDockerContainer, unpauseDockerContainer, removeDockerContainer, getContainerLogs, downloadContainerLogs, getContainerStats, } from "@/api/docker-api"; export { getOpenTabs, syncOpenTabs, deleteOpenTab, patchOpenTab, addOpenTab, getActiveSessions, getUserPreferences, saveUserPreferences, type OpenTabRecord, type OpenTabSyncPayload, type OpenTabUpsertPayload, type ActiveSessionInfo, type UserPreferences, } from "@/api/open-tabs-api"; // ============================================================================ // TERMIX ID API // ============================================================================ export { getMyTermixId, checkTermixIdHandle, createTermixId, updateTermixId, deleteTermixId, addTermixIdKey, generateTermixIdKey, setTermixIdKeyEnabled, deleteTermixIdKey, getMyCa, createCa, rotateCa, deleteCa, issueCertificate, getLinkedCredentialIds, type TermixIdentity, type TermixIdentityKey, type TermixIdMe, type GeneratedKey, type TermixIdCa, type IssuedCertificate, } from "@/api/termix-id-api";