fix: general server stats issues, file manager decoding, ui qol
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)" }}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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", {
|
||||||
|
|||||||
Reference in New Issue
Block a user