fix: test new translation issue and add better server-stats support

This commit is contained in:
LukeGus
2025-12-30 22:40:01 -06:00
parent 04e1ba22be
commit e79207af68
32 changed files with 622 additions and 58684 deletions
+25 -25
View File
@@ -15,7 +15,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t zh
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t zh -maxLinesPerRequest 25
- uses: actions/upload-artifact@v4
with:
name: translations-zh
@@ -29,7 +29,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t ru
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t ru -maxLinesPerRequest 25
- uses: actions/upload-artifact@v4
with:
name: translations-ru
@@ -43,7 +43,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t pt
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t pt -maxLinesPerRequest 25
- uses: actions/upload-artifact@v4
with:
name: translations-pt
@@ -57,7 +57,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t fr
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t fr -maxLinesPerRequest 25
- uses: actions/upload-artifact@v4
with:
name: translations-fr
@@ -71,7 +71,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t es
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t es -maxLinesPerRequest 25
- uses: actions/upload-artifact@v4
with:
name: translations-es
@@ -85,7 +85,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t de
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t de -maxLinesPerRequest 25
- uses: actions/upload-artifact@v4
with:
name: translations-de
@@ -99,7 +99,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t hi
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t hi -maxLinesPerRequest 25
- uses: actions/upload-artifact@v4
with:
name: translations-hi
@@ -113,7 +113,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t bn
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t bn -maxLinesPerRequest 25
- uses: actions/upload-artifact@v4
with:
name: translations-bn
@@ -127,7 +127,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t ja
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t ja -maxLinesPerRequest 25
- uses: actions/upload-artifact@v4
with:
name: translations-ja
@@ -141,7 +141,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t vi
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t vi -maxLinesPerRequest 25
- uses: actions/upload-artifact@v4
with:
name: translations-vi
@@ -155,7 +155,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t tr
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t tr -maxLinesPerRequest 25
- uses: actions/upload-artifact@v4
with:
name: translations-tr
@@ -169,7 +169,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t ko
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t ko -maxLinesPerRequest 25
- uses: actions/upload-artifact@v4
with:
name: translations-ko
@@ -183,7 +183,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t it
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t it -maxLinesPerRequest 25
- uses: actions/upload-artifact@v4
with:
name: translations-it
@@ -197,7 +197,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t he
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t he -maxLinesPerRequest 25
- uses: actions/upload-artifact@v4
with:
name: translations-he
@@ -211,7 +211,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t ar
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t ar -maxLinesPerRequest 25
- uses: actions/upload-artifact@v4
with:
name: translations-ar
@@ -225,7 +225,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t pl
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t pl -maxLinesPerRequest 25
- uses: actions/upload-artifact@v4
with:
name: translations-pl
@@ -239,7 +239,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t nl
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t nl -maxLinesPerRequest 25
- uses: actions/upload-artifact@v4
with:
name: translations-nl
@@ -253,7 +253,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t sv
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t sv -maxLinesPerRequest 25
- uses: actions/upload-artifact@v4
with:
name: translations-sv
@@ -267,7 +267,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t id
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t id -maxLinesPerRequest 25
- uses: actions/upload-artifact@v4
with:
name: translations-id
@@ -281,7 +281,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t th
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t th -maxLinesPerRequest 25
- uses: actions/upload-artifact@v4
with:
name: translations-th
@@ -295,7 +295,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t uk
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t uk -maxLinesPerRequest 25
- uses: actions/upload-artifact@v4
with:
name: translations-uk
@@ -309,7 +309,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t cs
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t cs -maxLinesPerRequest 25
- uses: actions/upload-artifact@v4
with:
name: translations-cs
@@ -323,7 +323,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t ro
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t ro -maxLinesPerRequest 25
- uses: actions/upload-artifact@v4
with:
name: translations-ro
@@ -337,7 +337,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t el
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t el -maxLinesPerRequest 25
- uses: actions/upload-artifact@v4
with:
name: translations-el
@@ -351,7 +351,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t nb
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t nb -maxLinesPerRequest 25
- uses: actions/upload-artifact@v4
with:
name: translations-nb
+4
View File
@@ -311,6 +311,10 @@ http {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
location ~ ^/uptime(/.*)?$ {
+4
View File
@@ -300,6 +300,10 @@ http {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
location ~ ^/uptime(/.*)?$ {
+411 -9
View File
@@ -194,6 +194,80 @@ interface PooledConnection {
hostKey: string;
}
interface MetricsSession {
client: Client;
isConnected: boolean;
lastActive: number;
timeout?: NodeJS.Timeout;
activeOperations: number;
hostId: number;
userId: string;
}
interface PendingTOTPSession {
client: Client;
finish: (responses: string[]) => void;
config: ConnectConfig;
createdAt: number;
sessionId: string;
hostId: number;
userId: string;
prompts?: Array<{ prompt: string; echo: boolean }>;
totpPromptIndex?: number;
resolvedPassword?: string;
totpAttempts: number;
}
const metricsSessions: Record<string, MetricsSession> = {};
const pendingTOTPSessions: Record<string, PendingTOTPSession> = {};
function cleanupMetricsSession(sessionId: string) {
const session = metricsSessions[sessionId];
if (session) {
if (session.activeOperations > 0) {
statsLogger.warn(
`Deferring metrics session cleanup - ${session.activeOperations} active operations`,
{
operation: "cleanup_deferred",
sessionId,
activeOperations: session.activeOperations,
},
);
scheduleMetricsSessionCleanup(sessionId);
return;
}
try {
session.client.end();
} catch (error) {}
clearTimeout(session.timeout);
delete metricsSessions[sessionId];
statsLogger.info("Metrics session cleaned up", {
operation: "session_cleanup",
sessionId,
});
}
}
function scheduleMetricsSessionCleanup(sessionId: string) {
const session = metricsSessions[sessionId];
if (session) {
if (session.timeout) clearTimeout(session.timeout);
session.timeout = setTimeout(
() => {
cleanupMetricsSession(sessionId);
},
30 * 60 * 1000,
);
}
}
function getSessionKey(hostId: number, userId: string): string {
return `${userId}:${hostId}`;
}
class SSHConnectionPool {
private connections = new Map<string, PooledConnection[]>();
private maxConnectionsPerHost = 3;
@@ -310,20 +384,37 @@ class SSHConnectionPool {
prompts: Array<{ prompt: string; echo: boolean }>,
finish: (responses: string[]) => void,
) => {
const totpPrompt = prompts.find((p) =>
const totpPromptIndex = prompts.findIndex((p) =>
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
p.prompt,
),
);
if (totpPrompt) {
authFailureTracker.recordFailure(host.id, "TOTP", true);
client.end();
reject(
new Error(
"TOTP authentication required but not supported in Server Stats",
),
);
if (totpPromptIndex !== -1) {
const sessionId = `totp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
pendingTOTPSessions[sessionId] = {
client,
finish,
config,
createdAt: Date.now(),
sessionId,
hostId: host.id,
userId: host.userId!,
prompts,
totpPromptIndex,
resolvedPassword: host.password,
totpAttempts: 0,
};
statsLogger.info("TOTP required for metrics collection", {
operation: "metrics_totp_required",
hostId: host.id,
sessionId,
prompt: prompts[totpPromptIndex].prompt,
});
return;
} else if (host.password) {
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt)) {
@@ -1055,6 +1146,14 @@ class PollingManager {
}
}
stopMetricsOnly(hostId: number): void {
const config = this.pollingConfigs.get(hostId);
if (config?.metricsTimer) {
clearInterval(config.metricsTimer);
config.metricsTimer = undefined;
}
}
getStatus(hostId: number): StatusEntry | undefined {
return this.statusStore.get(hostId);
}
@@ -1436,6 +1535,8 @@ function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
);
throw new Error(`Invalid SSH key format for host ${host.ip}`);
}
} else if (host.authType === "none") {
// Allow "none" auth - SSH will handle via keyboard-interactive
} else {
throw new Error(
`Unsupported authentication type '${host.authType}' for host ${host.ip}`,
@@ -1784,6 +1885,307 @@ app.get("/metrics/:id", validateHostId, async (req, res) => {
});
});
app.post("/metrics/start/:id", validateHostId, async (req, res) => {
const id = Number(req.params.id);
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",
});
}
try {
const host = await fetchHostById(id, userId);
if (!host) {
return res.status(404).json({ error: "Host not found" });
}
const sessionKey = getSessionKey(host.id, userId);
const existingSession = metricsSessions[sessionKey];
if (existingSession && existingSession.isConnected) {
return res.json({ success: true });
}
const config = buildSshConfig(host);
const client = new Client();
const connectionPromise = new Promise<{
success: boolean;
requires_totp?: boolean;
sessionId?: string;
prompt?: string;
}>((resolve, reject) => {
let isResolved = false;
const timeout = setTimeout(() => {
if (!isResolved) {
isResolved = true;
client.end();
reject(new Error("Connection timeout"));
}
}, 60000);
client.on(
"keyboard-interactive",
(name, instructions, instructionsLang, prompts, finish) => {
const totpPromptIndex = prompts.findIndex((p) =>
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
p.prompt,
),
);
if (totpPromptIndex !== -1) {
const sessionId = `totp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
pendingTOTPSessions[sessionId] = {
client,
finish,
config,
createdAt: Date.now(),
sessionId,
hostId: host.id,
userId: host.userId!,
prompts,
totpPromptIndex,
resolvedPassword: host.password,
totpAttempts: 0,
};
clearTimeout(timeout);
if (!isResolved) {
isResolved = true;
resolve({
success: false,
requires_totp: true,
sessionId,
prompt: prompts[totpPromptIndex].prompt,
});
}
return;
} else if (host.password) {
const responses = prompts.map((p) =>
/password/i.test(p.prompt) ? host.password || "" : "",
);
finish(responses);
} else {
finish(prompts.map(() => ""));
}
},
);
client.on("ready", () => {
clearTimeout(timeout);
if (!isResolved) {
isResolved = true;
metricsSessions[sessionKey] = {
client,
isConnected: true,
lastActive: Date.now(),
activeOperations: 0,
hostId: host.id,
userId,
};
scheduleMetricsSessionCleanup(sessionKey);
pollingManager.startPollingForHost(host).catch((error) => {
statsLogger.error("Failed to start polling after connection", {
operation: "start_polling_error",
hostId: host.id,
error: error instanceof Error ? error.message : String(error),
});
});
resolve({ success: true });
}
});
client.on("error", (error) => {
clearTimeout(timeout);
if (!isResolved) {
isResolved = true;
reject(error);
}
});
client.connect(config);
});
const result = await connectionPromise;
res.json(result);
} catch (error) {
statsLogger.error("Failed to start metrics collection", {
operation: "metrics_start_error",
hostId: id,
error: error instanceof Error ? error.message : String(error),
});
res.status(500).json({
error:
error instanceof Error
? error.message
: "Failed to start metrics collection",
});
}
});
app.post("/metrics/stop/:id", validateHostId, async (req, res) => {
const id = Number(req.params.id);
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",
});
}
try {
const sessionKey = getSessionKey(id, userId);
const session = metricsSessions[sessionKey];
if (session) {
cleanupMetricsSession(sessionKey);
}
pollingManager.stopMetricsOnly(id);
res.json({ success: true });
} catch (error) {
statsLogger.error("Failed to stop metrics collection", {
operation: "metrics_stop_error",
hostId: id,
error: error instanceof Error ? error.message : String(error),
});
res.status(500).json({
error:
error instanceof Error
? error.message
: "Failed to stop metrics collection",
});
}
});
app.post("/metrics/connect-totp", async (req, res) => {
const { sessionId, totpCode } = 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 (!sessionId || !totpCode) {
return res.status(400).json({ error: "Missing sessionId or totpCode" });
}
const session = pendingTOTPSessions[sessionId];
if (!session) {
return res.status(404).json({ error: "TOTP session not found or expired" });
}
if (Date.now() - session.createdAt > 180000) {
delete pendingTOTPSessions[sessionId];
try {
session.client.end();
} catch {}
return res.status(408).json({ error: "TOTP session timeout" });
}
if (session.userId !== userId) {
return res.status(403).json({ error: "Unauthorized" });
}
session.totpAttempts++;
if (session.totpAttempts > 3) {
delete pendingTOTPSessions[sessionId];
try {
session.client.end();
} catch {}
return res.status(429).json({ error: "Too many TOTP attempts" });
}
try {
const responses = (session.prompts || []).map((p, idx) => {
if (idx === session.totpPromptIndex) {
return totpCode.trim();
} else if (/password/i.test(p.prompt) && session.resolvedPassword) {
return session.resolvedPassword;
}
return "";
});
const connectionPromise = new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("TOTP verification timeout"));
}, 30000);
session.client.once("ready", () => {
clearTimeout(timeout);
resolve();
});
session.client.once("error", (error) => {
clearTimeout(timeout);
reject(error);
});
});
session.finish(responses);
await connectionPromise;
const sessionKey = getSessionKey(session.hostId, userId);
metricsSessions[sessionKey] = {
client: session.client,
isConnected: true,
lastActive: Date.now(),
activeOperations: 0,
hostId: session.hostId,
userId,
};
scheduleMetricsSessionCleanup(sessionKey);
delete pendingTOTPSessions[sessionId];
const host = await fetchHostById(session.hostId, userId);
if (host) {
await pollingManager.startPollingForHost(host);
}
statsLogger.info("TOTP verified, metrics collection started", {
operation: "totp_verified",
hostId: session.hostId,
sessionId,
});
res.json({ success: true });
} catch (error) {
statsLogger.error("TOTP verification failed", {
operation: "totp_verification_failed",
hostId: session.hostId,
sessionId,
error: error instanceof Error ? error.message : String(error),
});
if (session.totpAttempts >= 3) {
delete pendingTOTPSessions[sessionId];
try {
session.client.end();
} catch {}
}
res.status(401).json({
error: "TOTP verification failed",
attemptsRemaining: Math.max(0, 3 - session.totpAttempts),
});
}
});
process.on("SIGINT", () => {
pollingManager.destroy();
connectionPool.destroy();
-2351
View File
File diff suppressed because it is too large Load Diff
-2367
View File
File diff suppressed because it is too large Load Diff
-2386
View File
File diff suppressed because it is too large Load Diff
-2027
View File
File diff suppressed because it is too large Load Diff
-2386
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -1690,6 +1690,11 @@
"cannotFetchMetrics": "Cannot fetch metrics from offline server",
"totpRequired": "TOTP Authentication Required",
"totpUnavailable": "Server Stats unavailable for TOTP-enabled servers",
"totpVerified": "TOTP verified, metrics collection started",
"totpFailed": "TOTP verification failed",
"totpInvalidCode": "Invalid verification code",
"totpCancelled": "Metrics collection cancelled",
"authenticationFailed": "Authentication failed",
"load": "Load",
"editLayout": "Edit Layout",
"cancelEdit": "Cancel",
-2358
View File
File diff suppressed because it is too large Load Diff
-2336
View File
File diff suppressed because it is too large Load Diff
-2373
View File
File diff suppressed because it is too large Load Diff
-2350
View File
File diff suppressed because it is too large Load Diff
-2377
View File
File diff suppressed because it is too large Load Diff
-2347
View File
File diff suppressed because it is too large Load Diff
-2385
View File
File diff suppressed because it is too large Load Diff
-2377
View File
File diff suppressed because it is too large Load Diff
-2380
View File
File diff suppressed because it is too large Load Diff
-2381
View File
File diff suppressed because it is too large Load Diff
-2362
View File
File diff suppressed because it is too large Load Diff
-2379
View File
File diff suppressed because it is too large Load Diff
-2381
View File
File diff suppressed because it is too large Load Diff
-2386
View File
File diff suppressed because it is too large Load Diff
-2379
View File
File diff suppressed because it is too large Load Diff
-2381
View File
File diff suppressed because it is too large Load Diff
-2128
View File
File diff suppressed because it is too large Load Diff
-2386
View File
File diff suppressed because it is too large Load Diff
-2288
View File
File diff suppressed because it is too large Load Diff
-2355
View File
File diff suppressed because it is too large Load Diff
@@ -6,9 +6,13 @@ import { Button } from "@/components/ui/button.tsx";
import {
getServerStatusById,
getServerMetricsById,
startMetricsPolling,
stopMetricsPolling,
submitMetricsTOTP,
executeSnippet,
type ServerMetrics,
} from "@/ui/main-axios.ts";
import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -90,6 +94,10 @@ export function ServerStats({
const [executingActions, setExecutingActions] = React.useState<Set<number>>(
new Set(),
);
const [totpRequired, setTotpRequired] = React.useState(false);
const [totpSessionId, setTotpSessionId] = React.useState<string | null>(null);
const [totpPrompt, setTotpPrompt] = React.useState<string>("");
const [isPageVisible, setIsPageVisible] = React.useState(!document.hidden);
const statsConfig = React.useMemo((): StatsConfig => {
if (!currentHostConfig?.statsConfig) {
@@ -111,6 +119,17 @@ export function ServerStats({
const statusCheckEnabled = statsConfig.statusCheckEnabled !== false;
const metricsEnabled = statsConfig.metricsEnabled !== false;
React.useEffect(() => {
const handleVisibilityChange = () => {
setIsPageVisible(!document.hidden);
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () =>
document.removeEventListener("visibilitychange", handleVisibilityChange);
}, []);
const isActuallyVisible = isVisible && isPageVisible;
React.useEffect(() => {
if (hostConfig?.id !== currentHostConfig?.id) {
setServerStatus("offline");
@@ -121,6 +140,35 @@ export function ServerStats({
setCurrentHostConfig(hostConfig);
}, [hostConfig?.id]);
const handleTOTPSubmit = async (totpCode: string) => {
if (!totpSessionId || !currentHostConfig) return;
try {
const result = await submitMetricsTOTP(totpSessionId, totpCode);
if (result.success) {
setTotpRequired(false);
toast.success(t("serverStats.totpVerified"));
const data = await getServerMetricsById(currentHostConfig.id);
setMetrics(data);
setShowStatsUI(true);
}
} catch (error) {
toast.error(t("serverStats.totpFailed"));
console.error("TOTP verification failed:", error);
}
};
const handleTOTPCancel = async () => {
setTotpRequired(false);
if (currentHostConfig?.id) {
try {
await stopMetricsPolling(currentHostConfig.id);
} catch (error) {
console.error("Failed to stop metrics polling:", error);
}
}
};
const renderWidget = (widgetType: WidgetType) => {
switch (widgetType) {
case "cpu":
@@ -203,7 +251,7 @@ export function ServerStats({
}, [hostConfig?.id]);
React.useEffect(() => {
if (!statusCheckEnabled || !currentHostConfig?.id || !isVisible) {
if (!statusCheckEnabled || !currentHostConfig?.id) {
setServerStatus("offline");
return;
}
@@ -247,24 +295,48 @@ export function ServerStats({
};
}, [
currentHostConfig?.id,
isVisible,
statusCheckEnabled,
statsConfig.statusCheckInterval,
]);
React.useEffect(() => {
if (!metricsEnabled || !currentHostConfig?.id || !isVisible) {
if (!metricsEnabled || !currentHostConfig?.id) {
setShowStatsUI(false);
return;
}
let cancelled = false;
let intervalId: number | undefined;
let pollingIntervalId: number | undefined;
let debounceTimeout: NodeJS.Timeout | undefined;
const startMetrics = async () => {
if (cancelled) return;
const fetchMetrics = async () => {
if (!currentHostConfig?.id) return;
try {
setIsLoadingMetrics(true);
try {
const result = await startMetricsPolling(currentHostConfig.id);
if (cancelled) return;
if (result.requires_totp) {
setTotpRequired(true);
setTotpSessionId(result.sessionId || null);
setTotpPrompt(result.prompt || "Verification code");
setIsLoadingMetrics(false);
return;
}
const data = await getServerMetricsById(currentHostConfig.id);
if (!cancelled) {
setMetrics(data);
setShowStatsUI(true);
setIsLoadingMetrics(false);
}
pollingIntervalId = window.setInterval(async () => {
if (cancelled) return;
try {
const data = await getServerMetricsById(currentHostConfig.id);
if (!cancelled) {
setMetrics(data);
@@ -272,51 +344,56 @@ export function ServerStats({
const newHistory = [...prev, data];
return newHistory.slice(-20);
});
setShowStatsUI(true);
}
} catch (error: unknown) {
} catch (error) {
if (!cancelled) {
const err = error as {
code?: string;
response?: { status?: number; data?: { error?: string } };
};
if (err?.response?.status === 404) {
setMetrics(null);
setShowStatsUI(false);
} else if (
err?.code === "TOTP_REQUIRED" ||
(err?.response?.status === 403 &&
err?.response?.data?.error === "TOTP_REQUIRED")
) {
setMetrics(null);
setShowStatsUI(false);
toast.error(t("serverStats.totpUnavailable"));
} else {
setMetrics(null);
console.error("Failed to fetch metrics:", error);
}
}
}, statsConfig.metricsInterval * 1000);
} catch (error) {
if (!cancelled) {
console.error("Failed to start metrics polling:", error);
setIsLoadingMetrics(false);
setShowStatsUI(false);
toast.error(t("serverStats.failedToFetchMetrics"));
}
}
} finally {
if (!cancelled) {
setIsLoadingMetrics(false);
};
const stopMetrics = async () => {
if (pollingIntervalId) {
window.clearInterval(pollingIntervalId);
pollingIntervalId = undefined;
}
if (currentHostConfig?.id) {
try {
await stopMetricsPolling(currentHostConfig.id);
} catch (error) {
console.error("Failed to stop metrics polling:", error);
}
}
};
fetchMetrics();
intervalId = window.setInterval(
fetchMetrics,
statsConfig.metricsInterval * 1000,
);
debounceTimeout = setTimeout(() => {
if (isActuallyVisible) {
startMetrics();
} else {
stopMetrics();
}
}, 500);
return () => {
cancelled = true;
if (intervalId) window.clearInterval(intervalId);
if (debounceTimeout) clearTimeout(debounceTimeout);
if (pollingIntervalId) window.clearInterval(pollingIntervalId);
if (currentHostConfig?.id) {
stopMetricsPolling(currentHostConfig.id).catch(() => {});
}
};
}, [
currentHostConfig?.id,
isVisible,
isActuallyVisible,
metricsEnabled,
statsConfig.metricsInterval,
]);
@@ -638,6 +715,16 @@ export function ServerStats({
) : null}
</div>
</div>
{totpRequired && (
<TOTPDialog
isOpen={totpRequired}
prompt={totpPrompt}
onSubmit={handleTOTPSubmit}
onCancel={handleTOTPCancel}
backgroundColor="var(--bg-canvas)"
/>
)}
</div>
);
}
+42
View File
@@ -1978,6 +1978,48 @@ export async function getServerMetricsById(id: number): Promise<ServerMetrics> {
}
}
export async function startMetricsPolling(hostId: number): Promise<{
success: boolean;
requires_totp?: boolean;
sessionId?: string;
prompt?: string;
}> {
try {
const response = await statsApi.post(`/metrics/start/${hostId}`);
return response.data;
} catch (error) {
handleApiError(error, "start metrics polling");
throw error;
}
}
export async function stopMetricsPolling(hostId: number): Promise<void> {
try {
await statsApi.post(`/metrics/stop/${hostId}`);
} catch (error) {
handleApiError(error, "stop metrics polling");
throw error;
}
}
export async function submitMetricsTOTP(
sessionId: string,
totpCode: string,
): Promise<{
success: boolean;
}> {
try {
const response = await statsApi.post("/metrics/connect-totp", {
sessionId,
totpCode,
});
return response.data;
} catch (error) {
handleApiError(error, "submit metrics TOTP");
throw error;
}
}
export async function refreshServerPolling(): Promise<void> {
try {
await statsApi.post("/refresh");