v1.10.0 #471

Merged
LukeGus merged 106 commits from dev-1.10.0 into main 2026-01-01 04:20:12 +00:00
32 changed files with 622 additions and 58684 deletions
Showing only changes of commit e79207af68 - Show all commits

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

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(/.*)?$ {

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(/.*)?$ {

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();

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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,76 +295,105 @@ 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;
setIsLoadingMetrics(true);
const fetchMetrics = async () => {
if (!currentHostConfig?.id) return;
try {
setIsLoadingMetrics(true);
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);
setMetricsHistory((prev) => {
const newHistory = [...prev, data];
return newHistory.slice(-20);
});
setShowStatsUI(true);
}
} catch (error: unknown) {
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);
setShowStatsUI(false);
toast.error(t("serverStats.failedToFetchMetrics"));
}
}
} finally {
if (!cancelled) {
setIsLoadingMetrics(false);
}
pollingIntervalId = window.setInterval(async () => {
if (cancelled) return;
try {
const data = await getServerMetricsById(currentHostConfig.id);
if (!cancelled) {
setMetrics(data);
setMetricsHistory((prev) => {
const newHistory = [...prev, data];
return newHistory.slice(-20);
});
}
} catch (error) {
if (!cancelled) {
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"));
}
}
};
fetchMetrics();
intervalId = window.setInterval(
fetchMetrics,
statsConfig.metricsInterval * 1000,
);
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);
}
}
};
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>
);
}

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");