feat: add to readme, a few qol changes, and improve server stats in general
This commit is contained in:
@@ -126,7 +126,7 @@ If you need help or want to request a feature with Termix, visit the [Issues](ht
|
||||
Please be as detailed as possible in your issue, preferably written in English. You can also join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support
|
||||
channel, however, response times may be longer.
|
||||
|
||||
# Show-off
|
||||
# Screenshots
|
||||
|
||||
<p align="center">
|
||||
<img src="./repo-images/Image 1.png" width="400" alt="Termix Demo 1"/>
|
||||
@@ -145,7 +145,12 @@ channel, however, response times may be longer.
|
||||
|
||||
<p align="center">
|
||||
<img src="./repo-images/Image 7.png" width="400" alt="Termix Demo 7"/>
|
||||
<img src="./repo-images/Image 8.png" width="400" alt="Termix Demo 8"/>
|
||||
<img src="./repo-images/Image 8.png" width="400" alt="Termix Demo 8"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./repo-images/Image 9.png" width="400" alt="Termix Demo 9"/>
|
||||
<img src="./repo-images/Image 10.png" width="400" alt="Termix Demo 110"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
|
||||
BIN
repo-images/Image 10.png
Normal file
BIN
repo-images/Image 10.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 158 KiB |
BIN
repo-images/Image 9.png
Normal file
BIN
repo-images/Image 9.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 153 KiB |
@@ -127,9 +127,18 @@ app.post("/activity/log", async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (type !== "terminal" && type !== "file_manager") {
|
||||
if (
|
||||
![
|
||||
"terminal",
|
||||
"file_manager",
|
||||
"server_stats",
|
||||
"tunnel",
|
||||
"docker",
|
||||
].includes(type)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: "Invalid activity type. Must be 'terminal' or 'file_manager'",
|
||||
error:
|
||||
"Invalid activity type. Must be 'terminal', 'file_manager', 'server_stats', 'tunnel', or 'docker'",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -242,11 +242,6 @@ function cleanupMetricsSession(sessionId: string) {
|
||||
} catch (error) {}
|
||||
clearTimeout(session.timeout);
|
||||
delete metricsSessions[sessionId];
|
||||
|
||||
statsLogger.info("Metrics session cleaned up", {
|
||||
operation: "session_cleanup",
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,13 +406,6 @@ class SSHConnectionPool {
|
||||
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) => {
|
||||
@@ -728,7 +716,7 @@ interface AuthFailureRecord {
|
||||
class AuthFailureTracker {
|
||||
private failures = new Map<number, AuthFailureRecord>();
|
||||
private maxRetries = 3;
|
||||
private backoffBase = 60000;
|
||||
private backoffBase = 5000;
|
||||
|
||||
recordFailure(
|
||||
hostId: number,
|
||||
@@ -991,8 +979,12 @@ class PollingManager {
|
||||
return result;
|
||||
}
|
||||
|
||||
async startPollingForHost(host: SSHHostWithCredentials): Promise<void> {
|
||||
async startPollingForHost(
|
||||
host: SSHHostWithCredentials,
|
||||
options?: { statusOnly?: boolean },
|
||||
): Promise<void> {
|
||||
const statsConfig = this.parseStatsConfig(host.statsConfig);
|
||||
const statusOnly = options?.statusOnly ?? false;
|
||||
|
||||
const existingConfig = this.pollingConfigs.get(host.id);
|
||||
|
||||
@@ -1034,10 +1026,10 @@ class PollingManager {
|
||||
this.statusStore.delete(host.id);
|
||||
}
|
||||
|
||||
if (statsConfig.metricsEnabled) {
|
||||
if (!statusOnly && statsConfig.metricsEnabled) {
|
||||
const intervalMs = statsConfig.metricsInterval * 1000;
|
||||
|
||||
this.pollHostMetrics(host);
|
||||
await this.pollHostMetrics(host);
|
||||
|
||||
config.metricsTimer = setInterval(() => {
|
||||
const latestConfig = this.pollingConfigs.get(host.id);
|
||||
@@ -1083,11 +1075,6 @@ class PollingManager {
|
||||
}
|
||||
|
||||
private async pollHostMetrics(host: SSHHostWithCredentials): Promise<void> {
|
||||
if (pollingBackoff.shouldSkip(host.id)) {
|
||||
const backoffInfo = pollingBackoff.getBackoffInfo(host.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshedHost = await fetchHostById(host.id, host.userId);
|
||||
if (!refreshedHost) {
|
||||
statsLogger.warn("Host not found during metrics polling", {
|
||||
@@ -1102,6 +1089,13 @@ class PollingManager {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasExistingMetrics = this.metricsStore.has(refreshedHost.id);
|
||||
|
||||
if (hasExistingMetrics && pollingBackoff.shouldSkip(host.id)) {
|
||||
const backoffInfo = pollingBackoff.getBackoffInfo(host.id);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const metrics = await collectMetrics(refreshedHost);
|
||||
this.metricsStore.set(refreshedHost.id, {
|
||||
@@ -1118,7 +1112,7 @@ class PollingManager {
|
||||
const latestConfig = this.pollingConfigs.get(refreshedHost.id);
|
||||
if (latestConfig && latestConfig.statsConfig.metricsEnabled) {
|
||||
const backoffInfo = pollingBackoff.getBackoffInfo(refreshedHost.id);
|
||||
statsLogger.warn("Failed to collect metrics for host", {
|
||||
statsLogger.error("Failed to collect metrics for host", {
|
||||
operation: "metrics_poll_failed",
|
||||
hostId: refreshedHost.id,
|
||||
hostName: refreshedHost.name,
|
||||
@@ -1176,7 +1170,7 @@ class PollingManager {
|
||||
const hosts = await fetchAllHosts(userId);
|
||||
|
||||
for (const host of hosts) {
|
||||
await this.startPollingForHost(host);
|
||||
await this.startPollingForHost(host, { statusOnly: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1196,7 +1190,7 @@ class PollingManager {
|
||||
}
|
||||
|
||||
for (const host of hosts) {
|
||||
await this.startPollingForHost(host);
|
||||
await this.startPollingForHost(host, { statusOnly: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1538,7 +1532,6 @@ 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}`,
|
||||
@@ -1622,8 +1615,11 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
||||
}
|
||||
|
||||
return requestQueue.queueRequest(host.id, async () => {
|
||||
const sessionKey = getSessionKey(host.id, host.userId!);
|
||||
const existingSession = metricsSessions[sessionKey];
|
||||
|
||||
try {
|
||||
return await withSshConnection(host, async (client) => {
|
||||
const collectFn = async (client: Client) => {
|
||||
const cpu = await collectCpuMetrics(client);
|
||||
const memory = await collectMemoryMetrics(client);
|
||||
const disk = await collectDiskMetrics(client);
|
||||
@@ -1655,7 +1651,20 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
||||
|
||||
metricsCache.set(host.id, result);
|
||||
return result;
|
||||
});
|
||||
};
|
||||
|
||||
if (existingSession && existingSession.isConnected) {
|
||||
existingSession.activeOperations++;
|
||||
try {
|
||||
const result = await collectFn(existingSession.client);
|
||||
existingSession.lastActive = Date.now();
|
||||
return result;
|
||||
} finally {
|
||||
existingSession.activeOperations--;
|
||||
}
|
||||
} else {
|
||||
return await withSshConnection(host, collectFn);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes("TOTP authentication required")) {
|
||||
@@ -1970,13 +1979,14 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
|
||||
});
|
||||
}
|
||||
return;
|
||||
} else if (host.password) {
|
||||
const responses = prompts.map((p) =>
|
||||
/password/i.test(p.prompt) ? host.password || "" : "",
|
||||
);
|
||||
finish(responses);
|
||||
} else {
|
||||
finish(prompts.map(() => ""));
|
||||
const responses = prompts.map((p) => {
|
||||
if (/password/i.test(p.prompt) && host.password) {
|
||||
return host.password;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
finish(responses);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -2012,11 +2022,44 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
|
||||
clearTimeout(timeout);
|
||||
if (!isResolved) {
|
||||
isResolved = true;
|
||||
statsLogger.error("SSH connection error in metrics/start", {
|
||||
operation: "metrics_start_ssh_error",
|
||||
hostId: host.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
client.connect(config);
|
||||
if (
|
||||
host.useSocks5 &&
|
||||
(host.socks5Host ||
|
||||
(host.socks5ProxyChain && host.socks5ProxyChain.length > 0))
|
||||
) {
|
||||
createSocks5Connection(host.ip, host.port, {
|
||||
useSocks5: host.useSocks5,
|
||||
socks5Host: host.socks5Host,
|
||||
socks5Port: host.socks5Port,
|
||||
socks5Username: host.socks5Username,
|
||||
socks5Password: host.socks5Password,
|
||||
socks5ProxyChain: host.socks5ProxyChain,
|
||||
})
|
||||
.then((socks5Socket) => {
|
||||
if (socks5Socket) {
|
||||
config.sock = socks5Socket;
|
||||
}
|
||||
client.connect(config);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!isResolved) {
|
||||
isResolved = true;
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
client.connect(config);
|
||||
}
|
||||
});
|
||||
|
||||
const result = await connectionPromise;
|
||||
@@ -2129,6 +2172,25 @@ app.post("/metrics/connect-totp", async (req, res) => {
|
||||
reject(new Error("TOTP verification timeout"));
|
||||
}, 30000);
|
||||
|
||||
session.client.once(
|
||||
"keyboard-interactive",
|
||||
(name, instructions, instructionsLang, prompts, finish) => {
|
||||
statsLogger.warn("Second keyboard-interactive received after TOTP", {
|
||||
operation: "totp_second_keyboard_interactive",
|
||||
hostId: session.hostId,
|
||||
sessionId,
|
||||
prompts: prompts.map((p) => p.prompt),
|
||||
});
|
||||
const secondResponses = prompts.map((p) => {
|
||||
if (/password/i.test(p.prompt) && session.resolvedPassword) {
|
||||
return session.resolvedPassword;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
finish(secondResponses);
|
||||
},
|
||||
);
|
||||
|
||||
session.client.once("ready", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
@@ -2136,6 +2198,12 @@ app.post("/metrics/connect-totp", async (req, res) => {
|
||||
|
||||
session.client.once("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
statsLogger.error("SSH client error after TOTP", {
|
||||
operation: "totp_client_error",
|
||||
hostId: session.hostId,
|
||||
sessionId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
@@ -2159,15 +2227,15 @@ app.post("/metrics/connect-totp", async (req, res) => {
|
||||
|
||||
const host = await fetchHostById(session.hostId, userId);
|
||||
if (host) {
|
||||
await pollingManager.startPollingForHost(host);
|
||||
pollingManager.startPollingForHost(host).catch((error) => {
|
||||
statsLogger.error("Failed to start polling after TOTP", {
|
||||
operation: "totp_polling_start_error",
|
||||
hostId: session.hostId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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", {
|
||||
|
||||
@@ -2395,4 +2395,4 @@
|
||||
"switchToLight": "قم بتشغيل الضوء",
|
||||
"switchToDark": "التبديل إلى الوضع الداكن"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2395,4 +2395,4 @@
|
||||
"switchToLight": "আলোতে স্যুইচ করুন",
|
||||
"switchToDark": "ডার্ক এ স্যুইচ করুন"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2395,4 +2395,4 @@
|
||||
"switchToLight": "Přepnout na světlo",
|
||||
"switchToDark": "Přepnout na tmavou"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2395,4 +2395,4 @@
|
||||
"switchToLight": "Auf Licht umschalten",
|
||||
"switchToDark": "Auf Dunkel umschalten"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2395,4 +2395,4 @@
|
||||
"switchToLight": "Αλλαγή σε Φως",
|
||||
"switchToDark": "Αλλαγή σε Σκούρο"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1696,6 +1696,7 @@
|
||||
"totpInvalidCode": "Invalid verification code",
|
||||
"totpCancelled": "Metrics collection cancelled",
|
||||
"authenticationFailed": "Authentication failed",
|
||||
"noneAuthNotSupported": "Server Stats does not support 'none' authentication type.",
|
||||
"load": "Load",
|
||||
"editLayout": "Edit Layout",
|
||||
"cancelEdit": "Cancel",
|
||||
|
||||
@@ -2395,4 +2395,4 @@
|
||||
"switchToLight": "Cambiar a la luz",
|
||||
"switchToDark": "Cambiar a oscuro"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2395,4 +2395,4 @@
|
||||
"switchToLight": "Passer à la lumière",
|
||||
"switchToDark": "Passer au mode sombre"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2395,4 +2395,4 @@
|
||||
"switchToLight": "מעבר לתאורה",
|
||||
"switchToDark": "עבור למצב כהה"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2395,4 +2395,4 @@
|
||||
"switchToLight": "लाइट पर स्विच करें",
|
||||
"switchToDark": "डार्क मोड पर स्विच करें"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2395,4 +2395,4 @@
|
||||
"switchToLight": "Beralih ke Cahaya",
|
||||
"switchToDark": "Beralih ke Gelap"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2395,4 +2395,4 @@
|
||||
"switchToLight": "Passa alla luce",
|
||||
"switchToDark": "Passa a Scuro"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2395,4 +2395,4 @@
|
||||
"switchToLight": "光に切り替える",
|
||||
"switchToDark": "ダークに切り替える"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2395,4 +2395,4 @@
|
||||
"switchToLight": "조명으로 전환",
|
||||
"switchToDark": "어둡게 전환"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2395,4 +2395,4 @@
|
||||
"switchToLight": "Bytt til lys",
|
||||
"switchToDark": "Bytt til mørkt"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2395,4 +2395,4 @@
|
||||
"switchToLight": "Schakel over naar Licht",
|
||||
"switchToDark": "Schakel over naar Donker"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2395,4 +2395,4 @@
|
||||
"switchToLight": "Przełącz na światło",
|
||||
"switchToDark": "Przełącz na ciemność"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2395,4 +2395,4 @@
|
||||
"switchToLight": "Alternar para luz",
|
||||
"switchToDark": "Mudar para o modo escuro"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2395,4 +2395,4 @@
|
||||
"switchToLight": "Comutare la lumină",
|
||||
"switchToDark": "Comutare la Întuneric"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2395,4 +2395,4 @@
|
||||
"switchToLight": "Переключиться на свет",
|
||||
"switchToDark": "Переключиться на темный"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2395,4 +2395,4 @@
|
||||
"switchToLight": "Växla till ljus",
|
||||
"switchToDark": "Växla till mörkt"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2395,4 +2395,4 @@
|
||||
"switchToLight": "เปลี่ยนเป็นโหมดสว่าง",
|
||||
"switchToDark": "เปลี่ยนเป็นโหมดมืด"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2395,4 +2395,4 @@
|
||||
"switchToLight": "Işığa geç",
|
||||
"switchToDark": "Koyu moda geç"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2395,4 +2395,4 @@
|
||||
"switchToLight": "Перейти на світлий режим",
|
||||
"switchToDark": "Перейти на темний режим"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2395,4 +2395,4 @@
|
||||
"switchToLight": "Chuyển sang chế độ sáng",
|
||||
"switchToDark": "Chuyển sang chế độ Tối"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2395,4 +2395,4 @@
|
||||
"switchToLight": "切换到灯光",
|
||||
"switchToDark": "切换到黑暗模式"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,7 +389,7 @@ export interface TabContextTab {
|
||||
| "home"
|
||||
| "terminal"
|
||||
| "ssh_manager"
|
||||
| "server"
|
||||
| "server_stats"
|
||||
| "admin"
|
||||
| "file_manager"
|
||||
| "user_profile"
|
||||
|
||||
@@ -180,7 +180,7 @@ function AppContent() {
|
||||
const currentTabData = tabs.find((tab) => tab.id === currentTab);
|
||||
const showTerminalView =
|
||||
currentTabData?.type === "terminal" ||
|
||||
currentTabData?.type === "server" ||
|
||||
currentTabData?.type === "server_stats" ||
|
||||
currentTabData?.type === "file_manager" ||
|
||||
currentTabData?.type === "tunnel" ||
|
||||
currentTabData?.type === "docker";
|
||||
|
||||
@@ -223,7 +223,7 @@ export function CommandPalette({
|
||||
const title = host.name?.trim()
|
||||
? host.name
|
||||
: `${host.username}@${host.ip}:${host.port}`;
|
||||
addTab({ type: "server", title, hostConfig: host });
|
||||
addTab({ type: "server_stats", title, hostConfig: host });
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
|
||||
@@ -36,6 +36,9 @@ import {
|
||||
Loader2,
|
||||
Terminal,
|
||||
FolderOpen,
|
||||
Activity,
|
||||
Container,
|
||||
ArrowDownUp,
|
||||
} from "lucide-react";
|
||||
import { Status } from "@/components/ui/shadcn-io/status";
|
||||
import { BsLightning } from "react-icons/bs";
|
||||
@@ -297,6 +300,24 @@ export function Dashboard({
|
||||
title: item.hostName,
|
||||
hostConfig: host,
|
||||
});
|
||||
} else if (item.type === "server_stats") {
|
||||
addTab({
|
||||
type: "server_stats",
|
||||
title: item.hostName,
|
||||
hostConfig: host,
|
||||
});
|
||||
} else if (item.type === "tunnel") {
|
||||
addTab({
|
||||
type: "tunnel",
|
||||
title: item.hostName,
|
||||
hostConfig: host,
|
||||
});
|
||||
} else if (item.type === "docker") {
|
||||
addTab({
|
||||
type: "docker",
|
||||
title: item.hostName,
|
||||
hostConfig: host,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -307,7 +328,7 @@ export function Dashboard({
|
||||
if (!host) return;
|
||||
|
||||
addTab({
|
||||
type: "server",
|
||||
type: "server_stats",
|
||||
title: serverName,
|
||||
hostConfig: host,
|
||||
});
|
||||
@@ -544,7 +565,7 @@ export function Dashboard({
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-between bg-canvas w-full h-auto mt-3 border-2 border-edge rounded-md px-3 py-3 min-w-0 gap-2">
|
||||
<div className="flex flex-row items-center min-w-0">
|
||||
<Network size={16} className="mr-3 shrink-0" />
|
||||
<ArrowDownUp size={16} className="mr-3 shrink-0" />
|
||||
<p className="m-0 leading-none truncate">
|
||||
{t("dashboard.totalTunnels")}
|
||||
</p>
|
||||
@@ -617,8 +638,16 @@ export function Dashboard({
|
||||
>
|
||||
{item.type === "terminal" ? (
|
||||
<Terminal size={20} className="shrink-0" />
|
||||
) : (
|
||||
) : item.type === "file_manager" ? (
|
||||
<FolderOpen size={20} className="shrink-0" />
|
||||
) : item.type === "server_stats" ? (
|
||||
<Server size={20} className="shrink-0" />
|
||||
) : item.type === "tunnel" ? (
|
||||
<ArrowDownUp size={20} className="shrink-0" />
|
||||
) : item.type === "docker" ? (
|
||||
<Container size={20} className="shrink-0" />
|
||||
) : (
|
||||
<Terminal size={20} className="shrink-0" />
|
||||
)}
|
||||
<p className="truncate ml-2 font-semibold">
|
||||
{item.hostName}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
validateDockerAvailability,
|
||||
keepaliveDockerSession,
|
||||
verifyDockerTOTP,
|
||||
logActivity,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
@@ -25,6 +26,7 @@ import { ContainerList } from "./components/ContainerList.tsx";
|
||||
import { ContainerDetail } from "./components/ContainerDetail.tsx";
|
||||
import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx";
|
||||
import { SSHAuthDialog } from "@/ui/desktop/navigation/SSHAuthDialog.tsx";
|
||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
|
||||
interface DockerManagerProps {
|
||||
hostConfig?: SSHHost;
|
||||
@@ -35,6 +37,12 @@ interface DockerManagerProps {
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
interface TabData {
|
||||
id: number;
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function DockerManager({
|
||||
hostConfig,
|
||||
title,
|
||||
@@ -45,6 +53,10 @@ export function DockerManager({
|
||||
}: DockerManagerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { state: sidebarState } = useSidebar();
|
||||
const { currentTab, removeTab } = useTabs() as {
|
||||
currentTab: number | null;
|
||||
removeTab: (tabId: number) => void;
|
||||
};
|
||||
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
||||
const [sessionId, setSessionId] = React.useState<string | null>(null);
|
||||
const [containers, setContainers] = React.useState<DockerContainer[]>([]);
|
||||
@@ -66,6 +78,34 @@ export function DockerManager({
|
||||
"no_keyboard" | "auth_failed" | "timeout"
|
||||
>("no_keyboard");
|
||||
|
||||
const activityLoggedRef = React.useRef(false);
|
||||
const activityLoggingRef = React.useRef(false);
|
||||
|
||||
const logDockerActivity = async () => {
|
||||
if (
|
||||
!currentHostConfig?.id ||
|
||||
activityLoggedRef.current ||
|
||||
activityLoggingRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
activityLoggingRef.current = true;
|
||||
activityLoggedRef.current = true;
|
||||
|
||||
try {
|
||||
const hostName =
|
||||
currentHostConfig.name ||
|
||||
`${currentHostConfig.username}@${currentHostConfig.ip}`;
|
||||
await logActivity("docker", currentHostConfig.id, hostName);
|
||||
} catch (err) {
|
||||
console.warn("Failed to log docker activity:", err);
|
||||
activityLoggedRef.current = false;
|
||||
} finally {
|
||||
activityLoggingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hostConfig?.id !== currentHostConfig?.id) {
|
||||
setCurrentHostConfig(hostConfig);
|
||||
@@ -172,6 +212,8 @@ export function DockerManager({
|
||||
toast.error(
|
||||
validation.error || "Docker is not available on this host",
|
||||
);
|
||||
} else {
|
||||
logDockerActivity();
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
@@ -279,6 +321,8 @@ export function DockerManager({
|
||||
toast.error(
|
||||
validation.error || "Docker is not available on this host",
|
||||
);
|
||||
} else {
|
||||
logDockerActivity();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -294,6 +338,9 @@ export function DockerManager({
|
||||
setTotpSessionId(null);
|
||||
setTotpPrompt("");
|
||||
setIsConnecting(false);
|
||||
if (currentTab !== null) {
|
||||
removeTab(currentTab);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAuthSubmit = async (credentials: {
|
||||
@@ -345,6 +392,8 @@ export function DockerManager({
|
||||
|
||||
if (!validation.available) {
|
||||
toast.error(validation.error || "Docker is not available on this host");
|
||||
} else {
|
||||
logDockerActivity();
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to connect");
|
||||
@@ -422,15 +471,13 @@ export function DockerManager({
|
||||
</div>
|
||||
<Separator className="p-0.25 w-full" />
|
||||
|
||||
<div className="flex-1 overflow-hidden min-h-0 p-4 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<SimpleLoader size="lg" />
|
||||
<p className="text-muted-foreground mt-4">
|
||||
{isValidating
|
||||
? t("docker.validating")
|
||||
: t("docker.connectingToHost")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden min-h-0 relative">
|
||||
<SimpleLoader
|
||||
visible={true}
|
||||
message={
|
||||
isValidating ? t("docker.validating") : t("docker.connecting")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -490,19 +537,15 @@ export function DockerManager({
|
||||
</div>
|
||||
<Separator className="p-0.25 w-full" />
|
||||
|
||||
<div className="flex-1 overflow-hidden min-h-0">
|
||||
<div className="flex-1 overflow-hidden min-h-0 relative">
|
||||
{viewMode === "list" ? (
|
||||
<div className="h-full px-4 py-4">
|
||||
{sessionId ? (
|
||||
isLoadingContainers && containers.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<SimpleLoader size="lg" />
|
||||
<p className="text-muted-foreground mt-4">
|
||||
{t("docker.loadingContainers")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<SimpleLoader
|
||||
visible={true}
|
||||
message={t("docker.loadingContainers")}
|
||||
/>
|
||||
) : (
|
||||
<ContainerList
|
||||
containers={containers}
|
||||
|
||||
@@ -64,11 +64,11 @@ export function ConsoleTerminal({
|
||||
"http://127.0.0.1:30001";
|
||||
const wsProtocol = baseUrl.startsWith("https://") ? "wss://" : "ws://";
|
||||
const wsHost = baseUrl.replace(/^https?:\/\//, "");
|
||||
return `${wsProtocol}${wsHost}/docker/console/`;
|
||||
return `${wsProtocol}${wsHost}/docker/console`;
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${protocol}//${window.location.host}/docker/console/`;
|
||||
return `${protocol}//${window.location.host}/docker/console`;
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -1320,7 +1320,7 @@ export function FileManagerGrid({
|
||||
document.body,
|
||||
)}
|
||||
|
||||
<SimpleLoader visible={isLoading} message={t("common.loading")} />
|
||||
<SimpleLoader visible={isLoading} message={t("common.connecting")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
stopMetricsPolling,
|
||||
submitMetricsTOTP,
|
||||
executeSnippet,
|
||||
logActivity,
|
||||
type ServerMetrics,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx";
|
||||
@@ -76,9 +77,11 @@ export function ServerStats({
|
||||
}: ServerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { state: sidebarState } = useSidebar();
|
||||
const { addTab, tabs } = useTabs() as {
|
||||
const { addTab, tabs, currentTab, removeTab } = useTabs() as {
|
||||
addTab: (tab: { type: string; [key: string]: unknown }) => number;
|
||||
tabs: TabData[];
|
||||
currentTab: number | null;
|
||||
removeTab: (tabId: number) => void;
|
||||
};
|
||||
const [serverStatus, setServerStatus] = React.useState<"online" | "offline">(
|
||||
"offline",
|
||||
@@ -98,6 +101,10 @@ export function ServerStats({
|
||||
const [totpSessionId, setTotpSessionId] = React.useState<string | null>(null);
|
||||
const [totpPrompt, setTotpPrompt] = React.useState<string>("");
|
||||
const [isPageVisible, setIsPageVisible] = React.useState(!document.hidden);
|
||||
const [totpVerified, setTotpVerified] = React.useState(false);
|
||||
|
||||
const activityLoggedRef = React.useRef(false);
|
||||
const activityLoggingRef = React.useRef(false);
|
||||
|
||||
const statsConfig = React.useMemo((): StatsConfig => {
|
||||
if (!currentHostConfig?.statsConfig) {
|
||||
@@ -140,6 +147,31 @@ export function ServerStats({
|
||||
setCurrentHostConfig(hostConfig);
|
||||
}, [hostConfig?.id]);
|
||||
|
||||
const logServerActivity = async () => {
|
||||
if (
|
||||
!currentHostConfig?.id ||
|
||||
activityLoggedRef.current ||
|
||||
activityLoggingRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
activityLoggingRef.current = true;
|
||||
activityLoggedRef.current = true;
|
||||
|
||||
try {
|
||||
const hostName =
|
||||
currentHostConfig.name ||
|
||||
`${currentHostConfig.username}@${currentHostConfig.ip}`;
|
||||
await logActivity("server_stats", currentHostConfig.id, hostName);
|
||||
} catch (err) {
|
||||
console.warn("Failed to log server stats activity:", err);
|
||||
activityLoggedRef.current = false;
|
||||
} finally {
|
||||
activityLoggingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleTOTPSubmit = async (totpCode: string) => {
|
||||
if (!totpSessionId || !currentHostConfig) return;
|
||||
|
||||
@@ -147,10 +179,11 @@ export function ServerStats({
|
||||
const result = await submitMetricsTOTP(totpSessionId, totpCode);
|
||||
if (result.success) {
|
||||
setTotpRequired(false);
|
||||
toast.success(t("serverStats.totpVerified"));
|
||||
const data = await getServerMetricsById(currentHostConfig.id);
|
||||
setMetrics(data);
|
||||
setTotpSessionId(null);
|
||||
setShowStatsUI(true);
|
||||
setTotpVerified(true);
|
||||
} else {
|
||||
toast.error(t("serverStats.totpFailed"));
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("serverStats.totpFailed"));
|
||||
@@ -167,6 +200,9 @@ export function ServerStats({
|
||||
console.error("Failed to stop metrics polling:", error);
|
||||
}
|
||||
}
|
||||
if (currentTab !== null) {
|
||||
removeTab(currentTab);
|
||||
}
|
||||
};
|
||||
|
||||
const renderWidget = (widgetType: WidgetType) => {
|
||||
@@ -301,7 +337,6 @@ export function ServerStats({
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!metricsEnabled || !currentHostConfig?.id) {
|
||||
setShowStatsUI(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -309,29 +344,77 @@ export function ServerStats({
|
||||
let pollingIntervalId: number | undefined;
|
||||
let debounceTimeout: NodeJS.Timeout | undefined;
|
||||
|
||||
if (isActuallyVisible && !metrics) {
|
||||
setIsLoadingMetrics(true);
|
||||
setShowStatsUI(true);
|
||||
} else if (!isActuallyVisible) {
|
||||
setIsLoadingMetrics(false);
|
||||
}
|
||||
|
||||
const startMetrics = async () => {
|
||||
if (cancelled) return;
|
||||
|
||||
setIsLoadingMetrics(true);
|
||||
if (currentHostConfig.authType === "none") {
|
||||
toast.error(t("serverStats.noneAuthNotSupported"));
|
||||
setIsLoadingMetrics(false);
|
||||
if (currentTab !== null) {
|
||||
removeTab(currentTab);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const hasExistingMetrics = metrics !== null;
|
||||
|
||||
if (!hasExistingMetrics) {
|
||||
setIsLoadingMetrics(true);
|
||||
}
|
||||
setShowStatsUI(true);
|
||||
|
||||
try {
|
||||
const result = await startMetricsPolling(currentHostConfig.id);
|
||||
if (!totpVerified) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
let retryCount = 0;
|
||||
let data = null;
|
||||
const maxRetries = 15;
|
||||
const initialDelay = totpVerified ? 3000 : 5000;
|
||||
const retryDelay = 2000;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, initialDelay));
|
||||
|
||||
while (retryCount < maxRetries && !cancelled) {
|
||||
try {
|
||||
data = await getServerMetricsById(currentHostConfig.id);
|
||||
break;
|
||||
} catch (error: any) {
|
||||
retryCount++;
|
||||
if (retryCount < maxRetries && !cancelled) {
|
||||
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
if (data) {
|
||||
setMetrics(data);
|
||||
setShowStatsUI(true);
|
||||
setIsLoadingMetrics(false);
|
||||
if (!hasExistingMetrics) {
|
||||
setIsLoadingMetrics(false);
|
||||
logServerActivity();
|
||||
}
|
||||
}
|
||||
|
||||
pollingIntervalId = window.setInterval(async () => {
|
||||
@@ -355,8 +438,10 @@ export function ServerStats({
|
||||
if (!cancelled) {
|
||||
console.error("Failed to start metrics polling:", error);
|
||||
setIsLoadingMetrics(false);
|
||||
setShowStatsUI(false);
|
||||
toast.error(t("serverStats.failedToFetchMetrics"));
|
||||
if (currentTab !== null) {
|
||||
removeTab(currentTab);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -396,6 +481,7 @@ export function ServerStats({
|
||||
isActuallyVisible,
|
||||
metricsEnabled,
|
||||
statsConfig.metricsInterval,
|
||||
totpVerified,
|
||||
]);
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 16;
|
||||
@@ -427,155 +513,162 @@ export function ServerStats({
|
||||
: "bg-canvas text-foreground rounded-lg border-2 border-edge overflow-hidden";
|
||||
|
||||
return (
|
||||
<div style={wrapperStyle} className={containerClass}>
|
||||
<div style={wrapperStyle} className={`${containerClass} relative`}>
|
||||
<div className="h-full w-full flex flex-col">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-bold text-lg truncate">
|
||||
{currentHostConfig?.folder} / {title}
|
||||
</h1>
|
||||
</div>
|
||||
{statusCheckEnabled && (
|
||||
<Status
|
||||
status={serverStatus}
|
||||
className="!bg-transparent !p-0.75 flex-shrink-0"
|
||||
>
|
||||
<StatusIndicator />
|
||||
</Status>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isRefreshing}
|
||||
className="font-semibold"
|
||||
onClick={async () => {
|
||||
if (currentHostConfig?.id) {
|
||||
try {
|
||||
setIsRefreshing(true);
|
||||
const res = await getServerStatusById(currentHostConfig.id);
|
||||
setServerStatus(
|
||||
res?.status === "online" ? "online" : "offline",
|
||||
);
|
||||
const data = await getServerMetricsById(
|
||||
currentHostConfig.id,
|
||||
);
|
||||
setMetrics(data);
|
||||
setShowStatsUI(true);
|
||||
} catch (error: unknown) {
|
||||
const err = error as {
|
||||
code?: string;
|
||||
status?: number;
|
||||
response?: { status?: number; data?: { error?: string } };
|
||||
};
|
||||
if (
|
||||
err?.code === "TOTP_REQUIRED" ||
|
||||
(err?.response?.status === 403 &&
|
||||
err?.response?.data?.error === "TOTP_REQUIRED")
|
||||
) {
|
||||
toast.error(t("serverStats.totpUnavailable"));
|
||||
setMetrics(null);
|
||||
setShowStatsUI(false);
|
||||
} else if (
|
||||
err?.response?.status === 503 ||
|
||||
err?.status === 503
|
||||
) {
|
||||
setServerStatus("offline");
|
||||
setMetrics(null);
|
||||
setShowStatsUI(false);
|
||||
} else if (
|
||||
err?.response?.status === 504 ||
|
||||
err?.status === 504
|
||||
) {
|
||||
setServerStatus("offline");
|
||||
setMetrics(null);
|
||||
setShowStatsUI(false);
|
||||
} else if (
|
||||
err?.response?.status === 404 ||
|
||||
err?.status === 404
|
||||
) {
|
||||
setServerStatus("offline");
|
||||
setMetrics(null);
|
||||
setShowStatsUI(false);
|
||||
} else {
|
||||
setServerStatus("offline");
|
||||
setMetrics(null);
|
||||
setShowStatsUI(false);
|
||||
}
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}
|
||||
}}
|
||||
title={t("serverStats.refreshStatusAndMetrics")}
|
||||
>
|
||||
{isRefreshing ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 border-2 border-foreground-secondary border-t-transparent rounded-full animate-spin"></div>
|
||||
{t("serverStats.refreshing")}
|
||||
</div>
|
||||
) : (
|
||||
t("serverStats.refreshStatus")
|
||||
{!totpRequired && (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-bold text-lg truncate">
|
||||
{currentHostConfig?.folder} / {title}
|
||||
</h1>
|
||||
</div>
|
||||
{statusCheckEnabled && (
|
||||
<Status
|
||||
status={serverStatus}
|
||||
className="!bg-transparent !p-0.75 flex-shrink-0"
|
||||
>
|
||||
<StatusIndicator />
|
||||
</Status>
|
||||
)}
|
||||
</Button>
|
||||
{currentHostConfig?.enableFileManager && (
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isRefreshing}
|
||||
className="font-semibold"
|
||||
disabled={isFileManagerAlreadyOpen}
|
||||
title={
|
||||
isFileManagerAlreadyOpen
|
||||
? t("serverStats.fileManagerAlreadyOpen")
|
||||
: t("serverStats.openFileManager")
|
||||
}
|
||||
onClick={() => {
|
||||
if (!currentHostConfig || isFileManagerAlreadyOpen) return;
|
||||
const titleBase =
|
||||
currentHostConfig?.name &&
|
||||
currentHostConfig.name.trim() !== ""
|
||||
? currentHostConfig.name.trim()
|
||||
: `${currentHostConfig.username}@${currentHostConfig.ip}`;
|
||||
addTab({
|
||||
type: "file_manager",
|
||||
title: titleBase,
|
||||
hostConfig: currentHostConfig,
|
||||
});
|
||||
onClick={async () => {
|
||||
if (currentHostConfig?.id) {
|
||||
try {
|
||||
setIsRefreshing(true);
|
||||
const res = await getServerStatusById(
|
||||
currentHostConfig.id,
|
||||
);
|
||||
setServerStatus(
|
||||
res?.status === "online" ? "online" : "offline",
|
||||
);
|
||||
const data = await getServerMetricsById(
|
||||
currentHostConfig.id,
|
||||
);
|
||||
setMetrics(data);
|
||||
setShowStatsUI(true);
|
||||
} catch (error: unknown) {
|
||||
const err = error as {
|
||||
code?: string;
|
||||
status?: number;
|
||||
response?: {
|
||||
status?: number;
|
||||
data?: { error?: string };
|
||||
};
|
||||
};
|
||||
if (
|
||||
err?.code === "TOTP_REQUIRED" ||
|
||||
(err?.response?.status === 403 &&
|
||||
err?.response?.data?.error === "TOTP_REQUIRED")
|
||||
) {
|
||||
toast.error(t("serverStats.totpUnavailable"));
|
||||
setMetrics(null);
|
||||
setShowStatsUI(false);
|
||||
} else if (
|
||||
err?.response?.status === 503 ||
|
||||
err?.status === 503
|
||||
) {
|
||||
setServerStatus("offline");
|
||||
setMetrics(null);
|
||||
setShowStatsUI(false);
|
||||
} else if (
|
||||
err?.response?.status === 504 ||
|
||||
err?.status === 504
|
||||
) {
|
||||
setServerStatus("offline");
|
||||
setMetrics(null);
|
||||
setShowStatsUI(false);
|
||||
} else if (
|
||||
err?.response?.status === 404 ||
|
||||
err?.status === 404
|
||||
) {
|
||||
setServerStatus("offline");
|
||||
setMetrics(null);
|
||||
setShowStatsUI(false);
|
||||
} else {
|
||||
setServerStatus("offline");
|
||||
setMetrics(null);
|
||||
setShowStatsUI(false);
|
||||
}
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}
|
||||
}}
|
||||
title={t("serverStats.refreshStatusAndMetrics")}
|
||||
>
|
||||
{t("nav.fileManager")}
|
||||
{isRefreshing ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 border-2 border-foreground-secondary border-t-transparent rounded-full animate-spin"></div>
|
||||
{t("serverStats.refreshing")}
|
||||
</div>
|
||||
) : (
|
||||
t("serverStats.refreshStatus")
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{currentHostConfig?.enableFileManager && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="font-semibold"
|
||||
disabled={isFileManagerAlreadyOpen}
|
||||
title={
|
||||
isFileManagerAlreadyOpen
|
||||
? t("serverStats.fileManagerAlreadyOpen")
|
||||
: t("serverStats.openFileManager")
|
||||
}
|
||||
onClick={() => {
|
||||
if (!currentHostConfig || isFileManagerAlreadyOpen) return;
|
||||
const titleBase =
|
||||
currentHostConfig?.name &&
|
||||
currentHostConfig.name.trim() !== ""
|
||||
? currentHostConfig.name.trim()
|
||||
: `${currentHostConfig.username}@${currentHostConfig.ip}`;
|
||||
addTab({
|
||||
type: "file_manager",
|
||||
title: titleBase,
|
||||
hostConfig: currentHostConfig,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("nav.fileManager")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{currentHostConfig?.enableDocker && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="font-semibold"
|
||||
onClick={() => {
|
||||
const titleBase =
|
||||
currentHostConfig?.name &&
|
||||
currentHostConfig.name.trim() !== ""
|
||||
? currentHostConfig.name.trim()
|
||||
: `${currentHostConfig.username}@${currentHostConfig.ip}`;
|
||||
addTab({
|
||||
type: "docker",
|
||||
title: titleBase,
|
||||
hostConfig: currentHostConfig,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("nav.docker")}
|
||||
</Button>
|
||||
)}
|
||||
{currentHostConfig?.enableDocker && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="font-semibold"
|
||||
onClick={() => {
|
||||
const titleBase =
|
||||
currentHostConfig?.name &&
|
||||
currentHostConfig.name.trim() !== ""
|
||||
? currentHostConfig.name.trim()
|
||||
: `${currentHostConfig.username}@${currentHostConfig.ip}`;
|
||||
addTab({
|
||||
type: "docker",
|
||||
title: titleBase,
|
||||
hostConfig: currentHostConfig,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("nav.docker")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="p-0.25 w-full" />
|
||||
)}
|
||||
{!totpRequired && <Separator className="p-0.25 w-full" />}
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0 thin-scrollbar">
|
||||
<div className="flex-1 overflow-y-auto min-h-0 thin-scrollbar relative">
|
||||
{(metricsEnabled && showStatsUI) ||
|
||||
(currentHostConfig?.quickActions &&
|
||||
currentHostConfig.quickActions.length > 0) ? (
|
||||
<div className="border-edge m-1 p-2 overflow-y-auto thin-scrollbar relative flex-1 flex flex-col">
|
||||
<div className="border-edge m-1 p-2 overflow-y-auto thin-scrollbar flex-1 flex flex-col">
|
||||
{currentHostConfig?.quickActions &&
|
||||
currentHostConfig.quickActions.length > 0 && (
|
||||
<div className={metricsEnabled && showStatsUI ? "mb-4" : ""}>
|
||||
@@ -681,6 +774,7 @@ export function ServerStats({
|
||||
)}
|
||||
{metricsEnabled &&
|
||||
showStatsUI &&
|
||||
!isLoadingMetrics &&
|
||||
(!metrics && serverStatus === "offline" ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-center">
|
||||
@@ -695,7 +789,7 @@ export function ServerStats({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
) : metrics ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{enabledWidgets.map((widgetType) => (
|
||||
<div key={widgetType} className="h-[280px]">
|
||||
@@ -703,28 +797,26 @@ export function ServerStats({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{metricsEnabled && showStatsUI && (
|
||||
<SimpleLoader
|
||||
visible={isLoadingMetrics && !metrics}
|
||||
message={t("serverStats.loadingMetrics")}
|
||||
/>
|
||||
)}
|
||||
) : null)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{metricsEnabled && (
|
||||
<SimpleLoader
|
||||
visible={isLoadingMetrics && !metrics}
|
||||
message={t("serverStats.connecting")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{totpRequired && (
|
||||
<TOTPDialog
|
||||
isOpen={totpRequired}
|
||||
prompt={totpPrompt}
|
||||
onSubmit={handleTOTPSubmit}
|
||||
onCancel={handleTOTPCancel}
|
||||
backgroundColor="var(--bg-canvas)"
|
||||
/>
|
||||
)}
|
||||
<TOTPDialog
|
||||
isOpen={totpRequired}
|
||||
prompt={totpPrompt}
|
||||
onSubmit={handleTOTPSubmit}
|
||||
onCancel={handleTOTPCancel}
|
||||
backgroundColor="var(--bg-canvas)"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
connectTunnel,
|
||||
disconnectTunnel,
|
||||
cancelTunnel,
|
||||
logActivity,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import type {
|
||||
SSHHost,
|
||||
@@ -27,6 +28,8 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
|
||||
);
|
||||
|
||||
const prevVisibleHostRef = React.useRef<SSHHost | null>(null);
|
||||
const activityLoggedRef = React.useRef(false);
|
||||
const activityLoggingRef = React.useRef(false);
|
||||
|
||||
const haveTunnelConnectionsChanged = (
|
||||
a: TunnelConnection[] = [],
|
||||
@@ -88,6 +91,25 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
|
||||
}
|
||||
}, [filterHostKey]);
|
||||
|
||||
const logTunnelActivity = async (host: SSHHost) => {
|
||||
if (!host?.id || activityLoggedRef.current || activityLoggingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
activityLoggingRef.current = true;
|
||||
activityLoggedRef.current = true;
|
||||
|
||||
try {
|
||||
const hostName = host.name || `${host.username}@${host.ip}`;
|
||||
await logActivity("tunnel", host.id, hostName);
|
||||
} catch (err) {
|
||||
console.warn("Failed to log tunnel activity:", err);
|
||||
activityLoggedRef.current = false;
|
||||
} finally {
|
||||
activityLoggingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTunnelStatuses = useCallback(async () => {
|
||||
const statusData = await getTunnelStatuses();
|
||||
setTunnelStatuses(statusData);
|
||||
@@ -120,6 +142,12 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchTunnelStatuses]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visibleHosts.length > 0 && visibleHosts[0]) {
|
||||
logTunnelActivity(visibleHosts[0]);
|
||||
}
|
||||
}, [visibleHosts.length > 0 ? visibleHosts[0]?.id : null]);
|
||||
|
||||
const handleTunnelAction = async (
|
||||
action: "connect" | "disconnect" | "cancel",
|
||||
host: SSHHost,
|
||||
|
||||
@@ -1537,7 +1537,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
? host.name
|
||||
: `${host.username}@${host.ip}:${host.port}`;
|
||||
addTab({
|
||||
type: "server",
|
||||
type: "server_stats",
|
||||
title,
|
||||
hostConfig: host,
|
||||
});
|
||||
|
||||
@@ -246,7 +246,7 @@ export function SSHToolsSidebar({
|
||||
const splittableTabs = tabs.filter(
|
||||
(tab: TabData) =>
|
||||
tab.type === "terminal" ||
|
||||
tab.type === "server" ||
|
||||
tab.type === "server_stats" ||
|
||||
tab.type === "file_manager" ||
|
||||
tab.type === "tunnel" ||
|
||||
tab.type === "docker",
|
||||
|
||||
@@ -67,7 +67,7 @@ export function AppView({
|
||||
tabs.filter(
|
||||
(tab: TabData) =>
|
||||
tab.type === "terminal" ||
|
||||
tab.type === "server" ||
|
||||
tab.type === "server_stats" ||
|
||||
tab.type === "file_manager" ||
|
||||
tab.type === "tunnel" ||
|
||||
tab.type === "docker",
|
||||
@@ -345,7 +345,7 @@ export function AppView({
|
||||
splitScreen={allSplitScreenTab.length > 0}
|
||||
onClose={() => removeTab(t.id)}
|
||||
/>
|
||||
) : t.type === "server" ? (
|
||||
) : t.type === "server_stats" ? (
|
||||
<ServerView
|
||||
hostConfig={t.hostConfig}
|
||||
title={t.title}
|
||||
|
||||
@@ -367,7 +367,7 @@ export function TopNavbar({
|
||||
Array.isArray(allSplitScreenTab) &&
|
||||
allSplitScreenTab.includes(tab.id);
|
||||
const isTerminal = tab.type === "terminal";
|
||||
const isServer = tab.type === "server";
|
||||
const isServer = tab.type === "server_stats";
|
||||
const isFileManager = tab.type === "file_manager";
|
||||
const isTunnel = tab.type === "tunnel";
|
||||
const isDocker = tab.type === "docker";
|
||||
|
||||
@@ -186,7 +186,7 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
||||
{shouldShowMetrics && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
addTab({ type: "server", title, hostConfig: host })
|
||||
addTab({ type: "server_stats", title, hostConfig: host })
|
||||
}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||
>
|
||||
|
||||
@@ -119,13 +119,13 @@ export function Tab({
|
||||
|
||||
if (
|
||||
tabType === "terminal" ||
|
||||
tabType === "server" ||
|
||||
tabType === "server_stats" ||
|
||||
tabType === "file_manager" ||
|
||||
tabType === "tunnel" ||
|
||||
tabType === "docker" ||
|
||||
tabType === "user_profile"
|
||||
) {
|
||||
const isServer = tabType === "server";
|
||||
const isServer = tabType === "server_stats";
|
||||
const isFileManager = tabType === "file_manager";
|
||||
const isTunnel = tabType === "tunnel";
|
||||
const isDocker = tabType === "docker";
|
||||
|
||||
@@ -71,7 +71,7 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
desiredTitle: string | undefined,
|
||||
): string {
|
||||
const defaultTitle =
|
||||
tabType === "server"
|
||||
tabType === "server_stats"
|
||||
? t("nav.serverStats")
|
||||
: tabType === "file_manager"
|
||||
? t("nav.fileManager")
|
||||
@@ -139,7 +139,7 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
const id = nextTabId.current++;
|
||||
const needsUniqueTitle =
|
||||
tabData.type === "terminal" ||
|
||||
tabData.type === "server" ||
|
||||
tabData.type === "server_stats" ||
|
||||
tabData.type === "file_manager" ||
|
||||
tabData.type === "tunnel" ||
|
||||
tabData.type === "docker";
|
||||
|
||||
@@ -31,7 +31,7 @@ export function TabDropdown(): React.ReactElement {
|
||||
return <Home className="h-4 w-4" />;
|
||||
case "terminal":
|
||||
return <TerminalIcon className="h-4 w-4" />;
|
||||
case "server":
|
||||
case "server_stats":
|
||||
return <ServerIcon className="h-4 w-4" />;
|
||||
case "file_manager":
|
||||
return <FolderIcon className="h-4 w-4" />;
|
||||
@@ -54,7 +54,7 @@ export function TabDropdown(): React.ReactElement {
|
||||
switch (tab.type) {
|
||||
case "home":
|
||||
return t("nav.home");
|
||||
case "server":
|
||||
case "server_stats":
|
||||
return tab.title || t("nav.serverStats");
|
||||
case "file_manager":
|
||||
return tab.title || t("nav.fileManager");
|
||||
|
||||
@@ -3085,7 +3085,7 @@ export interface UptimeInfo {
|
||||
export interface RecentActivityItem {
|
||||
id: number;
|
||||
userId: string;
|
||||
type: "terminal" | "file_manager";
|
||||
type: "terminal" | "file_manager" | "server_stats" | "tunnel" | "docker";
|
||||
hostId: number;
|
||||
hostName: string;
|
||||
timestamp: string;
|
||||
@@ -3114,7 +3114,7 @@ export async function getRecentActivity(
|
||||
}
|
||||
|
||||
export async function logActivity(
|
||||
type: "terminal" | "file_manager",
|
||||
type: "terminal" | "file_manager" | "server_stats" | "tunnel" | "docker",
|
||||
hostId: number,
|
||||
hostName: string,
|
||||
): Promise<{ message: string; id: number | string }> {
|
||||
|
||||
Reference in New Issue
Block a user