fix: general server stats issues, file manager decoding, ui qol

This commit is contained in:
LukeGus
2025-12-31 14:30:54 -06:00
parent 1aeb225efd
commit faebdf7374
13 changed files with 649 additions and 293 deletions

View File

@@ -2,8 +2,8 @@ import express from "express";
import cors from "cors"; import cors from "cors";
import cookieParser from "cookie-parser"; import cookieParser from "cookie-parser";
import { getDb } from "./database/db/index.js"; import { getDb } from "./database/db/index.js";
import { recentActivity, sshData } from "./database/db/schema.js"; import { recentActivity, sshData, hostAccess } from "./database/db/schema.js";
import { eq, and, desc } from "drizzle-orm"; import { eq, and, desc, or } from "drizzle-orm";
import { dashboardLogger } from "./utils/logger.js"; import { dashboardLogger } from "./utils/logger.js";
import { SimpleDBOps } from "./utils/simple-db-ops.js"; import { SimpleDBOps } from "./utils/simple-db-ops.js";
import { AuthManager } from "./utils/auth-manager.js"; import { AuthManager } from "./utils/auth-manager.js";
@@ -164,7 +164,7 @@ app.post("/activity/log", async (req, res) => {
entriesToDelete.forEach((key) => activityRateLimiter.delete(key)); entriesToDelete.forEach((key) => activityRateLimiter.delete(key));
} }
const hosts = await SimpleDBOps.select( const ownedHosts = await SimpleDBOps.select(
getDb() getDb()
.select() .select()
.from(sshData) .from(sshData)
@@ -173,8 +173,19 @@ app.post("/activity/log", async (req, res) => {
userId, userId,
); );
if (hosts.length === 0) { if (ownedHosts.length === 0) {
return res.status(404).json({ error: "Host not found" }); const sharedHosts = await getDb()
.select()
.from(hostAccess)
.where(
and(eq(hostAccess.hostId, hostId), eq(hostAccess.userId, userId)),
);
if (sharedHosts.length === 0) {
return res
.status(404)
.json({ error: "Host not found or access denied" });
}
} }
const result = (await SimpleDBOps.insert( const result = (await SimpleDBOps.insert(

View File

@@ -1440,7 +1440,7 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
let fileBuffer; let fileBuffer;
try { try {
if (typeof content === "string") { if (typeof content === "string") {
fileBuffer = Buffer.from(content, "utf8"); fileBuffer = Buffer.from(content, "base64");
} else if (Buffer.isBuffer(content)) { } else if (Buffer.isBuffer(content)) {
fileBuffer = content; fileBuffer = content;
} else { } else {
@@ -1649,7 +1649,7 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => {
let fileBuffer; let fileBuffer;
try { try {
if (typeof content === "string") { if (typeof content === "string") {
fileBuffer = Buffer.from(content, "utf8"); fileBuffer = Buffer.from(content, "base64");
} else if (Buffer.isBuffer(content)) { } else if (Buffer.isBuffer(content)) {
fileBuffer = content; fileBuffer = content;
} else { } else {

View File

@@ -9,6 +9,7 @@ import { eq, and } from "drizzle-orm";
import { statsLogger, sshLogger } from "../utils/logger.js"; import { statsLogger, sshLogger } from "../utils/logger.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { SimpleDBOps } from "../utils/simple-db-ops.js";
import { AuthManager } from "../utils/auth-manager.js"; import { AuthManager } from "../utils/auth-manager.js";
import { PermissionManager } from "../utils/permission-manager.js";
import type { AuthenticatedRequest, ProxyNode } from "../../types/index.js"; import type { AuthenticatedRequest, ProxyNode } from "../../types/index.js";
import { collectCpuMetrics } from "./widgets/cpu-collector.js"; import { collectCpuMetrics } from "./widgets/cpu-collector.js";
import { collectMemoryMetrics } from "./widgets/memory-collector.js"; import { collectMemoryMetrics } from "./widgets/memory-collector.js";
@@ -218,6 +219,13 @@ interface PendingTOTPSession {
totpAttempts: number; totpAttempts: number;
} }
interface MetricsViewer {
sessionId: string;
userId: string;
hostId: number;
lastHeartbeat: number;
}
const metricsSessions: Record<string, MetricsSession> = {}; const metricsSessions: Record<string, MetricsSession> = {};
const pendingTOTPSessions: Record<string, PendingTOTPSession> = {}; const pendingTOTPSessions: Record<string, PendingTOTPSession> = {};
@@ -868,6 +876,7 @@ const metricsCache = new MetricsCache();
const authFailureTracker = new AuthFailureTracker(); const authFailureTracker = new AuthFailureTracker();
const pollingBackoff = new PollingBackoff(); const pollingBackoff = new PollingBackoff();
const authManager = AuthManager.getInstance(); const authManager = AuthManager.getInstance();
const permissionManager = PermissionManager.getInstance();
type HostStatus = "online" | "offline"; type HostStatus = "online" | "offline";
@@ -931,6 +940,7 @@ interface HostPollingConfig {
statsConfig: StatsConfig; statsConfig: StatsConfig;
statusTimer?: NodeJS.Timeout; statusTimer?: NodeJS.Timeout;
metricsTimer?: NodeJS.Timeout; metricsTimer?: NodeJS.Timeout;
viewerUserId?: string;
} }
class PollingManager { class PollingManager {
@@ -943,6 +953,15 @@ class PollingManager {
timestamp: number; timestamp: number;
} }
>(); >();
private activeViewers = new Map<number, Set<string>>();
private viewerDetails = new Map<string, MetricsViewer>();
private viewerCleanupInterval: NodeJS.Timeout;
constructor() {
this.viewerCleanupInterval = setInterval(() => {
this.cleanupInactiveViewers();
}, 60000);
}
parseStatsConfig(statsConfigStr?: string | StatsConfig): StatsConfig { parseStatsConfig(statsConfigStr?: string | StatsConfig): StatsConfig {
if (!statsConfigStr) { if (!statsConfigStr) {
@@ -981,10 +1000,11 @@ class PollingManager {
async startPollingForHost( async startPollingForHost(
host: SSHHostWithCredentials, host: SSHHostWithCredentials,
options?: { statusOnly?: boolean }, options?: { statusOnly?: boolean; viewerUserId?: string },
): Promise<void> { ): Promise<void> {
const statsConfig = this.parseStatsConfig(host.statsConfig); const statsConfig = this.parseStatsConfig(host.statsConfig);
const statusOnly = options?.statusOnly ?? false; const statusOnly = options?.statusOnly ?? false;
const viewerUserId = options?.viewerUserId;
const existingConfig = this.pollingConfigs.get(host.id); const existingConfig = this.pollingConfigs.get(host.id);
@@ -1009,17 +1029,18 @@ class PollingManager {
const config: HostPollingConfig = { const config: HostPollingConfig = {
host, host,
statsConfig, statsConfig,
viewerUserId,
}; };
if (statsConfig.statusCheckEnabled) { if (statsConfig.statusCheckEnabled) {
const intervalMs = statsConfig.statusCheckInterval * 1000; const intervalMs = statsConfig.statusCheckInterval * 1000;
this.pollHostStatus(host); this.pollHostStatus(host, viewerUserId);
config.statusTimer = setInterval(() => { config.statusTimer = setInterval(() => {
const latestConfig = this.pollingConfigs.get(host.id); const latestConfig = this.pollingConfigs.get(host.id);
if (latestConfig && latestConfig.statsConfig.statusCheckEnabled) { if (latestConfig && latestConfig.statsConfig.statusCheckEnabled) {
this.pollHostStatus(latestConfig.host); this.pollHostStatus(latestConfig.host, latestConfig.viewerUserId);
} }
}, intervalMs); }, intervalMs);
} else { } else {
@@ -1029,12 +1050,12 @@ class PollingManager {
if (!statusOnly && statsConfig.metricsEnabled) { if (!statusOnly && statsConfig.metricsEnabled) {
const intervalMs = statsConfig.metricsInterval * 1000; const intervalMs = statsConfig.metricsInterval * 1000;
await this.pollHostMetrics(host); await this.pollHostMetrics(host, viewerUserId);
config.metricsTimer = setInterval(() => { config.metricsTimer = setInterval(() => {
const latestConfig = this.pollingConfigs.get(host.id); const latestConfig = this.pollingConfigs.get(host.id);
if (latestConfig && latestConfig.statsConfig.metricsEnabled) { if (latestConfig && latestConfig.statsConfig.metricsEnabled) {
this.pollHostMetrics(latestConfig.host); this.pollHostMetrics(latestConfig.host, latestConfig.viewerUserId);
} }
}, intervalMs); }, intervalMs);
} else { } else {
@@ -1044,13 +1065,13 @@ class PollingManager {
this.pollingConfigs.set(host.id, config); this.pollingConfigs.set(host.id, config);
} }
private async pollHostStatus(host: SSHHostWithCredentials): Promise<void> { private async pollHostStatus(
const refreshedHost = await fetchHostById(host.id, host.userId); host: SSHHostWithCredentials,
viewerUserId?: string,
): Promise<void> {
const userId = viewerUserId || host.userId;
const refreshedHost = await fetchHostById(host.id, userId);
if (!refreshedHost) { if (!refreshedHost) {
statsLogger.warn("Host not found during status polling", {
operation: "poll_host_status",
hostId: host.id,
});
return; return;
} }
@@ -1074,13 +1095,13 @@ class PollingManager {
} }
} }
private async pollHostMetrics(host: SSHHostWithCredentials): Promise<void> { private async pollHostMetrics(
const refreshedHost = await fetchHostById(host.id, host.userId); host: SSHHostWithCredentials,
viewerUserId?: string,
): Promise<void> {
const userId = viewerUserId || host.userId;
const refreshedHost = await fetchHostById(host.id, userId);
if (!refreshedHost) { if (!refreshedHost) {
statsLogger.warn("Host not found during metrics polling", {
operation: "poll_host_metrics",
hostId: host.id,
});
return; return;
} }
@@ -1194,7 +1215,87 @@ class PollingManager {
} }
} }
registerViewer(hostId: number, sessionId: string, userId: string): void {
if (!this.activeViewers.has(hostId)) {
this.activeViewers.set(hostId, new Set());
}
this.activeViewers.get(hostId)!.add(sessionId);
this.viewerDetails.set(sessionId, {
sessionId,
userId,
hostId,
lastHeartbeat: Date.now(),
});
if (this.activeViewers.get(hostId)!.size === 1) {
this.startMetricsForHost(hostId, userId);
}
}
updateHeartbeat(sessionId: string): boolean {
const viewer = this.viewerDetails.get(sessionId);
if (viewer) {
viewer.lastHeartbeat = Date.now();
return true;
}
return false;
}
unregisterViewer(hostId: number, sessionId: string): void {
const viewers = this.activeViewers.get(hostId);
if (viewers) {
viewers.delete(sessionId);
if (viewers.size === 0) {
this.activeViewers.delete(hostId);
this.stopMetricsForHost(hostId);
}
}
this.viewerDetails.delete(sessionId);
}
private async startMetricsForHost(
hostId: number,
userId: string,
): Promise<void> {
try {
const host = await fetchHostById(hostId, userId);
if (host) {
await this.startPollingForHost(host, { viewerUserId: userId });
}
} catch (error) {
statsLogger.error("Failed to start metrics polling", {
operation: "start_metrics_error",
hostId,
error: error instanceof Error ? error.message : String(error),
});
}
}
private stopMetricsForHost(hostId: number): void {
this.stopMetricsOnly(hostId);
}
private cleanupInactiveViewers(): void {
const now = Date.now();
const maxInactivity = 120000;
for (const [sessionId, viewer] of this.viewerDetails.entries()) {
if (now - viewer.lastHeartbeat > maxInactivity) {
statsLogger.warn("Cleaning up inactive viewer", {
operation: "cleanup_inactive_viewer",
sessionId,
hostId: viewer.hostId,
inactiveFor: Math.floor((now - viewer.lastHeartbeat) / 1000),
});
this.unregisterViewer(viewer.hostId, sessionId);
}
}
}
destroy(): void { destroy(): void {
clearInterval(this.viewerCleanupInterval);
for (const hostId of this.pollingConfigs.keys()) { for (const hostId of this.pollingConfigs.keys()) {
this.stopPollingForHost(hostId); this.stopPollingForHost(hostId);
} }
@@ -1297,11 +1398,23 @@ async function fetchHostById(
return undefined; return undefined;
} }
const accessInfo = await permissionManager.canAccessHost(
userId,
id,
"read",
);
if (!accessInfo.hasAccess) {
statsLogger.warn(`User ${userId} cannot access host ${id}`, {
operation: "fetch_host_access_denied",
userId,
hostId: id,
});
return undefined;
}
const hosts = await SimpleDBOps.select( const hosts = await SimpleDBOps.select(
getDb() getDb().select().from(sshData).where(eq(sshData.id, id)),
.select()
.from(sshData)
.where(and(eq(sshData.id, id), eq(sshData.userId, userId))),
"ssh_data", "ssh_data",
userId, userId,
); );
@@ -1362,41 +1475,72 @@ async function resolveHostCredentials(
if (host.credentialId) { if (host.credentialId) {
try { try {
const credentials = await SimpleDBOps.select( const ownerId = host.userId;
getDb() const isSharedHost = userId !== ownerId;
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, host.credentialId as number),
eq(sshCredentials.userId, userId),
),
),
"ssh_credentials",
userId,
);
if (credentials.length > 0) { if (isSharedHost) {
const credential = credentials[0]; const { SharedCredentialManager } =
baseHost.credentialId = credential.id; await import("../utils/shared-credential-manager.js");
baseHost.username = credential.username; const sharedCredManager = SharedCredentialManager.getInstance();
baseHost.authType = credential.auth_type || credential.authType; const sharedCred = await sharedCredManager.getSharedCredentialForUser(
host.id as number,
userId,
);
if (credential.password) { baseHost.credentialId = host.credentialId;
baseHost.password = credential.password; baseHost.authType = sharedCred.authType;
if (!host.overrideCredentialUsername) {
baseHost.username = sharedCred.username;
} }
if (credential.key) {
baseHost.key = credential.key; if (sharedCred.password) {
baseHost.password = sharedCred.password;
} }
if (credential.key_password || credential.keyPassword) { if (sharedCred.key) {
baseHost.keyPassword = baseHost.key = sharedCred.key;
credential.key_password || credential.keyPassword;
} }
if (credential.key_type || credential.keyType) { if (sharedCred.keyPassword) {
baseHost.keyType = credential.key_type || credential.keyType; baseHost.keyPassword = sharedCred.keyPassword;
}
if (sharedCred.keyType) {
baseHost.keyType = sharedCred.keyType;
} }
} else { } else {
addLegacyCredentials(baseHost, host); const credentials = await SimpleDBOps.select(
getDb()
.select()
.from(sshCredentials)
.where(eq(sshCredentials.id, host.credentialId as number)),
"ssh_credentials",
userId,
);
if (credentials.length > 0) {
const credential = credentials[0];
baseHost.credentialId = credential.id;
baseHost.authType = credential.auth_type || credential.authType;
if (!host.overrideCredentialUsername) {
baseHost.username = credential.username;
}
if (credential.password) {
baseHost.password = credential.password;
}
if (credential.key) {
baseHost.key = credential.key;
}
if (credential.key_password || credential.keyPassword) {
baseHost.keyPassword =
credential.key_password || credential.keyPassword;
}
if (credential.key_type || credential.keyType) {
baseHost.keyType = credential.key_type || credential.keyType;
}
} else {
addLegacyCredentials(baseHost, host);
}
} }
} catch (error) { } catch (error) {
statsLogger.warn( statsLogger.warn(
@@ -1928,6 +2072,7 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
requires_totp?: boolean; requires_totp?: boolean;
sessionId?: string; sessionId?: string;
prompt?: string; prompt?: string;
viewerSessionId?: string;
}>((resolve, reject) => { }>((resolve, reject) => {
let isResolved = false; let isResolved = false;
@@ -2006,15 +2151,10 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
}; };
scheduleMetricsSessionCleanup(sessionKey); scheduleMetricsSessionCleanup(sessionKey);
pollingManager.startPollingForHost(host).catch((error) => { const viewerSessionId = `viewer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
statsLogger.error("Failed to start polling after connection", { pollingManager.registerViewer(host.id, viewerSessionId, userId);
operation: "start_polling_error",
hostId: host.id,
error: error instanceof Error ? error.message : String(error),
});
});
resolve({ success: true }); resolve({ success: true, viewerSessionId });
} }
}); });
@@ -2082,6 +2222,7 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
app.post("/metrics/stop/:id", validateHostId, async (req, res) => { app.post("/metrics/stop/:id", validateHostId, async (req, res) => {
const id = Number(req.params.id); const id = Number(req.params.id);
const userId = (req as AuthenticatedRequest).userId; const userId = (req as AuthenticatedRequest).userId;
const { viewerSessionId } = req.body;
if (!SimpleDBOps.isUserDataUnlocked(userId)) { if (!SimpleDBOps.isUserDataUnlocked(userId)) {
return res.status(401).json({ return res.status(401).json({
@@ -2098,7 +2239,11 @@ app.post("/metrics/stop/:id", validateHostId, async (req, res) => {
cleanupMetricsSession(sessionKey); cleanupMetricsSession(sessionKey);
} }
pollingManager.stopMetricsOnly(id); if (viewerSessionId && typeof viewerSessionId === "string") {
pollingManager.unregisterViewer(id, viewerSessionId);
} else {
pollingManager.stopMetricsOnly(id);
}
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
@@ -2225,18 +2370,10 @@ app.post("/metrics/connect-totp", async (req, res) => {
delete pendingTOTPSessions[sessionId]; delete pendingTOTPSessions[sessionId];
const host = await fetchHostById(session.hostId, userId); const viewerSessionId = `viewer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
if (host) { pollingManager.registerViewer(session.hostId, viewerSessionId, userId);
pollingManager.startPollingForHost(host).catch((error) => {
statsLogger.error("Failed to start polling after TOTP", {
operation: "totp_polling_start_error",
hostId: session.hostId,
error: error instanceof Error ? error.message : String(error),
});
});
}
res.json({ success: true }); res.json({ success: true, viewerSessionId });
} catch (error) { } catch (error) {
statsLogger.error("TOTP verification failed", { statsLogger.error("TOTP verification failed", {
operation: "totp_verification_failed", operation: "totp_verification_failed",
@@ -2259,6 +2396,101 @@ app.post("/metrics/connect-totp", async (req, res) => {
} }
}); });
app.post("/metrics/heartbeat", async (req, res) => {
const { viewerSessionId } = req.body;
const userId = (req as AuthenticatedRequest).userId;
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
if (!viewerSessionId || typeof viewerSessionId !== "string") {
return res.status(400).json({ error: "Invalid viewerSessionId" });
}
try {
const success = pollingManager.updateHeartbeat(viewerSessionId);
if (success) {
res.json({ success: true });
} else {
res.status(404).json({ error: "Viewer session not found" });
}
} catch (error) {
statsLogger.error("Failed to update heartbeat", {
operation: "heartbeat_error",
viewerSessionId,
error: error instanceof Error ? error.message : String(error),
});
res.status(500).json({ error: "Failed to update heartbeat" });
}
});
app.post("/metrics/register-viewer", async (req, res) => {
const { hostId } = req.body;
const userId = (req as AuthenticatedRequest).userId;
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
if (!hostId || typeof hostId !== "number") {
return res.status(400).json({ error: "Invalid hostId" });
}
try {
const viewerSessionId = `viewer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
pollingManager.registerViewer(hostId, viewerSessionId, userId);
res.json({ success: true, viewerSessionId });
} catch (error) {
statsLogger.error("Failed to register viewer", {
operation: "register_viewer_error",
hostId,
userId,
error: error instanceof Error ? error.message : String(error),
});
res.status(500).json({ error: "Failed to register viewer" });
}
});
app.post("/metrics/unregister-viewer", async (req, res) => {
const { hostId, viewerSessionId } = req.body;
const userId = (req as AuthenticatedRequest).userId;
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
if (!hostId || typeof hostId !== "number") {
return res.status(400).json({ error: "Invalid hostId" });
}
if (!viewerSessionId || typeof viewerSessionId !== "string") {
return res.status(400).json({ error: "Invalid viewerSessionId" });
}
try {
pollingManager.unregisterViewer(hostId, viewerSessionId);
res.json({ success: true });
} catch (error) {
statsLogger.error("Failed to unregister viewer", {
operation: "unregister_viewer_error",
hostId,
viewerSessionId,
error: error instanceof Error ? error.message : String(error),
});
res.status(500).json({ error: "Failed to unregister viewer" });
}
});
process.on("SIGINT", () => { process.on("SIGINT", () => {
pollingManager.destroy(); pollingManager.destroy();
connectionPool.destroy(); connectionPool.destroy();

View File

@@ -828,6 +828,9 @@
"hostAddedSuccessfully": "Host \"{{name}}\" added successfully!", "hostAddedSuccessfully": "Host \"{{name}}\" added successfully!",
"hostDeletedSuccessfully": "Host \"{{name}}\" deleted successfully!", "hostDeletedSuccessfully": "Host \"{{name}}\" deleted successfully!",
"failedToSaveHost": "Failed to save host. Please try again.", "failedToSaveHost": "Failed to save host. Please try again.",
"savingHost": "Saving host...",
"updatingHost": "Updating host...",
"cloningHost": "Cloning host...",
"enableTerminal": "Enable Terminal", "enableTerminal": "Enable Terminal",
"enableTerminalDesc": "Enable/disable host visibility in Terminal tab", "enableTerminalDesc": "Enable/disable host visibility in Terminal tab",
"enableTunnel": "Enable Tunnel", "enableTunnel": "Enable Tunnel",

View File

@@ -422,35 +422,6 @@ export function UserEditDialog({
<Separator /> <Separator />
{showPasswordReset && (
<>
<div className="space-y-3">
<Label className="text-base font-semibold flex items-center gap-2">
<Key className="h-4 w-4" />
{t("admin.passwordManagement")}
</Label>
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>{t("common.warning")}</AlertTitle>
<AlertDescription>
{t("admin.passwordResetWarning")}
</AlertDescription>
</Alert>
<Button
variant="destructive"
onClick={handlePasswordReset}
disabled={passwordResetLoading}
className="w-full"
>
{passwordResetLoading
? t("admin.resettingPassword")
: t("admin.resetUserPassword")}
</Button>
</div>
<Separator />
</>
)}
<div className="space-y-4"> <div className="space-y-4">
<Label className="text-base font-semibold flex items-center gap-2"> <Label className="text-base font-semibold flex items-center gap-2">
<UserCog className="h-4 w-4" /> <UserCog className="h-4 w-4" />

View File

@@ -43,34 +43,6 @@ export function ConsoleTerminal({
const fitAddonRef = React.useRef<FitAddon | null>(null); const fitAddonRef = React.useRef<FitAddon | null>(null);
const pingIntervalRef = React.useRef<NodeJS.Timeout | null>(null); const pingIntervalRef = React.useRef<NodeJS.Timeout | null>(null);
const getWebSocketBaseUrl = React.useCallback(() => {
const isElectronApp = isElectron();
const isDev =
!isElectronApp &&
process.env.NODE_ENV === "development" &&
(window.location.port === "3000" ||
window.location.port === "5173" ||
window.location.port === "");
if (isDev) {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//localhost:30008`;
}
if (isElectronApp) {
const baseUrl =
(window as { configuredServerUrl?: string }).configuredServerUrl ||
"http://127.0.0.1:30001";
const wsProtocol = baseUrl.startsWith("https://") ? "wss://" : "ws://";
const wsHost = baseUrl.replace(/^https?:\/\//, "");
return `${wsProtocol}${wsHost}/docker/console/`;
}
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${window.location.host}/docker/console/`;
}, []);
React.useEffect(() => { React.useEffect(() => {
if (!terminal) return; if (!terminal) return;
@@ -175,7 +147,31 @@ export function ConsoleTerminal({
fitAddonRef.current.fit(); fitAddonRef.current.fit();
} }
const wsUrl = `${getWebSocketBaseUrl()}?token=${encodeURIComponent(token)}`; const isElectronApp = isElectron();
const isDev =
!isElectronApp &&
process.env.NODE_ENV === "development" &&
(window.location.port === "3000" ||
window.location.port === "5173" ||
window.location.port === "");
const baseWsUrl = isDev
? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30008`
: isElectronApp
? (() => {
const baseUrl =
(window as { configuredServerUrl?: string })
.configuredServerUrl || "http://127.0.0.1:30001";
const wsProtocol = baseUrl.startsWith("https://")
? "wss://"
: "ws://";
const wsHost = baseUrl.replace(/^https?:\/\//, "");
return `${wsProtocol}${wsHost}/docker/console/`;
})()
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/docker/console/`;
const wsUrl = `${baseWsUrl}?token=${encodeURIComponent(token)}`;
const ws = new WebSocket(wsUrl); const ws = new WebSocket(wsUrl);
ws.onopen = () => { ws.onopen = () => {

View File

@@ -32,16 +32,7 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip.tsx"; } from "@/components/ui/tooltip.tsx";
import { import { useConfirmation } from "@/hooks/use-confirmation.ts";
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog.tsx";
interface ContainerCardProps { interface ContainerCardProps {
container: DockerContainer; container: DockerContainer;
@@ -59,12 +50,12 @@ export function ContainerCard({
onRefresh, onRefresh,
}: ContainerCardProps): React.ReactElement { }: ContainerCardProps): React.ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
const [isStarting, setIsStarting] = React.useState(false); const [isStarting, setIsStarting] = React.useState(false);
const [isStopping, setIsStopping] = React.useState(false); const [isStopping, setIsStopping] = React.useState(false);
const [isRestarting, setIsRestarting] = React.useState(false); const [isRestarting, setIsRestarting] = React.useState(false);
const [isPausing, setIsPausing] = React.useState(false); const [isPausing, setIsPausing] = React.useState(false);
const [isRemoving, setIsRemoving] = React.useState(false); const [isRemoving, setIsRemoving] = React.useState(false);
const [showRemoveDialog, setShowRemoveDialog] = React.useState(false);
const statusColors = { const statusColors = {
running: { running: {
@@ -191,23 +182,42 @@ export function ContainerCard({
} }
}; };
const handleRemove = async () => { const handleRemove = async (e: React.MouseEvent) => {
setIsRemoving(true); e.stopPropagation();
try { const containerName = container.name.startsWith("/")
const force = container.state === "running"; ? container.name.slice(1)
await removeDockerContainer(sessionId, container.id, force); : container.name;
toast.success(t("docker.containerRemoved", { name: container.name }));
setShowRemoveDialog(false); let confirmMessage = t("docker.confirmRemoveContainer", {
onRefresh?.(); name: containerName,
} catch (error) { });
toast.error(
t("docker.failedToRemoveContainer", { if (container.state === "running") {
error: error instanceof Error ? error.message : "Unknown error", confirmMessage += " " + t("docker.runningContainerWarning");
}),
);
} finally {
setIsRemoving(false);
} }
confirmWithToast(
confirmMessage,
async () => {
setIsRemoving(true);
try {
const force = container.state === "running";
await removeDockerContainer(sessionId, container.id, force);
toast.success(t("docker.containerRemoved", { name: containerName }));
onRefresh?.();
} catch (error) {
toast.error(
t("docker.failedToRemoveContainer", {
error: error instanceof Error ? error.message : "Unknown error",
}),
);
} finally {
setIsRemoving(false);
}
},
t("common.remove"),
t("common.cancel"),
);
}; };
const isLoading = const isLoading =
@@ -405,56 +415,18 @@ export function ContainerCard({
size="sm" size="sm"
variant="outline" variant="outline"
className="h-8 text-red-400 hover:text-red-300 hover:bg-red-500/20" className="h-8 text-red-400 hover:text-red-300 hover:bg-red-500/20"
onClick={(e) => { onClick={handleRemove}
e.stopPropagation();
setShowRemoveDialog(true);
}}
disabled={isLoading} disabled={isLoading}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>{t("docker.remove")}</TooltipContent>{" "} <TooltipContent>{t("docker.remove")}</TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<AlertDialog open={showRemoveDialog} onOpenChange={setShowRemoveDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("docker.removeContainer")}</AlertDialogTitle>
<AlertDialogDescription>
{t("docker.confirmRemoveContainer", {
name: container.name.startsWith("/")
? container.name.slice(1)
: container.name,
})}
{container.state === "running" && (
<div className="mt-2 text-yellow-400">
{t("docker.runningContainerWarning")}
</div>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isRemoving}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleRemove();
}}
disabled={isRemoving}
className="bg-red-600 hover:bg-red-700"
>
{isRemoving ? t("docker.removing") : t("common.remove")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</> </>
); );
} }

View File

@@ -329,6 +329,22 @@ export function FileViewer({
const fileTypeInfo = getFileType(file.name); const fileTypeInfo = getFileType(file.name);
const getImageDataUrl = (content: string, fileName: string): string => {
const ext = fileName.split(".").pop()?.toLowerCase() || "";
if (ext === "svg") {
try {
const base64 = btoa(unescape(encodeURIComponent(content)));
return `data:image/svg+xml;base64,${base64}`;
} catch (e) {
console.error("Failed to encode SVG:", e);
return "";
}
}
return `data:image/*;base64,${content}`;
};
const WARNING_SIZE = 50 * 1024 * 1024; const WARNING_SIZE = 50 * 1024 * 1024;
const MAX_SIZE = Number.MAX_SAFE_INTEGER; const MAX_SIZE = Number.MAX_SAFE_INTEGER;
@@ -353,15 +369,6 @@ export function FileViewer({
} else { } else {
setShowLargeFileWarning(false); setShowLargeFileWarning(false);
} }
if (
fileTypeInfo.type === "image" &&
file.name.toLowerCase().endsWith(".svg") &&
content
) {
setImageLoading(false);
setImageLoadError(false);
}
}, [ }, [
content, content,
savedContent, savedContent,
@@ -716,21 +723,11 @@ export function FileViewer({
</Button> </Button>
)} )}
</div> </div>
) : file.name.toLowerCase().endsWith(".svg") ? (
<div
className="max-w-full max-h-full flex items-center justify-center"
style={{ maxHeight: "calc(100vh - 200px)" }}
dangerouslySetInnerHTML={{ __html: content }}
onLoad={() => {
setImageLoading(false);
setImageLoadError(false);
}}
/>
) : ( ) : (
<PhotoProvider maskOpacity={0.7}> <PhotoProvider maskOpacity={0.7}>
<PhotoView src={`data:image/*;base64,${content}`}> <PhotoView src={getImageDataUrl(content, file.name)}>
<img <img
src={`data:image/*;base64,${content}`} src={getImageDataUrl(content, file.name)}
alt={file.name} alt={file.name}
className="max-w-full max-h-full object-contain rounded-lg shadow-sm cursor-pointer hover:shadow-lg transition-shadow" className="max-w-full max-h-full object-contain rounded-lg shadow-sm cursor-pointer hover:shadow-lg transition-shadow"
style={{ maxHeight: "calc(100vh - 200px)" }} style={{ maxHeight: "calc(100vh - 200px)" }}

View File

@@ -102,6 +102,9 @@ export function ServerStats({
const [totpPrompt, setTotpPrompt] = React.useState<string>(""); const [totpPrompt, setTotpPrompt] = React.useState<string>("");
const [isPageVisible, setIsPageVisible] = React.useState(!document.hidden); const [isPageVisible, setIsPageVisible] = React.useState(!document.hidden);
const [totpVerified, setTotpVerified] = React.useState(false); const [totpVerified, setTotpVerified] = React.useState(false);
const [viewerSessionId, setViewerSessionId] = React.useState<string | null>(
null,
);
const activityLoggedRef = React.useRef(false); const activityLoggedRef = React.useRef(false);
const activityLoggingRef = React.useRef(false); const activityLoggingRef = React.useRef(false);
@@ -137,6 +140,21 @@ export function ServerStats({
const isActuallyVisible = isVisible && isPageVisible; const isActuallyVisible = isVisible && isPageVisible;
React.useEffect(() => {
if (!viewerSessionId || !isActuallyVisible) return;
const heartbeatInterval = setInterval(async () => {
try {
const { sendMetricsHeartbeat } = await import("@/ui/main-axios.ts");
await sendMetricsHeartbeat(viewerSessionId);
} catch (error) {
console.error("Failed to send heartbeat:", error);
}
}, 30000);
return () => clearInterval(heartbeatInterval);
}, [viewerSessionId, isActuallyVisible]);
React.useEffect(() => { React.useEffect(() => {
if (hostConfig?.id !== currentHostConfig?.id) { if (hostConfig?.id !== currentHostConfig?.id) {
setServerStatus("offline"); setServerStatus("offline");
@@ -182,6 +200,9 @@ export function ServerStats({
setTotpSessionId(null); setTotpSessionId(null);
setShowStatsUI(true); setShowStatsUI(true);
setTotpVerified(true); setTotpVerified(true);
if (result.viewerSessionId) {
setViewerSessionId(result.viewerSessionId);
}
} else { } else {
toast.error(t("serverStats.totpFailed")); toast.error(t("serverStats.totpFailed"));
} }
@@ -383,6 +404,10 @@ export function ServerStats({
setIsLoadingMetrics(false); setIsLoadingMetrics(false);
return; return;
} }
if (result.viewerSessionId) {
setViewerSessionId(result.viewerSessionId);
}
} }
let retryCount = 0; let retryCount = 0;
@@ -453,7 +478,10 @@ export function ServerStats({
} }
if (currentHostConfig?.id) { if (currentHostConfig?.id) {
try { try {
await stopMetricsPolling(currentHostConfig.id); await stopMetricsPolling(
currentHostConfig.id,
viewerSessionId || undefined,
);
} catch (error) { } catch (error) {
console.error("Failed to stop metrics polling:", error); console.error("Failed to stop metrics polling:", error);
} }

View File

@@ -96,6 +96,11 @@ export function DiskWidget({ metrics, metricsHistory }: DiskWidgetProps) {
color: "#fff", color: "#fff",
}} }}
formatter={(value: number) => [`${value.toFixed(1)}%`, "Disk"]} formatter={(value: number) => [`${value.toFixed(1)}%`, "Disk"]}
cursor={{
stroke: "#fb923c",
strokeWidth: 1,
strokeDasharray: "3 3",
}}
/> />
<Area <Area
type="monotone" type="monotone"
@@ -104,6 +109,12 @@ export function DiskWidget({ metrics, metricsHistory }: DiskWidgetProps) {
strokeWidth={2} strokeWidth={2}
fill="url(#diskGradient)" fill="url(#diskGradient)"
animationDuration={300} animationDuration={300}
activeDot={{
r: 4,
fill: "#fb923c",
stroke: "#fff",
strokeWidth: 2,
}}
/> />
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>

View File

@@ -102,6 +102,11 @@ export function MemoryWidget({ metrics, metricsHistory }: MemoryWidgetProps) {
`${value.toFixed(1)}%`, `${value.toFixed(1)}%`,
"Memory", "Memory",
]} ]}
cursor={{
stroke: "#34d399",
strokeWidth: 1,
strokeDasharray: "3 3",
}}
/> />
<Area <Area
type="monotone" type="monotone"
@@ -110,6 +115,12 @@ export function MemoryWidget({ metrics, metricsHistory }: MemoryWidgetProps) {
strokeWidth={2} strokeWidth={2}
fill="url(#memoryGradient)" fill="url(#memoryGradient)"
animationDuration={300} animationDuration={300}
activeDot={{
r: 4,
fill: "#34d399",
stroke: "#fff",
strokeWidth: 2,
}}
/> />
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>

View File

@@ -129,6 +129,7 @@ import { HostTunnelTab } from "./tabs/HostTunnelTab";
import { HostFileManagerTab } from "./tabs/HostFileManagerTab"; import { HostFileManagerTab } from "./tabs/HostFileManagerTab";
import { HostStatisticsTab } from "./tabs/HostStatisticsTab"; import { HostStatisticsTab } from "./tabs/HostStatisticsTab";
import { HostSharingTab } from "./tabs/HostSharingTab"; import { HostSharingTab } from "./tabs/HostSharingTab";
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
interface User { interface User {
id: string; id: string;
@@ -168,7 +169,7 @@ export function HostManagerEditor({
const [keyInputMethod, setKeyInputMethod] = useState<"upload" | "paste">( const [keyInputMethod, setKeyInputMethod] = useState<"upload" | "paste">(
"upload", "upload",
); );
const isSubmittingRef = useRef(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [activeTab, setActiveTab] = useState("general"); const [activeTab, setActiveTab] = useState("general");
const [formError, setFormError] = useState<string | null>(null); const [formError, setFormError] = useState<string | null>(null);
@@ -473,6 +474,7 @@ export function HostManagerEditor({
const form = useForm<FormData>({ const form = useForm<FormData>({
resolver: zodResolver(formSchema) as any, resolver: zodResolver(formSchema) as any,
mode: "all",
defaultValues: { defaultValues: {
name: "", name: "",
ip: "", ip: "",
@@ -509,19 +511,69 @@ export function HostManagerEditor({
}, },
}); });
useEffect(() => { const watchedFields = form.watch();
if (authTab === "credential") { const formState = form.formState;
const currentCredentialId = form.getValues("credentialId");
const overrideUsername = form.getValues("overrideCredentialUsername"); const isFormValid = React.useMemo(() => {
if (currentCredentialId && !overrideUsername) { const values = form.getValues();
const selectedCredential = credentials.find(
(c) => c.id === currentCredentialId, if (!values.ip || !values.username) return false;
);
if (selectedCredential) { if (authTab === "password") {
form.setValue("username", selectedCredential.username); return !!(values.password && values.password.trim() !== "");
} } else if (authTab === "key") {
} return !!(values.key && values.keyType);
} else if (authTab === "credential") {
return !!values.credentialId;
} else if (authTab === "none") {
return true;
} }
return false;
}, [watchedFields, authTab]);
useEffect(() => {
const updateAuthFields = async () => {
form.setValue("authType", authTab, { shouldValidate: true });
if (authTab === "password") {
form.setValue("key", null, { shouldValidate: true });
form.setValue("keyPassword", "", { shouldValidate: true });
form.setValue("keyType", "auto", { shouldValidate: true });
form.setValue("credentialId", null, { shouldValidate: true });
} else if (authTab === "key") {
form.setValue("password", "", { shouldValidate: true });
form.setValue("credentialId", null, { shouldValidate: true });
} else if (authTab === "credential") {
form.setValue("password", "", { shouldValidate: true });
form.setValue("key", null, { shouldValidate: true });
form.setValue("keyPassword", "", { shouldValidate: true });
form.setValue("keyType", "auto", { shouldValidate: true });
const currentCredentialId = form.getValues("credentialId");
const overrideUsername = form.getValues("overrideCredentialUsername");
if (currentCredentialId && !overrideUsername) {
const selectedCredential = credentials.find(
(c) => c.id === currentCredentialId,
);
if (selectedCredential) {
form.setValue("username", selectedCredential.username, {
shouldValidate: true,
});
}
}
} else if (authTab === "none") {
form.setValue("password", "", { shouldValidate: true });
form.setValue("key", null, { shouldValidate: true });
form.setValue("keyPassword", "", { shouldValidate: true });
form.setValue("keyType", "auto", { shouldValidate: true });
form.setValue("credentialId", null, { shouldValidate: true });
}
await form.trigger();
};
updateAuthFields();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [authTab, credentials]); }, [authTab, credentials]);
@@ -690,36 +742,14 @@ export function HostManagerEditor({
}, [editingHost]); }, [editingHost]);
const onSubmit = async (data: FormData) => { const onSubmit = async (data: FormData) => {
await form.trigger();
try { try {
isSubmittingRef.current = true; setIsSubmitting(true);
setFormError(null); setFormError(null);
if (!data.name || data.name.trim() === "") { if (!data.name || data.name.trim() === "") {
data.name = `${data.username}@${data.ip}`; data.name = `${data.username}@${data.ip}`;
} }
if (data.statsConfig) {
const statusInterval = data.statsConfig.statusCheckInterval || 30;
const metricsInterval = data.statsConfig.metricsInterval || 30;
if (statusInterval < 5 || statusInterval > 3600) {
toast.error(t("hosts.intervalValidation"));
setActiveTab("statistics");
setFormError(t("hosts.intervalValidation"));
isSubmittingRef.current = false;
return;
}
if (metricsInterval < 5 || metricsInterval > 3600) {
toast.error(t("hosts.intervalValidation"));
setActiveTab("statistics");
setFormError(t("hosts.intervalValidation"));
isSubmittingRef.current = false;
return;
}
}
const submitData: Partial<SSHHost> = { const submitData: Partial<SSHHost> = {
...data, ...data,
}; };
@@ -799,40 +829,72 @@ export function HostManagerEditor({
toast.error(t("hosts.failedToSaveHost") + ": " + errorMessage); toast.error(t("hosts.failedToSaveHost") + ": " + errorMessage);
console.error("Failed to save host:", error); console.error("Failed to save host:", error);
} finally { } finally {
isSubmittingRef.current = false; setIsSubmitting(false);
} }
}; };
const handleFormError = () => { const TAB_PRIORITY = [
const errors = form.formState.errors; "general",
"terminal",
"tunnel",
"file_manager",
"docker",
"statistics",
] as const;
if ( const FIELD_TO_TAB_MAP: Record<string, string> = {
errors.ip || ip: "general",
errors.port || port: "general",
errors.username || username: "general",
errors.name || name: "general",
errors.folder || folder: "general",
errors.tags || tags: "general",
errors.pin || pin: "general",
errors.password || password: "general",
errors.key || key: "general",
errors.keyPassword || keyPassword: "general",
errors.keyType || keyType: "general",
errors.credentialId || credentialId: "general",
errors.forceKeyboardInteractive || overrideCredentialUsername: "general",
errors.jumpHosts forceKeyboardInteractive: "general",
) { jumpHosts: "general",
setActiveTab("general"); authType: "general",
} else if (errors.enableTerminal || errors.terminalConfig) { notes: "general",
setActiveTab("terminal"); useSocks5: "general",
} else if (errors.enableDocker) { socks5Host: "general",
setActiveTab("docker"); socks5Port: "general",
} else if (errors.enableTunnel || errors.tunnelConnections) { socks5Username: "general",
setActiveTab("tunnel"); socks5Password: "general",
} else if (errors.enableFileManager || errors.defaultPath) { socks5ProxyChain: "general",
setActiveTab("file_manager"); quickActions: "general",
} else if (errors.statsConfig) { enableTerminal: "terminal",
setActiveTab("statistics"); terminalConfig: "terminal",
enableDocker: "docker",
enableTunnel: "tunnel",
tunnelConnections: "tunnel",
enableFileManager: "file_manager",
defaultPath: "file_manager",
statsConfig: "statistics",
};
const handleFormError = async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
const errors = form.formState.errors;
const errorFields = Object.keys(errors);
if (errorFields.length === 0) return;
for (const tab of TAB_PRIORITY) {
const hasErrorInTab = errorFields.some((field) => {
const baseField = field.split(".")[0].split("[")[0];
return FIELD_TO_TAB_MAP[baseField] === tab;
});
if (hasErrorInTab) {
setActiveTab(tab);
return;
}
} }
}; };
@@ -994,7 +1056,19 @@ export function HostManagerEditor({
}, [sshConfigDropdownOpen]); }, [sshConfigDropdownOpen]);
return ( return (
<div className="flex-1 flex flex-col h-full min-h-0 w-full"> <div className="flex-1 flex flex-col h-full min-h-0 w-full relative">
<SimpleLoader
visible={isSubmitting}
message={
editingHost?.id
? t("hosts.updatingHost")
: editingHost
? t("hosts.cloningHost")
: t("hosts.savingHost")
}
backgroundColor="var(--bg-base)"
/>
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(onSubmit, handleFormError)} onSubmit={form.handleSubmit(onSubmit, handleFormError)}
@@ -1130,7 +1204,12 @@ export function HostManagerEditor({
<footer className="shrink-0 w-full pb-0"> <footer className="shrink-0 w-full pb-0">
<Separator className="p-0.25" /> <Separator className="p-0.25" />
{!editingHost?.isShared && ( {!editingHost?.isShared && (
<Button className="translate-y-2" type="submit" variant="outline"> <Button
className="translate-y-2"
type="submit"
variant="outline"
disabled={!isFormValid || isSubmitting}
>
{editingHost {editingHost
? editingHost.id ? editingHost.id
? t("hosts.updateHost") ? t("hosts.updateHost")

View File

@@ -1983,6 +1983,7 @@ export async function startMetricsPolling(hostId: number): Promise<{
requires_totp?: boolean; requires_totp?: boolean;
sessionId?: string; sessionId?: string;
prompt?: string; prompt?: string;
viewerSessionId?: string;
}> { }> {
try { try {
const response = await statsApi.post(`/metrics/start/${hostId}`); const response = await statsApi.post(`/metrics/start/${hostId}`);
@@ -1993,20 +1994,64 @@ export async function startMetricsPolling(hostId: number): Promise<{
} }
} }
export async function stopMetricsPolling(hostId: number): Promise<void> { export async function stopMetricsPolling(
hostId: number,
viewerSessionId?: string,
): Promise<void> {
try { try {
await statsApi.post(`/metrics/stop/${hostId}`); await statsApi.post(`/metrics/stop/${hostId}`, { viewerSessionId });
} catch (error) { } catch (error) {
handleApiError(error, "stop metrics polling"); handleApiError(error, "stop metrics polling");
throw error; 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 }> {
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( export async function submitMetricsTOTP(
sessionId: string, sessionId: string,
totpCode: string, totpCode: string,
): Promise<{ ): Promise<{
success: boolean; success: boolean;
viewerSessionId?: string;
}> { }> {
try { try {
const response = await statsApi.post("/metrics/connect-totp", { const response = await statsApi.post("/metrics/connect-totp", {