fix: test new translation issue and add better server-stats support
This commit is contained in:
50
.github/workflows/translate.yml
vendored
50
.github/workflows/translate.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
|||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
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
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: translations-zh
|
name: translations-zh
|
||||||
@@ -29,7 +29,7 @@ jobs:
|
|||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
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
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: translations-ru
|
name: translations-ru
|
||||||
@@ -43,7 +43,7 @@ jobs:
|
|||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
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
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: translations-pt
|
name: translations-pt
|
||||||
@@ -57,7 +57,7 @@ jobs:
|
|||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
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
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: translations-fr
|
name: translations-fr
|
||||||
@@ -71,7 +71,7 @@ jobs:
|
|||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
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
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: translations-es
|
name: translations-es
|
||||||
@@ -85,7 +85,7 @@ jobs:
|
|||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
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
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: translations-de
|
name: translations-de
|
||||||
@@ -99,7 +99,7 @@ jobs:
|
|||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
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
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: translations-hi
|
name: translations-hi
|
||||||
@@ -113,7 +113,7 @@ jobs:
|
|||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
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
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: translations-bn
|
name: translations-bn
|
||||||
@@ -127,7 +127,7 @@ jobs:
|
|||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
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
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: translations-ja
|
name: translations-ja
|
||||||
@@ -141,7 +141,7 @@ jobs:
|
|||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
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
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: translations-vi
|
name: translations-vi
|
||||||
@@ -155,7 +155,7 @@ jobs:
|
|||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
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
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: translations-tr
|
name: translations-tr
|
||||||
@@ -169,7 +169,7 @@ jobs:
|
|||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
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
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: translations-ko
|
name: translations-ko
|
||||||
@@ -183,7 +183,7 @@ jobs:
|
|||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
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
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: translations-it
|
name: translations-it
|
||||||
@@ -197,7 +197,7 @@ jobs:
|
|||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
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
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: translations-he
|
name: translations-he
|
||||||
@@ -211,7 +211,7 @@ jobs:
|
|||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
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
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: translations-ar
|
name: translations-ar
|
||||||
@@ -225,7 +225,7 @@ jobs:
|
|||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
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
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: translations-pl
|
name: translations-pl
|
||||||
@@ -239,7 +239,7 @@ jobs:
|
|||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
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
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: translations-nl
|
name: translations-nl
|
||||||
@@ -253,7 +253,7 @@ jobs:
|
|||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
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
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: translations-sv
|
name: translations-sv
|
||||||
@@ -267,7 +267,7 @@ jobs:
|
|||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
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
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: translations-id
|
name: translations-id
|
||||||
@@ -281,7 +281,7 @@ jobs:
|
|||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
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
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: translations-th
|
name: translations-th
|
||||||
@@ -295,7 +295,7 @@ jobs:
|
|||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
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
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: translations-uk
|
name: translations-uk
|
||||||
@@ -309,7 +309,7 @@ jobs:
|
|||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
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
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: translations-cs
|
name: translations-cs
|
||||||
@@ -323,7 +323,7 @@ jobs:
|
|||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
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
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: translations-ro
|
name: translations-ro
|
||||||
@@ -337,7 +337,7 @@ jobs:
|
|||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
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
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: translations-el
|
name: translations-el
|
||||||
@@ -351,7 +351,7 @@ jobs:
|
|||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
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
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: translations-nb
|
name: translations-nb
|
||||||
|
|||||||
@@ -311,6 +311,10 @@ http {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
proxy_send_timeout 60s;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/uptime(/.*)?$ {
|
location ~ ^/uptime(/.*)?$ {
|
||||||
|
|||||||
@@ -300,6 +300,10 @@ http {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
proxy_send_timeout 60s;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/uptime(/.*)?$ {
|
location ~ ^/uptime(/.*)?$ {
|
||||||
|
|||||||
@@ -194,6 +194,80 @@ interface PooledConnection {
|
|||||||
hostKey: string;
|
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 {
|
class SSHConnectionPool {
|
||||||
private connections = new Map<string, PooledConnection[]>();
|
private connections = new Map<string, PooledConnection[]>();
|
||||||
private maxConnectionsPerHost = 3;
|
private maxConnectionsPerHost = 3;
|
||||||
@@ -310,20 +384,37 @@ class SSHConnectionPool {
|
|||||||
prompts: Array<{ prompt: string; echo: boolean }>,
|
prompts: Array<{ prompt: string; echo: boolean }>,
|
||||||
finish: (responses: string[]) => void,
|
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(
|
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
|
||||||
p.prompt,
|
p.prompt,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (totpPrompt) {
|
if (totpPromptIndex !== -1) {
|
||||||
authFailureTracker.recordFailure(host.id, "TOTP", true);
|
const sessionId = `totp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
client.end();
|
|
||||||
reject(
|
pendingTOTPSessions[sessionId] = {
|
||||||
new Error(
|
client,
|
||||||
"TOTP authentication required but not supported in Server Stats",
|
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) {
|
} else if (host.password) {
|
||||||
const responses = prompts.map((p) => {
|
const responses = prompts.map((p) => {
|
||||||
if (/password/i.test(p.prompt)) {
|
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 {
|
getStatus(hostId: number): StatusEntry | undefined {
|
||||||
return this.statusStore.get(hostId);
|
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}`);
|
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 {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Unsupported authentication type '${host.authType}' for host ${host.ip}`,
|
`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", () => {
|
process.on("SIGINT", () => {
|
||||||
pollingManager.destroy();
|
pollingManager.destroy();
|
||||||
connectionPool.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",
|
"cannotFetchMetrics": "Cannot fetch metrics from offline server",
|
||||||
"totpRequired": "TOTP Authentication Required",
|
"totpRequired": "TOTP Authentication Required",
|
||||||
"totpUnavailable": "Server Stats unavailable for TOTP-enabled servers",
|
"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",
|
"load": "Load",
|
||||||
"editLayout": "Edit Layout",
|
"editLayout": "Edit Layout",
|
||||||
"cancelEdit": "Cancel",
|
"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 {
|
import {
|
||||||
getServerStatusById,
|
getServerStatusById,
|
||||||
getServerMetricsById,
|
getServerMetricsById,
|
||||||
|
startMetricsPolling,
|
||||||
|
stopMetricsPolling,
|
||||||
|
submitMetricsTOTP,
|
||||||
executeSnippet,
|
executeSnippet,
|
||||||
type ServerMetrics,
|
type ServerMetrics,
|
||||||
} from "@/ui/main-axios.ts";
|
} from "@/ui/main-axios.ts";
|
||||||
|
import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx";
|
||||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -90,6 +94,10 @@ export function ServerStats({
|
|||||||
const [executingActions, setExecutingActions] = React.useState<Set<number>>(
|
const [executingActions, setExecutingActions] = React.useState<Set<number>>(
|
||||||
new Set(),
|
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 => {
|
const statsConfig = React.useMemo((): StatsConfig => {
|
||||||
if (!currentHostConfig?.statsConfig) {
|
if (!currentHostConfig?.statsConfig) {
|
||||||
@@ -111,6 +119,17 @@ export function ServerStats({
|
|||||||
const statusCheckEnabled = statsConfig.statusCheckEnabled !== false;
|
const statusCheckEnabled = statsConfig.statusCheckEnabled !== false;
|
||||||
const metricsEnabled = statsConfig.metricsEnabled !== 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(() => {
|
React.useEffect(() => {
|
||||||
if (hostConfig?.id !== currentHostConfig?.id) {
|
if (hostConfig?.id !== currentHostConfig?.id) {
|
||||||
setServerStatus("offline");
|
setServerStatus("offline");
|
||||||
@@ -121,6 +140,35 @@ export function ServerStats({
|
|||||||
setCurrentHostConfig(hostConfig);
|
setCurrentHostConfig(hostConfig);
|
||||||
}, [hostConfig?.id]);
|
}, [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) => {
|
const renderWidget = (widgetType: WidgetType) => {
|
||||||
switch (widgetType) {
|
switch (widgetType) {
|
||||||
case "cpu":
|
case "cpu":
|
||||||
@@ -203,7 +251,7 @@ export function ServerStats({
|
|||||||
}, [hostConfig?.id]);
|
}, [hostConfig?.id]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!statusCheckEnabled || !currentHostConfig?.id || !isVisible) {
|
if (!statusCheckEnabled || !currentHostConfig?.id) {
|
||||||
setServerStatus("offline");
|
setServerStatus("offline");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -247,76 +295,105 @@ export function ServerStats({
|
|||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
currentHostConfig?.id,
|
currentHostConfig?.id,
|
||||||
isVisible,
|
|
||||||
statusCheckEnabled,
|
statusCheckEnabled,
|
||||||
statsConfig.statusCheckInterval,
|
statsConfig.statusCheckInterval,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!metricsEnabled || !currentHostConfig?.id || !isVisible) {
|
if (!metricsEnabled || !currentHostConfig?.id) {
|
||||||
setShowStatsUI(false);
|
setShowStatsUI(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
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 {
|
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);
|
const data = await getServerMetricsById(currentHostConfig.id);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setMetrics(data);
|
setMetrics(data);
|
||||||
setMetricsHistory((prev) => {
|
|
||||||
const newHistory = [...prev, data];
|
|
||||||
return newHistory.slice(-20);
|
|
||||||
});
|
|
||||||
setShowStatsUI(true);
|
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);
|
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();
|
const stopMetrics = async () => {
|
||||||
intervalId = window.setInterval(
|
if (pollingIntervalId) {
|
||||||
fetchMetrics,
|
window.clearInterval(pollingIntervalId);
|
||||||
statsConfig.metricsInterval * 1000,
|
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 () => {
|
return () => {
|
||||||
cancelled = true;
|
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,
|
currentHostConfig?.id,
|
||||||
isVisible,
|
isActuallyVisible,
|
||||||
metricsEnabled,
|
metricsEnabled,
|
||||||
statsConfig.metricsInterval,
|
statsConfig.metricsInterval,
|
||||||
]);
|
]);
|
||||||
@@ -638,6 +715,16 @@ export function ServerStats({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{totpRequired && (
|
||||||
|
<TOTPDialog
|
||||||
|
isOpen={totpRequired}
|
||||||
|
prompt={totpPrompt}
|
||||||
|
onSubmit={handleTOTPSubmit}
|
||||||
|
onCancel={handleTOTPCancel}
|
||||||
|
backgroundColor="var(--bg-canvas)"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</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> {
|
export async function refreshServerPolling(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await statsApi.post("/refresh");
|
await statsApi.post("/refresh");
|
||||||
|
|||||||
Reference in New Issue
Block a user