mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
v2.3.2 (#874)
* fix: patch critical security vulnerabilities (GHSA-5fqh, GHSA-ccm8, GHSA-wqfw, GHSA-xmjh) - Remove passwordHash from /users/list API response - Require both password and TOTP code for MFA-critical operations - Restrict tunnel kill commands to tunnelMarker-only matching - Add session ownership middleware for file manager endpoints * fix: allow navigating away from split-view to non-pane tabs Show the normal view container on top of the split view when the active tab is not assigned to any pane, so users can switch to dashboard or other tabs while split mode is active. Closes #739 * fix: add inline quick-action buttons on host name row Show Terminal, Files, RDP, and VNC shortcut icons on the host name row on hover, so users can launch connections with a single click without expanding the full action tray. Closes #736 * fix: restore SSH keepalive interval to 30s to prevent random disconnects Revert keepalive defaults from 60s/5 to 30s/3 across terminal, tunnel, and server-stats SSH connections. The 60s interval introduced in 2.3.0 causes firewalls and NAT devices to drop idle connections before the next keepalive probe. Closes #733 * fix: apply guacamole-lite protocol patch in Docker builds The Dockerfile uses --ignore-scripts which skips the postinstall hook that patches guacamole-lite for guacd 1.6.0 protocol VERSION_1_5_0. Without this patch, the timezone handshake instruction is not sent for protocol versions above 1.1.0, causing VNC connections to fail immediately on connect. Closes #734 * fix: show correct icons for network interface types Detect interface type from name pattern and show appropriate icons: WiFi for wlan/wl*, Ethernet (Cable) for eth/en*, Container for docker/bridge/virtual, generic Network for others. Closes #720 * fix: resolve sudo password for shared host users The password endpoint required hosts.userId to match the requesting user, which fails for shared hosts. Now falls back to decrypting with the owner's key when the requesting user doesn't own the host. Closes #717 * fix: use jump hosts for online status check and metrics collection Status polling now pings the first jump host instead of the unreachable target when jump hosts are configured. The /metrics/start endpoint now tunnels through the jump host chain to reach the target host. Closes #716 * fix: broaden sudo prompt detection for newer distros Add patterns for 'password for <user>:' and bare 'Password:' prompts in addition to the existing [sudo] and sudo: patterns. Covers Ubuntu 26.04 and other distros that use different sudo prompt formats. Closes #718 * fix: recalculate terminal layout after web fonts load xterm.js measures character widths at open() time. If custom fonts haven't loaded yet, measurements use the fallback font and spacing becomes incorrect. Now refresh and re-fit the terminal once document.fonts.ready resolves. Closes #710 * fix: improve terminal cwd detection and initial directory command Remove '&& pwd' from initial directory command — the shell prompt shows the new directory naturally. Fixes PowerShell 5.1 which doesn't support '&&' as a statement separator. Prepend Ctrl+U to get_cwd command to clear any pending input before injecting the cwd probe, reducing interference with foreground programs. Closes #713, #714 * fix: decode base64 file content as UTF-8 in file manager Replace bare atob() with TextDecoder('utf-8') for base64 content decoding. atob() only handles Latin-1, so multi-byte UTF-8 characters like 'é' were decoded as 'é'. Closes #719 * fix: normalize lazy import default exports for iOS compatibility Wrap all lazy() imports with explicit .then(m => ({ default: m.default })) to ensure consistent module resolution across platforms. iOS Safari/WebView may handle bare lazy(() => import(...)) differently, returning the module object instead of extracting the default export. Closes #721 * fix: prevent RDP display from snapping back after container resize Remove immediate rescaleDisplay() from ResizeObserver callback. The display.onresize event already triggers rescaling when the RDP server responds with the new resolution. Calling rescaleDisplay before the server responds uses stale display dimensions, causing the bottom of the screen to be truncated. Closes #725 * fix: add portal Desktop DBus permission for Flatpak URL opening Flatpak sandbox blocks window.open() without the portal permission, causing terminal link clicks to open about:blank. Add talk-name for org.freedesktop.portal.Desktop to enable xdg-desktop-portal URL handling. Closes #704 * chore: remove unused code and fix PR checks (#851) * chore: remove unused frontend code * chore: prune unused theme exports * ci: fix pr check failures * chore: reduce lint warnings * feat(oidc): expose admin_group via OIDC_ADMIN_GROUP env var (#828) The admin-group OIDC sync added in 2.3.0 (#782) reads `config.admin_group` to sync the user's admin flag from OIDC group membership on each login. That field is only populated when the OIDC config is stored in the in-app DB — `getOIDCConfigFromEnv()` does not expose it, so deployments using the env-var config path (declarative IaC: Helm/Compose/Puppet) cannot enable the feature without abandoning env vars and pasting the client_secret into the admin UI. Add `admin_group: process.env.OIDC_ADMIN_GROUP || ""` to the env-config return type and object. Backward compatible: when unset, the existing `if (config.admin_group)` guard at users.ts:1336 keeps the sync block skipped, matching today's behavior. * chore: reduce explicit-any warnings * chore: reduce more explicit-any warnings * chore: reduce lint warnings * chore: silence intentional hook dependency warnings * chore: clean dependency tooling * chore: narrow frontend tsconfig scope * chore: reduce type assertion debt * refactor: split host manager components * refactor: split host editor sections * refactor: split api client modules * refactor: split more api clients * refactor: split user settings api clients * refactor: split tab and history api clients * refactor: split tunnel api clients * refactor: split server stats api client * refactor: split file manager data api * refactor: split ssh file operations api * refactor: split host editor general tab * refactor: split host editor guacamole tabs * refactor: split ssh host management api * refactor: split admin general settings sections * refactor: split admin database section * refactor: split admin management sections * refactor: split admin keys and dialogs * refactor: split system status api clients * refactor: split user route helpers * refactor: split host route helpers * refactor: split file manager ssh helpers * refactor: split file manager session helpers * refactor: split file manager listing routes * refactor: split host opkssh routes * refactor: split file manager content routes * refactor: split user api key routes * refactor: split host folder routes * refactor: split user settings routes * refactor: split user totp routes * refactor: split host file manager bookmark routes * refactor: split file manager operation routes * refactor: split server stats settings routes * refactor: split user session routes * refactor: split host command history routes * refactor: split server stats viewer routes * refactor: split docker container routes * refactor: split user oidc account routes * refactor: split host autostart routes * refactor: split host internal routes * refactor: split host network routes * refactor: split user password reset routes * refactor: split user admin routes * refactor: split user data access routes * refactor: split credential key routes * refactor: split credential deploy routes * refactor: split host bulk routes * refactor: split server stats connection helpers * refactor: split tunnel helpers * refactor: split file manager action routes * refactor: split terminal auth helpers * refactor: split terminal jump host helpers * refactor: split tunnel relay helpers * refactor: split tunnel socks relay helpers * refactor: split tunnel c2s relay handlers * refactor: split server stats session helpers * refactor: split terminal presentation helpers * refactor: split file manager presentation helpers * refactor: split file manager toolbar * fix(guacamole-lite): send name instruction for protocol >= 1.3.0 The Guacamole protocol added the `name` handshake instruction in 1.3.0 (an optional human-readable identifier for the joining user). guacd 1.6.0 began requiring it during the VNC handshake even when negotiating older protocol versions, causing connections to silently drop right after the "User joined" log line with no client-visible error. This patch extends scripts/patch-guacamole-lite.cjs with a third idempotent string-replacement that injects the `name` instruction send when guacamole-lite has negotiated protocol VERSION_1_3_0 or VERSION_1_5_0. Verified end-to-end: guacd debug logs now show `Processing instruction: name` and `Client is using protocol version "VERSION_1_5_0"` (previously stuck at VERSION_1_1_0). VNC session connects successfully against guacd 1.5.5 / macOS Tahoe target. Related: Termix-SSH/Support#567, #734 * fix: resolve recent support bugs * fix(admin): wire up OIDC-to-password link dialog submit + visibility The admin user-management UI already shipped a link icon and a "Link Account" dialog, but two things blocked the flow: 1. The submit button had no onClick handler and the username input was uncontrolled (no value/onChange). Clicking "Link Accounts" was a no-op — no network request, no console error, no toast. 2. The link icon's visibility condition was `user.isOidc && !user.passwordHash`, which hid the button on OIDC users that had been auto-provisioned with a passwordHash. Termix's OIDC provisioning sets a passwordHash by default, so the button was hidden on virtually every OIDC-provisioned user. This change: - Adds `linkOIDCToPasswordAccount` to the imports from `@/main-axios`. - Adds two pieces of dialog state: `linkAccountTargetUsername` and `linkAccountSubmitting`. - Makes the dialog's Input field a controlled component. - Wires the submit Button's onClick to call `linkOIDCToPasswordAccount`, emit success/error toasts, refresh the local user list, and close the dialog. - Loosens the visibility condition to `user.isOidc` (the backend handler already enforces all integrity checks). - Adds `linkAccountSuccess`, `linkAccountFailed`, and `linkAccountInProgress` translation keys to `en.json`. Verified locally: full Docker build via docker/Dockerfile passes; `tsc --noEmit` is clean; `prettier --check .` is clean; ESLint produces the same warning count as upstream (16 pre-existing `any`-type warnings, 0 errors). * fix: support native oidc callbacks (#856) * docs: add cloudflare tunnel guidance (#857) * fix: sync appearance preferences (#858) * fix: pass through terminal tab completion (#859) * fix: resolve terminal jump hosts server-side (#860) * fix(electron): auto-allow SSL certificates for private network hosts (#861) Add private network IP detection (RFC 1918, link-local, loopback, IPv6 ULA) to the Electron certificate-error handler so that connections to local/private servers like 192.168.x.x bypass SSL validation automatically. Also add an explicit "Allow invalid certificate" toggle in the server config UI for public HTTPS servers with self-signed certs. * fix: restore host password copy actions (#862) * feat: support single-host direct tunnels (ssh -L style) (#863) Add direct tunnel mode that uses a single SSH host for port forwarding, matching the behavior of ssh -L / ssh -R / ssh -D without requiring a second endpoint host in the Termix database. The Termix server creates a local TCP listener and forwards through the SSH channel directly. * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * fix: backend build errors (Type) * fix: mobile auth failing to login with webview * fix: mobile app geting incorrectly sent auth token * feat: commit existing frontend/backend e2e/unit tests (skipped tests containing private info like OIDC and real server testing) * feat: host-to-host file transfer via server relay * feat: removed host management from command palette, fixed command palette opening wrong protocol, export/import failing for ssh key hosts, docker ssh2 native crypto not compiled, persisted terminal tabs attempt SSh on RDP hosts after migration, improved layout for click to expand hosts, show ip/username without having to hover over hosts * fix: credentials not indexing into host manager until refresh * feat: update credentials lists to match hosts list UI/UX * feat: add rename folder UI * feat: improve transfer to host UI/UX * chore: increment ver * feat: improve transfer to host UI * feat: implement initial auto release system --------- Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: nicodarge <43711429+nicodarge@users.noreply.github.com> Co-authored-by: Raman Gupta <7243222+raman325@users.noreply.github.com> Co-authored-by: luc <luc_cook@hotmail.co.uk>
This commit is contained in:
co-authored by
ZacharyZcR
nicodarge
Raman Gupta
luc
parent
da79b01db4
commit
52f4e51ae0
@@ -0,0 +1,63 @@
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
|
||||
// COMMAND HISTORY API
|
||||
// ============================================================================
|
||||
|
||||
export async function saveCommandToHistory(
|
||||
hostId: number,
|
||||
command: string,
|
||||
): Promise<{ id: number; command: string; executedAt: string }> {
|
||||
try {
|
||||
const response = await authApi.post("/terminal/command_history", {
|
||||
hostId,
|
||||
command,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "save command to history");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCommandHistory(
|
||||
hostId: number,
|
||||
limit: number = 100,
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const response = await authApi.get(`/terminal/command_history/${hostId}`, {
|
||||
params: { limit },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch command history");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCommandFromHistory(
|
||||
hostId: number,
|
||||
command: string,
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await authApi.post("/terminal/command_history/delete", {
|
||||
hostId,
|
||||
command,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "delete command from history");
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearCommandHistory(
|
||||
hostId: number,
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await authApi.delete(
|
||||
`/terminal/command_history/${hostId}`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "clear command history");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -0,0 +1,365 @@
|
||||
import { authApi, handleApiError, sshHostApi } from "@/main-axios";
|
||||
import type { SSHFolder } from "@/types/index";
|
||||
import { sshLogger } from "@/lib/frontend-logger";
|
||||
|
||||
export async function getCredentials(): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.get("/credentials");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch credentials");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCredentialDetails(
|
||||
credentialId: number,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.get(`/credentials/${credentialId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch credential details");
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCredential(
|
||||
credentialData: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.post("/credentials", credentialData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "create credential");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCredential(
|
||||
credentialId: number,
|
||||
credentialData: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.put(
|
||||
`/credentials/${credentialId}`,
|
||||
credentialData,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update credential");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCredential(
|
||||
credentialId: number,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.delete(`/credentials/${credentialId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "delete credential");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCredentialHosts(
|
||||
credentialId: number,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.get(`/credentials/${credentialId}/hosts`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch credential hosts");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCredentialFolders(): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.get("/credentials/folders");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch credential folders");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSSHHostWithCredentials(
|
||||
hostId: number,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await sshHostApi.get(
|
||||
`/db/host/${hostId}/with-credentials`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch SSH host with credentials");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getHostPassword(
|
||||
hostId: number,
|
||||
field: "password" | "sudoPassword" = "password",
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const response = await sshHostApi.get(
|
||||
`/db/host/${hostId}/password?field=${field}`,
|
||||
);
|
||||
return response.data?.value || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyCredentialToHost(
|
||||
hostId: number,
|
||||
credentialId: number,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await sshHostApi.post(
|
||||
`/db/host/${hostId}/apply-credential`,
|
||||
{ credentialId },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "apply credential to host");
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeCredentialFromHost(
|
||||
hostId: number,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await sshHostApi.delete(`/db/host/${hostId}/credential`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "remove credential from host");
|
||||
}
|
||||
}
|
||||
|
||||
export async function migrateHostToCredential(
|
||||
hostId: number,
|
||||
credentialName: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await sshHostApi.post(
|
||||
`/db/host/${hostId}/migrate-to-credential`,
|
||||
{ credentialName },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "migrate host to credential");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFoldersWithStats(): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.get("/host/db/folders/with-stats");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch folders with statistics");
|
||||
}
|
||||
}
|
||||
|
||||
export async function renameFolder(
|
||||
oldName: string,
|
||||
newName: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.put("/host/folders/rename", {
|
||||
oldName,
|
||||
newName,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "rename folder");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSSHFolders(): Promise<SSHFolder[]> {
|
||||
try {
|
||||
sshLogger.info("Fetching SSH folders", {
|
||||
operation: "fetch_ssh_folders",
|
||||
});
|
||||
|
||||
const response = await authApi.get("/host/folders");
|
||||
|
||||
sshLogger.success("SSH folders fetched successfully", {
|
||||
operation: "fetch_ssh_folders",
|
||||
count: response.data.length,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to fetch SSH folders", error, {
|
||||
operation: "fetch_ssh_folders",
|
||||
});
|
||||
handleApiError(error, "fetch SSH folders");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateFolderMetadata(
|
||||
name: string,
|
||||
color?: string,
|
||||
icon?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
sshLogger.info("Updating folder metadata", {
|
||||
operation: "update_folder_metadata",
|
||||
name,
|
||||
color,
|
||||
icon,
|
||||
});
|
||||
|
||||
await authApi.put("/host/folders/metadata", {
|
||||
name,
|
||||
color,
|
||||
icon,
|
||||
});
|
||||
|
||||
sshLogger.success("Folder metadata updated successfully", {
|
||||
operation: "update_folder_metadata",
|
||||
name,
|
||||
});
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to update folder metadata", error, {
|
||||
operation: "update_folder_metadata",
|
||||
name,
|
||||
});
|
||||
handleApiError(error, "update folder metadata");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAllHostsInFolder(
|
||||
folderName: string,
|
||||
): Promise<{ deletedCount: number }> {
|
||||
try {
|
||||
sshLogger.info("Deleting all hosts in folder", {
|
||||
operation: "delete_folder_hosts",
|
||||
folderName,
|
||||
});
|
||||
|
||||
const response = await authApi.delete(
|
||||
`/host/folders/${encodeURIComponent(folderName)}/hosts`,
|
||||
);
|
||||
|
||||
sshLogger.success("All hosts in folder deleted successfully", {
|
||||
operation: "delete_folder_hosts",
|
||||
folderName,
|
||||
deletedCount: response.data.deletedCount,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to delete hosts in folder", error, {
|
||||
operation: "delete_folder_hosts",
|
||||
folderName,
|
||||
});
|
||||
handleApiError(error, "delete hosts in folder");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function renameCredentialFolder(
|
||||
oldName: string,
|
||||
newName: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.put("/credentials/folders/rename", {
|
||||
oldName,
|
||||
newName,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "rename credential folder");
|
||||
}
|
||||
}
|
||||
|
||||
export async function detectKeyType(
|
||||
privateKey: string,
|
||||
keyPassword?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.post("/credentials/detect-key-type", {
|
||||
privateKey,
|
||||
keyPassword,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "detect key type");
|
||||
}
|
||||
}
|
||||
|
||||
export async function detectPublicKeyType(
|
||||
publicKey: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.post("/credentials/detect-public-key-type", {
|
||||
publicKey,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "detect public key type");
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateKeyPair(
|
||||
privateKey: string,
|
||||
publicKey: string,
|
||||
keyPassword?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.post("/credentials/validate-key-pair", {
|
||||
privateKey,
|
||||
publicKey,
|
||||
keyPassword,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "validate key pair");
|
||||
}
|
||||
}
|
||||
|
||||
export async function generatePublicKeyFromPrivate(
|
||||
privateKey: string,
|
||||
keyPassword?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.post("/credentials/generate-public-key", {
|
||||
privateKey,
|
||||
keyPassword,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "generate public key from private key");
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateKeyPair(
|
||||
keyType: "ssh-ed25519" | "ssh-rsa" | "ecdsa-sha2-nistp256",
|
||||
keySize?: number,
|
||||
passphrase?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.post("/credentials/generate-key-pair", {
|
||||
keyType,
|
||||
keySize,
|
||||
passphrase,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "generate SSH key pair");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deployCredentialToHost(
|
||||
credentialId: number,
|
||||
targetHostId: number,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.post(
|
||||
`/credentials/${credentialId}/deploy-to-host`,
|
||||
{ targetHostId },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "deploy credential to host");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { dashboardApi, handleApiError } from "@/main-axios";
|
||||
|
||||
// DASHBOARD API
|
||||
// ============================================================================
|
||||
|
||||
export interface UptimeInfo {
|
||||
uptimeMs: number;
|
||||
uptimeSeconds: number;
|
||||
formatted: string;
|
||||
}
|
||||
|
||||
export interface RecentActivityItem {
|
||||
id: number;
|
||||
userId: string;
|
||||
type:
|
||||
| "terminal"
|
||||
| "file_manager"
|
||||
| "server_stats"
|
||||
| "tunnel"
|
||||
| "docker"
|
||||
| "telnet"
|
||||
| "vnc"
|
||||
| "rdp";
|
||||
hostId: number;
|
||||
hostName: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export async function getUptime(): Promise<UptimeInfo> {
|
||||
try {
|
||||
const response = await dashboardApi.get("/uptime");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch uptime");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRecentActivity(
|
||||
limit?: number,
|
||||
): Promise<RecentActivityItem[]> {
|
||||
try {
|
||||
const response = await dashboardApi.get("/activity/recent", {
|
||||
params: { limit },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch recent activity");
|
||||
}
|
||||
}
|
||||
|
||||
export async function logActivity(
|
||||
type:
|
||||
| "terminal"
|
||||
| "file_manager"
|
||||
| "server_stats"
|
||||
| "tunnel"
|
||||
| "docker"
|
||||
| "rdp"
|
||||
| "vnc"
|
||||
| "telnet",
|
||||
hostId: number,
|
||||
hostName: string,
|
||||
): Promise<{ message: string; id: number | string }> {
|
||||
try {
|
||||
const response = await dashboardApi.post("/activity/log", {
|
||||
type,
|
||||
hostId,
|
||||
hostName,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "log activity");
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetRecentActivity(): Promise<{ message: string }> {
|
||||
try {
|
||||
const response = await dashboardApi.delete("/activity/reset");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "reset recent activity");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { dashboardApi } from "@/main-axios";
|
||||
|
||||
export interface DashboardLayout {
|
||||
cards: Array<{
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
order: number;
|
||||
panel?: "main" | "side";
|
||||
height?: number | null;
|
||||
}>;
|
||||
mainWidthPct?: number;
|
||||
}
|
||||
|
||||
export async function getDashboardPreferences(): Promise<DashboardLayout> {
|
||||
const response = await dashboardApi.get("/dashboard/preferences");
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function saveDashboardPreferences(
|
||||
layout: DashboardLayout,
|
||||
): Promise<{ success: boolean }> {
|
||||
const response = await dashboardApi.post("/dashboard/preferences", layout);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -0,0 +1,345 @@
|
||||
import axios from "axios";
|
||||
import { dockerApi, handleApiError } from "@/main-axios";
|
||||
import type {
|
||||
DockerContainer,
|
||||
DockerLogOptions,
|
||||
DockerStats,
|
||||
DockerValidation,
|
||||
} from "@/types/index";
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
export async function connectDockerSession(
|
||||
sessionId: string,
|
||||
hostId: number,
|
||||
config?: {
|
||||
userProvidedPassword?: string;
|
||||
userProvidedSshKey?: string;
|
||||
userProvidedKeyPassword?: string;
|
||||
forceKeyboardInteractive?: boolean;
|
||||
useSocks5?: boolean;
|
||||
socks5Host?: string;
|
||||
socks5Port?: number;
|
||||
socks5Username?: string;
|
||||
socks5Password?: string;
|
||||
socks5ProxyChain?: unknown;
|
||||
},
|
||||
): Promise<{
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
requires_totp?: boolean;
|
||||
prompt?: string;
|
||||
isPassword?: boolean;
|
||||
status?: string;
|
||||
reason?: string;
|
||||
connectionLogs?: ApiConnectionLog[];
|
||||
requires_warpgate?: boolean;
|
||||
url?: string;
|
||||
securityKey?: string;
|
||||
}> {
|
||||
try {
|
||||
const response = await dockerApi.post("/ssh/connect", {
|
||||
sessionId,
|
||||
hostId,
|
||||
...config,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
axios.isAxiosError<ConnectErrorResponse>(error) &&
|
||||
error.response?.data?.status === "auth_required"
|
||||
) {
|
||||
return error.response.data;
|
||||
}
|
||||
if (
|
||||
axios.isAxiosError<ConnectErrorResponse>(error) &&
|
||||
error.response?.data?.requires_totp
|
||||
) {
|
||||
return error.response.data;
|
||||
}
|
||||
if (
|
||||
axios.isAxiosError<ConnectErrorResponse>(error) &&
|
||||
error.response?.data?.requires_warpgate
|
||||
) {
|
||||
return error.response.data;
|
||||
}
|
||||
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;
|
||||
}
|
||||
throw handleApiError(error, "connect to Docker SSH session");
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyDockerTOTP(
|
||||
sessionId: string,
|
||||
totpCode: string,
|
||||
): Promise<{ status: string; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post("/ssh/connect-totp", {
|
||||
sessionId,
|
||||
totpCode,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "verify Docker TOTP");
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyDockerWarpgate(
|
||||
sessionId: string,
|
||||
): Promise<{ status: string; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post("/ssh/connect-warpgate", {
|
||||
sessionId,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "verify Docker Warpgate");
|
||||
}
|
||||
}
|
||||
|
||||
export async function disconnectDockerSession(
|
||||
sessionId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post("/ssh/disconnect", {
|
||||
sessionId,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "disconnect from Docker SSH session");
|
||||
}
|
||||
}
|
||||
|
||||
export async function keepaliveDockerSession(
|
||||
sessionId: string,
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await dockerApi.post("/ssh/keepalive", {
|
||||
sessionId,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "keepalive Docker SSH session");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDockerSessionStatus(
|
||||
sessionId: string,
|
||||
): Promise<{ success: boolean; connected: boolean }> {
|
||||
try {
|
||||
const response = await dockerApi.get("/ssh/status", {
|
||||
params: { sessionId },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get Docker session status");
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateDockerAvailability(
|
||||
sessionId: string,
|
||||
): Promise<DockerValidation> {
|
||||
try {
|
||||
const response = await dockerApi.get(`/validate/${sessionId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "validate Docker availability");
|
||||
}
|
||||
}
|
||||
|
||||
export async function listDockerContainers(
|
||||
sessionId: string,
|
||||
all: boolean = true,
|
||||
): Promise<DockerContainer[]> {
|
||||
try {
|
||||
const response = await dockerApi.get(`/containers/${sessionId}`, {
|
||||
params: { all },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "list Docker containers");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDockerContainerDetails(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
): Promise<DockerContainer> {
|
||||
try {
|
||||
const response = await dockerApi.get(
|
||||
`/containers/${sessionId}/${containerId}`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get Docker container details");
|
||||
}
|
||||
}
|
||||
|
||||
export async function startDockerContainer(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post(
|
||||
`/containers/${sessionId}/${containerId}/start`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "start Docker container");
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopDockerContainer(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post(
|
||||
`/containers/${sessionId}/${containerId}/stop`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "stop Docker container");
|
||||
}
|
||||
}
|
||||
|
||||
export async function restartDockerContainer(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post(
|
||||
`/containers/${sessionId}/${containerId}/restart`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "restart Docker container");
|
||||
}
|
||||
}
|
||||
|
||||
export async function pauseDockerContainer(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post(
|
||||
`/containers/${sessionId}/${containerId}/pause`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "pause Docker container");
|
||||
}
|
||||
}
|
||||
|
||||
export async function unpauseDockerContainer(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post(
|
||||
`/containers/${sessionId}/${containerId}/unpause`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "unpause Docker container");
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeDockerContainer(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
force: boolean = false,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.delete(
|
||||
`/containers/${sessionId}/${containerId}/remove`,
|
||||
{
|
||||
params: { force },
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "remove Docker container");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getContainerLogs(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
options?: DockerLogOptions,
|
||||
): Promise<{ logs: string }> {
|
||||
try {
|
||||
const response = await dockerApi.get(
|
||||
`/containers/${sessionId}/${containerId}/logs`,
|
||||
{
|
||||
params: options,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get container logs");
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadContainerLogs(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
options?: DockerLogOptions,
|
||||
): Promise<Blob> {
|
||||
try {
|
||||
const response = await dockerApi.get(
|
||||
`/containers/${sessionId}/${containerId}/logs`,
|
||||
{
|
||||
params: { ...options, download: true },
|
||||
responseType: "blob",
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "download container logs");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getContainerStats(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
): Promise<DockerStats> {
|
||||
try {
|
||||
const response = await dockerApi.get(
|
||||
`/containers/${sessionId}/${containerId}/stats`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get container stats");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
|
||||
// FILE MANAGER DATA
|
||||
// ============================================================================
|
||||
|
||||
export async function getRecentFiles(
|
||||
hostId: number,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.get("/host/file_manager/recent", {
|
||||
params: { hostId },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "get recent files");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function addRecentFile(
|
||||
hostId: number,
|
||||
path: string,
|
||||
name?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.post("/host/file_manager/recent", {
|
||||
hostId,
|
||||
path,
|
||||
name,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "add recent file");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeRecentFile(
|
||||
hostId: number,
|
||||
path: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.delete("/host/file_manager/recent", {
|
||||
data: { hostId, path },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "remove recent file");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPinnedFiles(
|
||||
hostId: number,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.get("/host/file_manager/pinned", {
|
||||
params: { hostId },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "get pinned files");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function addPinnedFile(
|
||||
hostId: number,
|
||||
path: string,
|
||||
name?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.post("/host/file_manager/pinned", {
|
||||
hostId,
|
||||
path,
|
||||
name,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "add pinned file");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function removePinnedFile(
|
||||
hostId: number,
|
||||
path: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.delete("/host/file_manager/pinned", {
|
||||
data: { hostId, path },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "remove pinned file");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFolderShortcuts(
|
||||
hostId: number,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.get("/host/file_manager/shortcuts", {
|
||||
params: { hostId },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "get folder shortcuts");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function addFolderShortcut(
|
||||
hostId: number,
|
||||
path: string,
|
||||
name?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.post("/host/file_manager/shortcuts", {
|
||||
hostId,
|
||||
path,
|
||||
name,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "add folder shortcut");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeFolderShortcut(
|
||||
hostId: number,
|
||||
path: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.delete("/host/file_manager/shortcuts", {
|
||||
data: { hostId, path },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "remove folder shortcut");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -0,0 +1,126 @@
|
||||
import { handleApiError, sshHostApi } from "@/main-axios";
|
||||
import type { FileManagerFile, FileManagerShortcut } from "@/types/index";
|
||||
|
||||
interface FileManagerOperation {
|
||||
name: string;
|
||||
path: string;
|
||||
isSSH: boolean;
|
||||
sshSessionId?: string;
|
||||
hostId: number;
|
||||
}
|
||||
|
||||
// FILE MANAGER METADATA (Recent, Pinned, Shortcuts)
|
||||
// ============================================================================
|
||||
|
||||
export async function getFileManagerRecent(
|
||||
hostId: number,
|
||||
): Promise<FileManagerFile[]> {
|
||||
try {
|
||||
const response = await sshHostApi.get(
|
||||
`/file_manager/recent?hostId=${hostId}`,
|
||||
);
|
||||
return response.data || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function addFileManagerRecent(
|
||||
file: FileManagerOperation,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await sshHostApi.post("/file_manager/recent", file);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "add recent file");
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeFileManagerRecent(
|
||||
file: FileManagerOperation,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await sshHostApi.delete("/file_manager/recent", {
|
||||
data: file,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "remove recent file");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFileManagerPinned(
|
||||
hostId: number,
|
||||
): Promise<FileManagerFile[]> {
|
||||
try {
|
||||
const response = await sshHostApi.get(
|
||||
`/file_manager/pinned?hostId=${hostId}`,
|
||||
);
|
||||
return response.data || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function addFileManagerPinned(
|
||||
file: FileManagerOperation,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await sshHostApi.post("/file_manager/pinned", file);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "add pinned file");
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeFileManagerPinned(
|
||||
file: FileManagerOperation,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await sshHostApi.delete("/file_manager/pinned", {
|
||||
data: file,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "remove pinned file");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFileManagerShortcuts(
|
||||
hostId: number,
|
||||
): Promise<FileManagerShortcut[]> {
|
||||
try {
|
||||
const response = await sshHostApi.get(
|
||||
`/file_manager/shortcuts?hostId=${hostId}`,
|
||||
);
|
||||
return response.data || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function addFileManagerShortcut(
|
||||
shortcut: FileManagerOperation,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await sshHostApi.post("/file_manager/shortcuts", shortcut);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "add shortcut");
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeFileManagerShortcut(
|
||||
shortcut: FileManagerOperation,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await sshHostApi.delete("/file_manager/shortcuts", {
|
||||
data: shortcut,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "remove shortcut");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -0,0 +1,228 @@
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
|
||||
export interface GuacamoleTokenRequest {
|
||||
protocol: "rdp" | "vnc" | "telnet";
|
||||
hostname: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
domain?: string;
|
||||
security?: string;
|
||||
ignoreCert?: boolean;
|
||||
guacamoleConfig?: {
|
||||
colorDepth?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
dpi?: number;
|
||||
resizeMethod?: string;
|
||||
forceLossless?: boolean;
|
||||
disableAudio?: boolean;
|
||||
enableAudioInput?: boolean;
|
||||
enableWallpaper?: boolean;
|
||||
enableTheming?: boolean;
|
||||
enableFontSmoothing?: boolean;
|
||||
enableFullWindowDrag?: boolean;
|
||||
enableDesktopComposition?: boolean;
|
||||
enableMenuAnimations?: boolean;
|
||||
disableBitmapCaching?: boolean;
|
||||
disableOffscreenCaching?: boolean;
|
||||
disableGlyphCaching?: boolean;
|
||||
disableGfx?: boolean;
|
||||
enablePrinting?: boolean;
|
||||
printerName?: string;
|
||||
enableDrive?: boolean;
|
||||
driveName?: string;
|
||||
drivePath?: string;
|
||||
createDrivePath?: boolean;
|
||||
disableDownload?: boolean;
|
||||
disableUpload?: boolean;
|
||||
enableTouch?: boolean;
|
||||
clientName?: string;
|
||||
console?: boolean;
|
||||
initialProgram?: string;
|
||||
serverLayout?: string;
|
||||
timezone?: string;
|
||||
gatewayHostname?: string;
|
||||
gatewayPort?: number;
|
||||
gatewayUsername?: string;
|
||||
gatewayPassword?: string;
|
||||
gatewayDomain?: string;
|
||||
remoteApp?: string;
|
||||
remoteAppDir?: string;
|
||||
remoteAppArgs?: string;
|
||||
normalizeClipboard?: string;
|
||||
disableCopy?: boolean;
|
||||
disablePaste?: boolean;
|
||||
cursor?: string;
|
||||
swapRedBlue?: boolean;
|
||||
readOnly?: boolean;
|
||||
recordingPath?: string;
|
||||
recordingName?: string;
|
||||
createRecordingPath?: boolean;
|
||||
recordingExcludeOutput?: boolean;
|
||||
recordingExcludeMouse?: boolean;
|
||||
recordingIncludeKeys?: boolean;
|
||||
wolSendPacket?: boolean;
|
||||
wolMacAddr?: string;
|
||||
wolBroadcastAddr?: string;
|
||||
wolUdpPort?: number;
|
||||
wolWaitTime?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GuacamoleTokenResponse {
|
||||
token: string;
|
||||
}
|
||||
|
||||
type GuacamoleConfigSource = {
|
||||
guacamoleConfig?: string | Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export function getGuacamoleDpi(
|
||||
source?: GuacamoleConfigSource,
|
||||
): number | undefined {
|
||||
const config = source?.guacamoleConfig;
|
||||
if (!config) return undefined;
|
||||
|
||||
let dpi: unknown;
|
||||
if (typeof config === "string") {
|
||||
try {
|
||||
dpi = JSON.parse(config).dpi;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
} else {
|
||||
dpi = config.dpi;
|
||||
}
|
||||
|
||||
const parsedDpi = typeof dpi === "string" ? Number(dpi) : dpi;
|
||||
if (
|
||||
typeof parsedDpi !== "number" ||
|
||||
!Number.isFinite(parsedDpi) ||
|
||||
parsedDpi <= 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Math.trunc(parsedDpi);
|
||||
}
|
||||
|
||||
function toGuacamoleParams(
|
||||
config: GuacamoleTokenRequest["guacamoleConfig"],
|
||||
): Record<string, unknown> {
|
||||
if (!config) return {};
|
||||
|
||||
const params: Record<string, unknown> = {};
|
||||
|
||||
const mappings: Record<string, string> = {
|
||||
colorDepth: "color-depth",
|
||||
resizeMethod: "resize-method",
|
||||
forceLossless: "force-lossless",
|
||||
disableAudio: "disable-audio",
|
||||
enableAudioInput: "enable-audio-input",
|
||||
enableWallpaper: "enable-wallpaper",
|
||||
enableTheming: "enable-theming",
|
||||
enableFontSmoothing: "enable-font-smoothing",
|
||||
enableFullWindowDrag: "enable-full-window-drag",
|
||||
enableDesktopComposition: "enable-desktop-composition",
|
||||
enableMenuAnimations: "enable-menu-animations",
|
||||
disableBitmapCaching: "disable-bitmap-caching",
|
||||
disableOffscreenCaching: "disable-offscreen-caching",
|
||||
disableGlyphCaching: "disable-glyph-caching",
|
||||
disableGfx: "disable-gfx",
|
||||
enablePrinting: "enable-printing",
|
||||
printerName: "printer-name",
|
||||
enableDrive: "enable-drive",
|
||||
driveName: "drive-name",
|
||||
drivePath: "drive-path",
|
||||
createDrivePath: "create-drive-path",
|
||||
disableDownload: "disable-download",
|
||||
disableUpload: "disable-upload",
|
||||
enableTouch: "enable-touch",
|
||||
clientName: "client-name",
|
||||
initialProgram: "initial-program",
|
||||
serverLayout: "server-layout",
|
||||
gatewayHostname: "gateway-hostname",
|
||||
gatewayPort: "gateway-port",
|
||||
gatewayUsername: "gateway-username",
|
||||
gatewayPassword: "gateway-password",
|
||||
gatewayDomain: "gateway-domain",
|
||||
remoteApp: "remote-app",
|
||||
remoteAppDir: "remote-app-dir",
|
||||
remoteAppArgs: "remote-app-args",
|
||||
normalizeClipboard: "normalize-clipboard",
|
||||
disableCopy: "disable-copy",
|
||||
disablePaste: "disable-paste",
|
||||
swapRedBlue: "swap-red-blue",
|
||||
readOnly: "read-only",
|
||||
recordingPath: "recording-path",
|
||||
recordingName: "recording-name",
|
||||
createRecordingPath: "create-recording-path",
|
||||
recordingExcludeOutput: "recording-exclude-output",
|
||||
recordingExcludeMouse: "recording-exclude-mouse",
|
||||
recordingIncludeKeys: "recording-include-keys",
|
||||
wolSendPacket: "wol-send-packet",
|
||||
wolMacAddr: "wol-mac-addr",
|
||||
wolBroadcastAddr: "wol-broadcast-addr",
|
||||
wolUdpPort: "wol-udp-port",
|
||||
wolWaitTime: "wol-wait-time",
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
const paramName = mappings[key] || key;
|
||||
if (typeof value === "boolean") {
|
||||
params[paramName] = value ? "true" : "false";
|
||||
} else {
|
||||
params[paramName] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
export async function getGuacamoleToken(
|
||||
request: GuacamoleTokenRequest,
|
||||
): Promise<GuacamoleTokenResponse> {
|
||||
try {
|
||||
const guacParams = toGuacamoleParams(request.guacamoleConfig);
|
||||
|
||||
const response = await authApi.post("/guacamole/token", {
|
||||
type: request.protocol,
|
||||
hostname: request.hostname,
|
||||
port: request.port,
|
||||
username: request.username,
|
||||
password: request.password,
|
||||
domain: request.domain,
|
||||
security: request.security,
|
||||
"ignore-cert": request.ignoreCert,
|
||||
...guacParams,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get guacamole token");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGuacamoleTokenFromHost(
|
||||
hostId: number,
|
||||
protocol?: "rdp" | "vnc" | "telnet",
|
||||
): Promise<GuacamoleTokenResponse> {
|
||||
try {
|
||||
const response = await authApi.post(
|
||||
`/guacamole/connect-host/${hostId}`,
|
||||
protocol ? { protocol } : {},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get guacamole token from host");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGuacdStatus(): Promise<{
|
||||
guacd: { status: string };
|
||||
}> {
|
||||
const response = await authApi.get("/guacamole/status");
|
||||
return response.data;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
|
||||
// OIDC ACCOUNT LINKING
|
||||
// ============================================================================
|
||||
|
||||
export async function linkOIDCToPasswordAccount(
|
||||
oidcUserId: string,
|
||||
targetUsername: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await authApi.post("/users/link-oidc-to-password", {
|
||||
oidcUserId,
|
||||
targetUsername,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "link OIDC account to password account");
|
||||
}
|
||||
}
|
||||
|
||||
export async function unlinkOIDCFromPasswordAccount(
|
||||
userId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await authApi.post("/users/unlink-oidc-from-password", {
|
||||
userId,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "unlink OIDC from password account");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { authApi } from "@/main-axios";
|
||||
|
||||
// OPEN TABS API
|
||||
// ============================================================================
|
||||
|
||||
export interface OpenTabRecord {
|
||||
id: string;
|
||||
userId: string;
|
||||
tabType: string;
|
||||
hostId: number | null;
|
||||
label: string;
|
||||
tabOrder: number;
|
||||
backendSessionId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface OpenTabSyncPayload {
|
||||
id: string;
|
||||
tabType: string;
|
||||
hostId?: number | null;
|
||||
label: string;
|
||||
tabOrder: number;
|
||||
backendSessionId?: string | null;
|
||||
}
|
||||
|
||||
export interface OpenTabUpsertPayload {
|
||||
id: string;
|
||||
tabType: string;
|
||||
hostId?: number | null;
|
||||
label: string;
|
||||
tabOrder: number;
|
||||
backendSessionId?: string | null;
|
||||
}
|
||||
|
||||
export interface ActiveSessionInfo {
|
||||
sessionId: string;
|
||||
hostId: number;
|
||||
hostName: string;
|
||||
tabInstanceId: string | null;
|
||||
isConnected: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export async function getOpenTabs(): Promise<OpenTabRecord[]> {
|
||||
const response = await authApi.get("/open-tabs");
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function syncOpenTabs(tabs: OpenTabSyncPayload[]): Promise<void> {
|
||||
await authApi.put("/open-tabs", { tabs });
|
||||
}
|
||||
|
||||
export async function deleteOpenTab(instanceId: string): Promise<void> {
|
||||
await authApi.delete(`/open-tabs/${instanceId}`);
|
||||
}
|
||||
|
||||
export async function patchOpenTab(
|
||||
instanceId: string,
|
||||
updates: Partial<
|
||||
Pick<OpenTabRecord, "label" | "tabOrder" | "backendSessionId">
|
||||
>,
|
||||
): Promise<void> {
|
||||
await authApi.patch(`/open-tabs/${instanceId}`, updates);
|
||||
}
|
||||
|
||||
export async function addOpenTab(tab: OpenTabUpsertPayload): Promise<void> {
|
||||
await authApi.post("/open-tabs", tab);
|
||||
}
|
||||
|
||||
export async function getActiveSessions(): Promise<ActiveSessionInfo[]> {
|
||||
const response = await authApi.get("/open-tabs/active-sessions");
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// USER PREFERENCES API
|
||||
// ============================================================================
|
||||
|
||||
export interface UserPreferences {
|
||||
reopenTabsOnLogin: boolean;
|
||||
theme?: string | null;
|
||||
fontSize?: string | null;
|
||||
accentColor?: string | null;
|
||||
language?: string | null;
|
||||
}
|
||||
|
||||
export async function getUserPreferences(): Promise<UserPreferences> {
|
||||
const response = await authApi.get("/user-preferences");
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function saveUserPreferences(
|
||||
prefs: Partial<UserPreferences>,
|
||||
): Promise<void> {
|
||||
await authApi.put("/user-preferences", prefs);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { handleApiError, rbacApi } from "@/main-axios";
|
||||
import type { AccessRecord, Role, UserRole } from "@/main-axios";
|
||||
|
||||
export async function getRoles(): Promise<{ roles: Role[] }> {
|
||||
try {
|
||||
const response = await rbacApi.get("/rbac/roles");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch roles");
|
||||
}
|
||||
}
|
||||
|
||||
export async function createRole(roleData: {
|
||||
name: string;
|
||||
displayName: string;
|
||||
description?: string | null;
|
||||
}): Promise<{ role: Role }> {
|
||||
try {
|
||||
const response = await rbacApi.post("/rbac/roles", roleData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "create role");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateRole(
|
||||
roleId: number,
|
||||
roleData: {
|
||||
displayName?: string;
|
||||
description?: string | null;
|
||||
},
|
||||
): Promise<{ role: Role }> {
|
||||
try {
|
||||
const response = await rbacApi.put(`/rbac/roles/${roleId}`, roleData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update role");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteRole(
|
||||
roleId: number,
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await rbacApi.delete(`/rbac/roles/${roleId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "delete role");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserRoles(
|
||||
userId: string,
|
||||
): Promise<{ roles: UserRole[] }> {
|
||||
try {
|
||||
const response = await rbacApi.get(`/rbac/users/${userId}/roles`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch user roles");
|
||||
}
|
||||
}
|
||||
|
||||
export async function assignRoleToUser(
|
||||
userId: string,
|
||||
roleId: number,
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await rbacApi.post(`/rbac/users/${userId}/roles`, {
|
||||
roleId,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "assign role to user");
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeRoleFromUser(
|
||||
userId: string,
|
||||
roleId: number,
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await rbacApi.delete(
|
||||
`/rbac/users/${userId}/roles/${roleId}`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "remove role from user");
|
||||
}
|
||||
}
|
||||
|
||||
export async function shareHost(
|
||||
hostId: number,
|
||||
shareData: {
|
||||
targetType: "user" | "role";
|
||||
targetUserId?: string;
|
||||
targetRoleId?: number;
|
||||
permissionLevel: "view";
|
||||
durationHours?: number;
|
||||
},
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await rbacApi.post(
|
||||
`/rbac/host/${hostId}/share`,
|
||||
shareData,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "share host");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getHostAccess(
|
||||
hostId: number,
|
||||
): Promise<{ accessList: AccessRecord[] }> {
|
||||
try {
|
||||
const response = await rbacApi.get(`/rbac/host/${hostId}/access`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch host access");
|
||||
}
|
||||
}
|
||||
|
||||
export async function revokeHostAccess(
|
||||
hostId: number,
|
||||
accessId: number,
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await rbacApi.delete(
|
||||
`/rbac/host/${hostId}/access/${accessId}`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "revoke host access");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SNIPPET SHARING
|
||||
// ============================================================================
|
||||
|
||||
export async function shareSnippet(
|
||||
snippetId: number,
|
||||
shareData: {
|
||||
targetType: "user" | "role";
|
||||
targetUserId?: string;
|
||||
targetRoleId?: number;
|
||||
durationHours?: number;
|
||||
},
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await rbacApi.post(
|
||||
`/rbac/snippet/${snippetId}/share`,
|
||||
shareData,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "share snippet");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSnippetAccess(
|
||||
snippetId: number,
|
||||
): Promise<{ accessList: AccessRecord[] }> {
|
||||
try {
|
||||
const response = await rbacApi.get(`/rbac/snippet/${snippetId}/access`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch snippet access");
|
||||
}
|
||||
}
|
||||
|
||||
export async function revokeSnippetAccess(
|
||||
snippetId: number,
|
||||
accessId: number,
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await rbacApi.delete(
|
||||
`/rbac/snippet/${snippetId}/access/${accessId}`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "revoke snippet access");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSharedSnippets(): Promise<{
|
||||
sharedSnippets: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
content: string;
|
||||
description: string | null;
|
||||
folder: string | null;
|
||||
ownerUsername: string;
|
||||
permissionLevel: string;
|
||||
expiresAt: string | null;
|
||||
}>;
|
||||
}> {
|
||||
try {
|
||||
const response = await rbacApi.get("/rbac/shared-snippets");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch shared snippets");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import axios, { type AxiosRequestConfig } from "axios";
|
||||
import { handleApiError, statsApi } from "@/main-axios";
|
||||
import type { ServerMetrics, ServerStatus } from "@/main-axios";
|
||||
|
||||
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
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 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>
|
||||
> {
|
||||
let lastError: unknown = null;
|
||||
|
||||
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,
|
||||
// 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 });
|
||||
return response.data || {};
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!isTransientStatusError(error)) {
|
||||
break;
|
||||
}
|
||||
if (pauseAfterMs === null) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, pauseAfterMs));
|
||||
}
|
||||
}
|
||||
|
||||
handleApiError(lastError, "fetch server statuses");
|
||||
}
|
||||
|
||||
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> {
|
||||
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");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
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}`);
|
||||
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<void> {
|
||||
try {
|
||||
await statsApi.post("/metrics/heartbeat", { viewerSessionId });
|
||||
} catch (error) {
|
||||
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,
|
||||
});
|
||||
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,
|
||||
});
|
||||
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 });
|
||||
} catch (error) {
|
||||
console.warn("Failed to notify stats server of host update:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -0,0 +1,100 @@
|
||||
import { authApi, handleApiError, statsApi } 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
|
||||
// ============================================================================
|
||||
|
||||
export async function getLogLevel(): Promise<{ level: string }> {
|
||||
try {
|
||||
const response = await authApi.get("/users/log-level");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch log level");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateLogLevel(level: string): Promise<void> {
|
||||
try {
|
||||
await authApi.patch("/users/log-level", { level });
|
||||
} catch (error) {
|
||||
handleApiError(error, "update log level");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SESSION TIMEOUT SETTINGS
|
||||
// ============================================================================
|
||||
|
||||
export async function getSessionTimeout(): Promise<{ timeoutHours: number }> {
|
||||
try {
|
||||
const response = await authApi.get("/users/session-timeout");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch session timeout");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSessionTimeout(
|
||||
timeoutHours: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await authApi.patch("/users/session-timeout", { timeoutHours });
|
||||
} catch (error) {
|
||||
handleApiError(error, "update session timeout");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GUACAMOLE SETTINGS
|
||||
// ============================================================================
|
||||
|
||||
export async function getGuacamoleSettings(): Promise<{
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
}> {
|
||||
try {
|
||||
const response = await authApi.get("/users/guacamole-settings");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch guacamole settings");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateGuacamoleSettings(settings: {
|
||||
enabled?: boolean;
|
||||
url?: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await authApi.patch("/users/guacamole-settings", settings);
|
||||
} catch (error) {
|
||||
handleApiError(error, "update guacamole settings");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -0,0 +1,183 @@
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
|
||||
export interface NetworkTopologyNode {
|
||||
data: {
|
||||
id: string;
|
||||
label?: string;
|
||||
ip?: string;
|
||||
status?: string;
|
||||
tags?: string[];
|
||||
parent?: string;
|
||||
color?: string;
|
||||
};
|
||||
position?: { x: number; y: number };
|
||||
}
|
||||
|
||||
export interface NetworkTopologyEdge {
|
||||
data: {
|
||||
id?: string;
|
||||
source: string;
|
||||
target: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NetworkTopologyData {
|
||||
nodes: NetworkTopologyNode[];
|
||||
edges: NetworkTopologyEdge[];
|
||||
}
|
||||
|
||||
export async function getSnippets(): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.get("/snippets");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch snippets");
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSnippet(
|
||||
snippetData: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.post("/snippets", snippetData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "create snippet");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSnippet(
|
||||
snippetId: number,
|
||||
snippetData: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.put(`/snippets/${snippetId}`, snippetData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update snippet");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSnippet(
|
||||
snippetId: number,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.delete(`/snippets/${snippetId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "delete snippet");
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeSnippet(
|
||||
snippetId: number,
|
||||
hostId: number,
|
||||
): Promise<{ success: boolean; output: string; error?: string }> {
|
||||
try {
|
||||
const response = await authApi.post("/snippets/execute", {
|
||||
snippetId,
|
||||
hostId,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "execute snippet");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getNetworkTopology(): Promise<NetworkTopologyData | null> {
|
||||
try {
|
||||
const response = await authApi.get("/network-topology/");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch network topology");
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveNetworkTopology(
|
||||
topology: NetworkTopologyData,
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await authApi.post("/network-topology/", { topology });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "save network topology");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSnippetFolders(): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.get("/snippets/folders");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch snippet folders");
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSnippetFolder(folderData: {
|
||||
name: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.post("/snippets/folders", folderData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "create snippet folder");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSnippetFolderMetadata(
|
||||
folderName: string,
|
||||
metadata: { color?: string; icon?: string },
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.put(
|
||||
`/snippets/folders/${encodeURIComponent(folderName)}/metadata`,
|
||||
metadata,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update snippet folder metadata");
|
||||
}
|
||||
}
|
||||
|
||||
export async function renameSnippetFolder(
|
||||
oldName: string,
|
||||
newName: string,
|
||||
): Promise<{ success: boolean; oldName: string; newName: string }> {
|
||||
try {
|
||||
const response = await authApi.put("/snippets/folders/rename", {
|
||||
oldName,
|
||||
newName,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "rename snippet folder");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSnippetFolder(
|
||||
folderName: string,
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await authApi.delete(
|
||||
`/snippets/folders/${encodeURIComponent(folderName)}`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "delete snippet folder");
|
||||
}
|
||||
}
|
||||
|
||||
export async function reorderSnippets(
|
||||
updates: Array<{ id: number; order: number; folder?: string }>,
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await authApi.put("/snippets/reorder", {
|
||||
snippets: updates,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "reorder snippets");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,794 @@
|
||||
import axios from "axios";
|
||||
import { authApi, fileManagerApi, handleApiError } from "@/main-axios";
|
||||
import { fileLogger } from "@/lib/frontend-logger";
|
||||
import type { SSHHost } from "@/types/index";
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
// SSH FILE OPERATIONS
|
||||
// ============================================================================
|
||||
|
||||
export async function connectSSH(
|
||||
sessionId: string,
|
||||
config: {
|
||||
hostId?: number;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
sshKey?: string;
|
||||
keyPassword?: string;
|
||||
authType?: string;
|
||||
credentialId?: number;
|
||||
userId?: string;
|
||||
forceKeyboardInteractive?: boolean;
|
||||
useSocks5?: boolean;
|
||||
socks5Host?: string;
|
||||
socks5Port?: number;
|
||||
socks5Username?: string;
|
||||
socks5Password?: string;
|
||||
socks5ProxyChain?: unknown;
|
||||
jumpHosts?: Array<{ hostId: number }>;
|
||||
},
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/ssh/connect", {
|
||||
sessionId,
|
||||
...config,
|
||||
});
|
||||
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,
|
||||
});
|
||||
if (data.requires_totp) {
|
||||
Object.assign(errorWithLogs, {
|
||||
requires_totp: true,
|
||||
sessionId: data.sessionId,
|
||||
prompt: data.prompt,
|
||||
});
|
||||
}
|
||||
if (data.requires_warpgate) {
|
||||
Object.assign(errorWithLogs, {
|
||||
requires_warpgate: true,
|
||||
sessionId: data.sessionId,
|
||||
url: data.url,
|
||||
securityKey: data.securityKey,
|
||||
});
|
||||
}
|
||||
if (data.status === "auth_required") {
|
||||
Object.assign(errorWithLogs, {
|
||||
status: "auth_required",
|
||||
reason: data.reason,
|
||||
});
|
||||
}
|
||||
throw errorWithLogs;
|
||||
}
|
||||
handleApiError(error, "connect SSH");
|
||||
}
|
||||
}
|
||||
|
||||
export async function disconnectSSH(
|
||||
sessionId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/ssh/disconnect", {
|
||||
sessionId,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "disconnect SSH");
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifySSHTOTP(
|
||||
sessionId: string,
|
||||
totpCode: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/ssh/connect-totp", {
|
||||
sessionId,
|
||||
totpCode,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "verify SSH TOTP");
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifySSHWarpgate(
|
||||
sessionId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/ssh/connect-warpgate", {
|
||||
sessionId,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "verify SSH Warpgate");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ssh/quick-connect:
|
||||
* post:
|
||||
* summary: Create a temporary SSH connection without saving to database
|
||||
* description: Returns a temporary host configuration for immediate use
|
||||
* tags:
|
||||
* - SSH
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - ip
|
||||
* - port
|
||||
* - username
|
||||
* - authType
|
||||
* properties:
|
||||
* ip:
|
||||
* type: string
|
||||
* description: SSH server IP or hostname
|
||||
* port:
|
||||
* type: number
|
||||
* description: SSH server port
|
||||
* username:
|
||||
* type: string
|
||||
* description: SSH username
|
||||
* authType:
|
||||
* type: string
|
||||
* enum: [password, key, credential]
|
||||
* description: Authentication method
|
||||
* password:
|
||||
* type: string
|
||||
* description: Password (required if authType is password)
|
||||
* key:
|
||||
* type: string
|
||||
* description: SSH private key (required if authType is key)
|
||||
* keyPassword:
|
||||
* type: string
|
||||
* description: SSH key password (optional)
|
||||
* keyType:
|
||||
* type: string
|
||||
* description: SSH key type
|
||||
* credentialId:
|
||||
* type: number
|
||||
* description: Credential ID (required if authType is credential)
|
||||
* overrideCredentialUsername:
|
||||
* type: boolean
|
||||
* description: Use provided username instead of credential username
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Temporary host configuration created successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* description: SSHHost object
|
||||
* 400:
|
||||
* description: Invalid request data
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 500:
|
||||
* description: Server error
|
||||
*/
|
||||
export async function quickConnect(
|
||||
data: Record<string, unknown>,
|
||||
): Promise<SSHHost> {
|
||||
try {
|
||||
const response = await authApi.post("/host/quick-connect", data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "quick connect");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSSHStatus(
|
||||
sessionId: string,
|
||||
): Promise<{ connected: boolean }> {
|
||||
try {
|
||||
const response = await fileManagerApi.get("/ssh/status", {
|
||||
params: { sessionId },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "get SSH status");
|
||||
}
|
||||
}
|
||||
|
||||
export async function keepSSHAlive(
|
||||
sessionId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/ssh/keepalive", {
|
||||
sessionId,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "SSH keepalive");
|
||||
}
|
||||
}
|
||||
|
||||
export async function listSSHFiles(
|
||||
sessionId: string,
|
||||
path: string,
|
||||
): Promise<{ files: unknown[]; path: string }> {
|
||||
try {
|
||||
const response = await fileManagerApi.get("/ssh/listFiles", {
|
||||
params: { sessionId, path },
|
||||
});
|
||||
return response.data || { files: [], path };
|
||||
} catch (error) {
|
||||
handleApiError(error, "list SSH files");
|
||||
return { files: [], path };
|
||||
}
|
||||
}
|
||||
|
||||
export async function identifySSHSymlink(
|
||||
sessionId: string,
|
||||
path: string,
|
||||
): Promise<{ path: string; target: string; type: "directory" | "file" }> {
|
||||
try {
|
||||
const response = await fileManagerApi.get("/ssh/identifySymlink", {
|
||||
params: { sessionId, path },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "identify SSH symlink");
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveSSHPath(
|
||||
sessionId: string,
|
||||
path: string,
|
||||
): Promise<string> {
|
||||
try {
|
||||
const response = await fileManagerApi.get("/ssh/resolvePath", {
|
||||
params: { sessionId, path },
|
||||
});
|
||||
return response.data?.resolvedPath || path;
|
||||
} catch {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readSSHFile(
|
||||
sessionId: string,
|
||||
path: string,
|
||||
): Promise<{
|
||||
content: string;
|
||||
path: string;
|
||||
encoding?: "base64" | "utf8";
|
||||
}> {
|
||||
try {
|
||||
const response = await fileManagerApi.get("/ssh/readFile", {
|
||||
params: { sessionId, path },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: unknown) {
|
||||
if (error.response?.status === 404) {
|
||||
const customError = new Error("File not found");
|
||||
(
|
||||
customError as Error & { response?: unknown; isFileNotFound?: boolean }
|
||||
).response = error.response;
|
||||
(
|
||||
customError as Error & { response?: unknown; isFileNotFound?: boolean }
|
||||
).isFileNotFound = error.response.data?.fileNotFound || true;
|
||||
throw customError;
|
||||
}
|
||||
handleApiError(error, "read SSH file");
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeSSHFile(
|
||||
sessionId: string,
|
||||
path: string,
|
||||
content: string,
|
||||
hostId?: number,
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/ssh/writeFile", {
|
||||
sessionId,
|
||||
path,
|
||||
content,
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
|
||||
if (
|
||||
response.data &&
|
||||
(response.data.message === "File written successfully" ||
|
||||
response.status === 200)
|
||||
) {
|
||||
return response.data;
|
||||
} else {
|
||||
throw new Error("File write operation did not return success status");
|
||||
}
|
||||
} catch (error) {
|
||||
handleApiError(error, "write SSH file");
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadSSHFile(
|
||||
sessionId: string,
|
||||
path: string,
|
||||
fileName: string,
|
||||
content: string,
|
||||
hostId?: number,
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/ssh/uploadFile", {
|
||||
sessionId,
|
||||
path,
|
||||
fileName,
|
||||
content,
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "upload SSH file");
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadSSHFile(
|
||||
sessionId: string,
|
||||
filePath: string,
|
||||
hostId?: number,
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/ssh/downloadFile", {
|
||||
sessionId,
|
||||
path: filePath,
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "download SSH file");
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadSSHFileStream(
|
||||
sessionId: string,
|
||||
filePath: string,
|
||||
): Promise<void> {
|
||||
const response = await fileManagerApi.post(
|
||||
"/ssh/downloadFileStream",
|
||||
{ sessionId, path: filePath },
|
||||
{ responseType: "blob" },
|
||||
);
|
||||
const blob = response.data as Blob;
|
||||
const fileName = filePath.split("/").pop() || "download";
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export async function createSSHFile(
|
||||
sessionId: string,
|
||||
path: string,
|
||||
fileName: string,
|
||||
content: string = "",
|
||||
hostId?: number,
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/ssh/createFile", {
|
||||
sessionId,
|
||||
path,
|
||||
fileName,
|
||||
content,
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "create SSH file");
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSSHFolder(
|
||||
sessionId: string,
|
||||
path: string,
|
||||
folderName: string,
|
||||
hostId?: number,
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/ssh/createFolder", {
|
||||
sessionId,
|
||||
path,
|
||||
folderName,
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "create SSH folder");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSSHItem(
|
||||
sessionId: string,
|
||||
path: string,
|
||||
isDirectory: boolean,
|
||||
hostId?: number,
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.delete("/ssh/deleteItem", {
|
||||
data: {
|
||||
sessionId,
|
||||
path,
|
||||
isDirectory,
|
||||
hostId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "delete SSH item");
|
||||
}
|
||||
}
|
||||
|
||||
export async function setSudoPassword(
|
||||
sessionId: string,
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await fileManagerApi.post("/sudo-password", {
|
||||
sessionId,
|
||||
password,
|
||||
});
|
||||
} catch (error) {
|
||||
handleApiError(error, "set sudo password");
|
||||
}
|
||||
}
|
||||
|
||||
export async function copySSHItem(
|
||||
sessionId: string,
|
||||
sourcePath: string,
|
||||
targetDir: string,
|
||||
hostId?: number,
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post(
|
||||
"/ssh/copyItem",
|
||||
{
|
||||
sessionId,
|
||||
sourcePath,
|
||||
targetDir,
|
||||
hostId,
|
||||
userId,
|
||||
},
|
||||
{
|
||||
timeout: 60000,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "copy SSH item");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function renameSSHItem(
|
||||
sessionId: string,
|
||||
oldPath: string,
|
||||
newName: string,
|
||||
hostId?: number,
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.put("/ssh/renameItem", {
|
||||
sessionId,
|
||||
oldPath,
|
||||
newName,
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "rename SSH item");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function moveSSHItem(
|
||||
sessionId: string,
|
||||
oldPath: string,
|
||||
newPath: string,
|
||||
hostId?: number,
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.put(
|
||||
"/ssh/moveItem",
|
||||
{
|
||||
sessionId,
|
||||
oldPath,
|
||||
newPath,
|
||||
hostId,
|
||||
userId,
|
||||
},
|
||||
{
|
||||
timeout: 60000,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "move SSH item");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function changeSSHPermissions(
|
||||
sessionId: string,
|
||||
path: string,
|
||||
permissions: string,
|
||||
hostId?: number,
|
||||
userId?: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
fileLogger.info("Changing SSH file permissions", {
|
||||
operation: "change_permissions",
|
||||
sessionId,
|
||||
path,
|
||||
permissions,
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
|
||||
const response = await fileManagerApi.post("/ssh/changePermissions", {
|
||||
sessionId,
|
||||
path,
|
||||
permissions,
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
|
||||
fileLogger.success("SSH file permissions changed successfully", {
|
||||
operation: "change_permissions",
|
||||
sessionId,
|
||||
path,
|
||||
permissions,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
fileLogger.error("Failed to change SSH file permissions", error, {
|
||||
operation: "change_permissions",
|
||||
sessionId,
|
||||
path,
|
||||
permissions,
|
||||
});
|
||||
handleApiError(error, "change SSH permissions");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function extractSSHArchive(
|
||||
sessionId: string,
|
||||
archivePath: string,
|
||||
extractPath?: string,
|
||||
hostId?: number,
|
||||
userId?: string,
|
||||
): Promise<{ success: boolean; message: string; extractPath: string }> {
|
||||
try {
|
||||
fileLogger.info("Extracting archive", {
|
||||
operation: "extract_archive",
|
||||
sessionId,
|
||||
archivePath,
|
||||
extractPath,
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
|
||||
const response = await fileManagerApi.post("/ssh/extractArchive", {
|
||||
sessionId,
|
||||
archivePath,
|
||||
extractPath,
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
|
||||
fileLogger.success("Archive extracted successfully", {
|
||||
operation: "extract_archive",
|
||||
sessionId,
|
||||
archivePath,
|
||||
extractPath: response.data.extractPath,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
fileLogger.error("Failed to extract archive", error, {
|
||||
operation: "extract_archive",
|
||||
sessionId,
|
||||
archivePath,
|
||||
extractPath,
|
||||
});
|
||||
handleApiError(error, "extract archive");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function compressSSHFiles(
|
||||
sessionId: string,
|
||||
paths: string[],
|
||||
archiveName: string,
|
||||
format?: string,
|
||||
hostId?: number,
|
||||
userId?: string,
|
||||
): Promise<{ success: boolean; message: string; archivePath: string }> {
|
||||
try {
|
||||
fileLogger.info("Compressing files", {
|
||||
operation: "compress_files",
|
||||
sessionId,
|
||||
paths,
|
||||
archiveName,
|
||||
format,
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
|
||||
const response = await fileManagerApi.post("/ssh/compressFiles", {
|
||||
sessionId,
|
||||
paths,
|
||||
archiveName,
|
||||
format: format || "zip",
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
|
||||
fileLogger.success("Files compressed successfully", {
|
||||
operation: "compress_files",
|
||||
sessionId,
|
||||
paths,
|
||||
archivePath: response.data.archivePath,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
fileLogger.error("Failed to compress files", error, {
|
||||
operation: "compress_files",
|
||||
sessionId,
|
||||
paths,
|
||||
archiveName,
|
||||
format,
|
||||
});
|
||||
handleApiError(error, "compress files");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
export type HostConnectionState =
|
||||
| "disconnected"
|
||||
| "connecting"
|
||||
| "ready"
|
||||
| "auth_required"
|
||||
| "error";
|
||||
|
||||
export interface EnsureSSHSessionResult {
|
||||
state: HostConnectionState;
|
||||
sessionId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function ensureSSHSessionForHost(
|
||||
host: SSHHost,
|
||||
): Promise<EnsureSSHSessionResult> {
|
||||
const sessionId = host.id.toString();
|
||||
try {
|
||||
const status = await getSSHStatus(sessionId);
|
||||
if (status?.connected) {
|
||||
return { state: "ready", sessionId };
|
||||
}
|
||||
} catch {
|
||||
// not connected — fall through to connect
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await connectSSH(sessionId, {
|
||||
hostId: host.id,
|
||||
ip: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
password: host.password,
|
||||
sshKey: host.key,
|
||||
keyPassword: host.keyPassword,
|
||||
authType: host.authType,
|
||||
credentialId: host.credentialId,
|
||||
userId: host.userId,
|
||||
forceKeyboardInteractive: host.forceKeyboardInteractive,
|
||||
jumpHosts: host.jumpHosts,
|
||||
useSocks5: host.useSocks5,
|
||||
socks5Host: host.socks5Host,
|
||||
socks5Port: host.socks5Port,
|
||||
socks5Username: host.socks5Username,
|
||||
socks5Password: host.socks5Password,
|
||||
socks5ProxyChain: host.socks5ProxyChain,
|
||||
});
|
||||
|
||||
if (
|
||||
result?.requires_totp ||
|
||||
result?.requires_warpgate ||
|
||||
result?.status === "auth_required"
|
||||
) {
|
||||
return { state: "auth_required", sessionId };
|
||||
}
|
||||
|
||||
return { state: "ready", sessionId };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Connection failed";
|
||||
return { state: "error", error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export interface BrowseSSHDirectoryResult {
|
||||
status: "ok" | "not_found" | "error";
|
||||
path: string;
|
||||
files: Array<{ name: string; type: "file" | "directory" | "link" }>;
|
||||
}
|
||||
|
||||
export async function browseSSHDirectory(
|
||||
sessionId: string,
|
||||
path: string,
|
||||
): Promise<BrowseSSHDirectoryResult> {
|
||||
try {
|
||||
const result = await listSSHFiles(sessionId, path);
|
||||
return {
|
||||
status: "ok",
|
||||
path: result.path,
|
||||
files: result.files as Array<{
|
||||
name: string;
|
||||
type: "file" | "directory" | "link";
|
||||
}>,
|
||||
};
|
||||
} catch (err) {
|
||||
const status =
|
||||
(err as { response?: { status?: number } })?.response?.status === 404
|
||||
? "not_found"
|
||||
: "error";
|
||||
return { status, path, files: [] };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { AxiosError } from "axios";
|
||||
import { getAllServerStatuses, handleApiError, sshHostApi } from "@/main-axios";
|
||||
import type { SSHHost, SSHHostData, ProxyNode } from "@/types/index";
|
||||
import type { ServerStatus, SSHHostWithStatus } from "@/main-axios";
|
||||
|
||||
// SSH HOST MANAGEMENT
|
||||
// ============================================================================
|
||||
|
||||
export async function getSSHHosts(): Promise<SSHHostWithStatus[]> {
|
||||
try {
|
||||
const hostsResponse = await sshHostApi.get("/db/host");
|
||||
const hosts: SSHHost[] = Array.isArray(hostsResponse.data)
|
||||
? hostsResponse.data
|
||||
: [];
|
||||
|
||||
let statuses: Record<number, ServerStatus> = {};
|
||||
try {
|
||||
statuses = (await getAllServerStatuses()) || {};
|
||||
} catch {
|
||||
// Status fetch failure should not prevent host list from loading
|
||||
}
|
||||
|
||||
return hosts.map((host) => ({
|
||||
...host,
|
||||
status: statuses[host.id]?.status || "unknown",
|
||||
}));
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch SSH hosts");
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
|
||||
try {
|
||||
if (hostData.authType === "key" && hostData.key instanceof File) {
|
||||
const formData = new FormData();
|
||||
formData.append("key", hostData.key);
|
||||
const dataWithoutFile = { ...hostData, key: undefined };
|
||||
formData.append("data", JSON.stringify(dataWithoutFile));
|
||||
const response = await sshHostApi.post("/db/host", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
const response = await sshHostApi.post("/db/host", hostData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "create SSH host");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSSHHost(
|
||||
hostId: number,
|
||||
hostData: SSHHostData,
|
||||
): Promise<SSHHost> {
|
||||
try {
|
||||
if (hostData.authType === "key" && hostData.key instanceof File) {
|
||||
const formData = new FormData();
|
||||
formData.append("key", hostData.key);
|
||||
const dataWithoutFile = { ...hostData, key: undefined };
|
||||
formData.append("data", JSON.stringify(dataWithoutFile));
|
||||
const response = await sshHostApi.put(`/db/host/${hostId}`, formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
const response = await sshHostApi.put(`/db/host/${hostId}`, hostData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update SSH host");
|
||||
}
|
||||
}
|
||||
|
||||
export async function wakeOnLan(hostId: number): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await sshHostApi.post(`/db/host/${hostId}/wake`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "wake on LAN");
|
||||
}
|
||||
}
|
||||
|
||||
export async function bulkImportSSHHosts(
|
||||
hosts: SSHHostData[],
|
||||
overwrite = false,
|
||||
): Promise<{
|
||||
message: string;
|
||||
success: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
errors: string[];
|
||||
}> {
|
||||
try {
|
||||
const response = await sshHostApi.post("/bulk-import", {
|
||||
hosts,
|
||||
overwrite,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "bulk import SSH hosts");
|
||||
}
|
||||
}
|
||||
|
||||
export async function bulkUpdateSSHHosts(
|
||||
hostIds: number[],
|
||||
updates: Record<string, unknown>,
|
||||
): Promise<{ updated: number; failed: number; errors: string[] }> {
|
||||
try {
|
||||
const response = await sshHostApi.patch("/bulk-update", {
|
||||
hostIds,
|
||||
updates,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "bulk update SSH hosts");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSSHHost(
|
||||
hostId: number,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await sshHostApi.delete(`/db/host/${hostId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "delete SSH host");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSSHHostById(hostId: number): Promise<SSHHost> {
|
||||
try {
|
||||
const response = await sshHostApi.get(`/db/host/${hostId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch SSH host");
|
||||
}
|
||||
}
|
||||
|
||||
export async function exportSSHHostWithCredentials(
|
||||
hostId: number,
|
||||
): Promise<SSHHost> {
|
||||
try {
|
||||
const response = await sshHostApi.get(`/db/host/${hostId}/export`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "export SSH host with credentials");
|
||||
}
|
||||
}
|
||||
|
||||
export async function exportAllSSHHosts(): Promise<{
|
||||
hosts: SSHHost[];
|
||||
}> {
|
||||
try {
|
||||
const response = await sshHostApi.get("/db/hosts/export");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "export all SSH hosts");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SSH AUTOSTART MANAGEMENT
|
||||
// ============================================================================
|
||||
|
||||
export async function enableAutoStart(
|
||||
sshConfigId: number,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await sshHostApi.post("/autostart/enable", {
|
||||
sshConfigId,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "enable autostart");
|
||||
}
|
||||
}
|
||||
|
||||
export async function disableAutoStart(
|
||||
sshConfigId: number,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await sshHostApi.delete("/autostart/disable", {
|
||||
data: { sshConfigId },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "disable autostart");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAutoStartStatus(): Promise<{
|
||||
autostart_configs: Array<{
|
||||
sshConfigId: number;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
authType: string;
|
||||
}>;
|
||||
total_count: number;
|
||||
}> {
|
||||
try {
|
||||
const response = await sshHostApi.get("/autostart/status");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch autostart status");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PROXY CONNECTIVITY TEST
|
||||
// ============================================================================
|
||||
|
||||
export async function testProxyConnection(options: {
|
||||
singleProxy?: {
|
||||
host: string;
|
||||
port: number;
|
||||
type?: 4 | 5 | "http";
|
||||
username?: string;
|
||||
password?: string;
|
||||
};
|
||||
proxyChain?: ProxyNode[];
|
||||
testTarget?: { host: string; port: number };
|
||||
}): Promise<{ success: boolean; latencyMs?: number; error?: string }> {
|
||||
try {
|
||||
const response = await sshHostApi.post("/db/proxy/test", options);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError && error.response?.data?.error) {
|
||||
return { success: false, error: error.response.data.error };
|
||||
}
|
||||
handleApiError(error, "test proxy connection");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -0,0 +1,177 @@
|
||||
import { AxiosError } from "axios";
|
||||
import {
|
||||
authApi,
|
||||
handleApiError,
|
||||
isElectron,
|
||||
markUserAuthenticated,
|
||||
} from "@/main-axios";
|
||||
import type { AuthResponse } from "@/main-axios";
|
||||
|
||||
// ALERTS
|
||||
// ============================================================================
|
||||
|
||||
export async function setupTOTP(): Promise<{
|
||||
secret: string;
|
||||
qr_code: string;
|
||||
}> {
|
||||
try {
|
||||
const response = await authApi.post("/users/totp/setup");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error as AxiosError, "setup TOTP");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function enableTOTP(
|
||||
totp_code: string,
|
||||
): Promise<{ message: string; backup_codes: string[] }> {
|
||||
try {
|
||||
const response = await authApi.post("/users/totp/enable", { totp_code });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error as AxiosError, "enable TOTP");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function disableTOTP(
|
||||
password?: string,
|
||||
totp_code?: string,
|
||||
): Promise<{ message: string }> {
|
||||
try {
|
||||
const response = await authApi.post("/users/totp/disable", {
|
||||
password,
|
||||
totp_code,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error as AxiosError, "disable TOTP");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyTOTPLogin(
|
||||
temp_token: string,
|
||||
totp_code: string,
|
||||
rememberMe: boolean = false,
|
||||
): Promise<AuthResponse> {
|
||||
try {
|
||||
const response = await authApi.post("/users/totp/verify-login", {
|
||||
temp_token,
|
||||
totp_code,
|
||||
rememberMe,
|
||||
});
|
||||
|
||||
const isInIframe =
|
||||
typeof window !== "undefined" && window.self !== window.top;
|
||||
|
||||
if (isInIframe && isElectron() && response.data.success) {
|
||||
try {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "AUTH_SUCCESS",
|
||||
source: "totp_verify",
|
||||
platform: "desktop",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
window.location.origin,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("[main-axios] Error posting message to parent:", e);
|
||||
}
|
||||
}
|
||||
|
||||
if (response.data.success) {
|
||||
markUserAuthenticated();
|
||||
}
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error as AxiosError, "verify TOTP login");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateBackupCodes(
|
||||
password?: string,
|
||||
totp_code?: string,
|
||||
): Promise<{ backup_codes: string[] }> {
|
||||
try {
|
||||
const response = await authApi.post("/users/totp/backup-codes", {
|
||||
password,
|
||||
totp_code,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error as AxiosError, "generate backup codes");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserAlerts(): Promise<{
|
||||
alerts: Array<Record<string, unknown>>;
|
||||
}> {
|
||||
try {
|
||||
const response = await authApi.get(`/alerts`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch user alerts");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function dismissAlert(
|
||||
alertId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.post("/alerts/dismiss", { alertId });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "dismiss alert");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// UPDATES & RELEASES
|
||||
// ============================================================================
|
||||
|
||||
export async function getReleasesRSS(
|
||||
perPage: number = 100,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.get(`/releases/rss?per_page=${perPage}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch releases RSS");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getVersionInfo(
|
||||
checkRemote = true,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.get(
|
||||
`/version${checkRemote ? "" : "?checkRemote=false"}`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch version info");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DATABASE HEALTH
|
||||
// ============================================================================
|
||||
|
||||
export async function getDatabaseHealth(): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.get("/health");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "check database health");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -0,0 +1,142 @@
|
||||
import axios from "axios";
|
||||
import { authApi, handleApiError, tunnelApi } from "@/main-axios";
|
||||
import type {
|
||||
C2STunnelPreset,
|
||||
TunnelConfig,
|
||||
TunnelConnection,
|
||||
TunnelStatus,
|
||||
} from "@/types/index";
|
||||
|
||||
// TUNNEL MANAGEMENT
|
||||
// ============================================================================
|
||||
|
||||
export async function getTunnelStatuses(): Promise<
|
||||
Record<string, TunnelStatus>
|
||||
> {
|
||||
try {
|
||||
const response = await tunnelApi.get("/tunnel/status");
|
||||
return response.data || {};
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch tunnel statuses");
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeTunnelStatuses(
|
||||
onStatuses: (statuses: Record<string, TunnelStatus>) => void,
|
||||
onError?: () => void,
|
||||
): () => void {
|
||||
const baseURL = (tunnelApi.defaults.baseURL || "").replace(/\/$/, "");
|
||||
const source = new EventSource(`${baseURL}/tunnel/status/stream`, {
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
source.addEventListener("statuses", (event) => {
|
||||
try {
|
||||
onStatuses(JSON.parse(event.data) as Record<string, TunnelStatus>);
|
||||
} catch {
|
||||
onError?.();
|
||||
}
|
||||
});
|
||||
|
||||
source.onerror = () => {
|
||||
onError?.();
|
||||
};
|
||||
|
||||
return () => source.close();
|
||||
}
|
||||
|
||||
export async function getTunnelStatusByName(
|
||||
tunnelName: string,
|
||||
): Promise<TunnelStatus | undefined> {
|
||||
const statuses = await getTunnelStatuses();
|
||||
return statuses[tunnelName];
|
||||
}
|
||||
|
||||
export async function connectTunnel(
|
||||
tunnelConfig: TunnelConfig,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await tunnelApi.post("/tunnel/connect", tunnelConfig);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "connect tunnel");
|
||||
}
|
||||
}
|
||||
|
||||
export async function disconnectTunnel(
|
||||
tunnelName: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await tunnelApi.post("/tunnel/disconnect", { tunnelName });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "disconnect tunnel");
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelTunnel(
|
||||
tunnelName: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await tunnelApi.post("/tunnel/cancel", { tunnelName });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "cancel tunnel");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getC2STunnelPresets(): Promise<C2STunnelPreset[]> {
|
||||
try {
|
||||
const response = await authApi.get("/c2s-tunnel-presets");
|
||||
return response.data || [];
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||
return [];
|
||||
}
|
||||
handleApiError(error, "fetch client tunnel presets");
|
||||
}
|
||||
}
|
||||
|
||||
export async function createC2STunnelPreset(data: {
|
||||
name: string;
|
||||
config: TunnelConnection[];
|
||||
platform?: string;
|
||||
computerName?: string;
|
||||
}): Promise<C2STunnelPreset> {
|
||||
try {
|
||||
const response = await authApi.post("/c2s-tunnel-presets", data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "create client tunnel preset");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateC2STunnelPreset(
|
||||
id: number,
|
||||
data: Partial<{
|
||||
name: string;
|
||||
config: TunnelConnection[];
|
||||
platform: string;
|
||||
computerName: string;
|
||||
}>,
|
||||
): Promise<C2STunnelPreset> {
|
||||
try {
|
||||
const response = await authApi.put(`/c2s-tunnel-presets/${id}`, data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "update client tunnel preset");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteC2STunnelPreset(
|
||||
id: number,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.delete(`/c2s-tunnel-presets/${id}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "delete client tunnel preset");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -0,0 +1,254 @@
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
import type { UserInfo } from "@/main-axios";
|
||||
|
||||
// USER MANAGEMENT
|
||||
// ============================================================================
|
||||
|
||||
export async function getUserList(): Promise<{ users: UserInfo[] }> {
|
||||
try {
|
||||
const response = await authApi.get("/users/list");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch user list");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSessions(): Promise<{
|
||||
sessions: {
|
||||
id: string;
|
||||
userId: string;
|
||||
username?: string;
|
||||
deviceType: string;
|
||||
deviceInfo: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
lastActiveAt: string;
|
||||
isRevoked?: boolean;
|
||||
isCurrentSession?: boolean;
|
||||
}[];
|
||||
}> {
|
||||
try {
|
||||
const response = await authApi.get("/users/sessions");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch sessions");
|
||||
}
|
||||
}
|
||||
|
||||
export async function revokeSession(
|
||||
sessionId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await authApi.delete(`/users/sessions/${sessionId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "revoke session");
|
||||
}
|
||||
}
|
||||
|
||||
export async function revokeAllUserSessions(
|
||||
userId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await authApi.post("/users/sessions/revoke-all", {
|
||||
targetUserId: userId,
|
||||
exceptCurrent: false,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "revoke all user sessions");
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
userId: string;
|
||||
username: string | null;
|
||||
tokenPrefix: string;
|
||||
createdAt: string;
|
||||
expiresAt: string | null;
|
||||
lastUsedAt: string | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface CreatedApiKey extends ApiKey {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export async function createApiKey(
|
||||
name: string,
|
||||
userId: string,
|
||||
expiresAt?: string,
|
||||
): Promise<CreatedApiKey> {
|
||||
try {
|
||||
const response = await authApi.post("/users/api-keys", {
|
||||
name,
|
||||
userId,
|
||||
expiresAt: expiresAt ?? null,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "create API key");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getApiKeys(): Promise<{ apiKeys: ApiKey[] }> {
|
||||
try {
|
||||
const response = await authApi.get("/users/api-keys");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch API keys");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteApiKey(
|
||||
keyId: string,
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await authApi.delete(`/users/api-keys/${keyId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "delete API key");
|
||||
}
|
||||
}
|
||||
|
||||
export async function makeUserAdmin(
|
||||
userId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.post("/users/make-admin", { userId });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "make user admin");
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeAdminStatus(
|
||||
userId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.post("/users/remove-admin", { userId });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "remove admin status");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteUser(
|
||||
username: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.delete("/users/delete-user", {
|
||||
data: { username },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "delete user");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAccount(
|
||||
password: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.delete("/users/delete-account", {
|
||||
data: { password },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "delete account");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateRegistrationAllowed(
|
||||
allowed: boolean,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.patch("/users/registration-allowed", {
|
||||
allowed,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "update registration allowed");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOidcAutoProvision(): Promise<{ enabled: boolean }> {
|
||||
try {
|
||||
const response = await authApi.get("/users/oidc-auto-provision");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "check OIDC auto-provision status");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateOidcAutoProvision(
|
||||
enabled: boolean,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.patch("/users/oidc-auto-provision", {
|
||||
enabled,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "update OIDC auto-provision");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updatePasswordLoginAllowed(
|
||||
allowed: boolean,
|
||||
): Promise<{ allowed: boolean }> {
|
||||
try {
|
||||
const response = await authApi.patch("/users/password-login-allowed", {
|
||||
allowed,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "update password login allowed");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPasswordResetAllowed(): Promise<boolean> {
|
||||
try {
|
||||
const response = await authApi.get("/users/password-reset-allowed");
|
||||
return response.data.allowed;
|
||||
} catch (error) {
|
||||
handleApiError(error, "get password reset allowed");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updatePasswordResetAllowed(
|
||||
allowed: boolean,
|
||||
): Promise<{ allowed: boolean }> {
|
||||
try {
|
||||
const response = await authApi.patch("/users/password-reset-allowed", {
|
||||
allowed,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "update password reset allowed");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateOIDCConfig(
|
||||
config: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.post("/users/oidc-config", config);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "update OIDC config");
|
||||
}
|
||||
}
|
||||
|
||||
export async function disableOIDCConfig(): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.delete("/users/oidc-config");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "disable OIDC config");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
Reference in New Issue
Block a user