v1.10.0 #471
50
.github/workflows/translate.yml
vendored
50
.github/workflows/translate.yml
vendored
@@ -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
|
||||
|
||||
@@ -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(/.*)?$ {
|
||||
|
||||
@@ -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(/.*)?$ {
|
||||
|
||||
@@ -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
src/locales/ar.json
2351
src/locales/ar.json
File diff suppressed because it is too large
Load Diff
2367
src/locales/bn.json
2367
src/locales/bn.json
File diff suppressed because it is too large
Load Diff
2386
src/locales/cs.json
2386
src/locales/cs.json
File diff suppressed because it is too large
Load Diff
2027
src/locales/de.json
2027
src/locales/de.json
File diff suppressed because it is too large
Load Diff
2386
src/locales/el.json
2386
src/locales/el.json
File diff suppressed because it is too large
Load Diff
@@ -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
src/locales/es.json
2358
src/locales/es.json
File diff suppressed because it is too large
Load Diff
2336
src/locales/fr.json
2336
src/locales/fr.json
File diff suppressed because it is too large
Load Diff
2373
src/locales/he.json
2373
src/locales/he.json
File diff suppressed because it is too large
Load Diff
2350
src/locales/hi.json
2350
src/locales/hi.json
File diff suppressed because it is too large
Load Diff
2377
src/locales/id.json
2377
src/locales/id.json
File diff suppressed because it is too large
Load Diff
2347
src/locales/it.json
2347
src/locales/it.json
File diff suppressed because it is too large
Load Diff
2385
src/locales/ja.json
2385
src/locales/ja.json
File diff suppressed because it is too large
Load Diff
2377
src/locales/ko.json
2377
src/locales/ko.json
File diff suppressed because it is too large
Load Diff
2380
src/locales/nb.json
2380
src/locales/nb.json
File diff suppressed because it is too large
Load Diff
2381
src/locales/nl.json
2381
src/locales/nl.json
File diff suppressed because it is too large
Load Diff
2362
src/locales/pl.json
2362
src/locales/pl.json
File diff suppressed because it is too large
Load Diff
2379
src/locales/pt.json
2379
src/locales/pt.json
File diff suppressed because it is too large
Load Diff
2381
src/locales/ro.json
2381
src/locales/ro.json
File diff suppressed because it is too large
Load Diff
2386
src/locales/ru.json
2386
src/locales/ru.json
File diff suppressed because it is too large
Load Diff
2379
src/locales/sv.json
2379
src/locales/sv.json
File diff suppressed because it is too large
Load Diff
2381
src/locales/th.json
2381
src/locales/th.json
File diff suppressed because it is too large
Load Diff
2128
src/locales/tr.json
2128
src/locales/tr.json
File diff suppressed because it is too large
Load Diff
2386
src/locales/uk.json
2386
src/locales/uk.json
File diff suppressed because it is too large
Load Diff
2288
src/locales/vi.json
2288
src/locales/vi.json
File diff suppressed because it is too large
Load Diff
2355
src/locales/zh.json
2355
src/locales/zh.json
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,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user