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
|
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.
|
channel, however, response times may be longer.
|
||||||
|
|
||||||
# Show-off
|
# Screenshots
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="./repo-images/Image 1.png" width="400" alt="Termix Demo 1"/>
|
<img src="./repo-images/Image 1.png" width="400" alt="Termix Demo 1"/>
|
||||||
@@ -148,6 +148,11 @@ channel, however, response times may be longer.
|
|||||||
<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>
|
||||||
|
|
||||||
|
<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">
|
<p align="center">
|
||||||
<video src="https://github.com/user-attachments/assets/88936e0d-2399-4122-8eee-c255c25da48c" width="800" controls>
|
<video src="https://github.com/user-attachments/assets/88936e0d-2399-4122-8eee-c255c25da48c" width="800" controls>
|
||||||
Your browser does not support the video tag.
|
Your browser does not support the video tag.
|
||||||
|
|||||||
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({
|
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) {}
|
} catch (error) {}
|
||||||
clearTimeout(session.timeout);
|
clearTimeout(session.timeout);
|
||||||
delete metricsSessions[sessionId];
|
delete metricsSessions[sessionId];
|
||||||
|
|
||||||
statsLogger.info("Metrics session cleaned up", {
|
|
||||||
operation: "session_cleanup",
|
|
||||||
sessionId,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,13 +406,6 @@ class SSHConnectionPool {
|
|||||||
totpAttempts: 0,
|
totpAttempts: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
statsLogger.info("TOTP required for metrics collection", {
|
|
||||||
operation: "metrics_totp_required",
|
|
||||||
hostId: host.id,
|
|
||||||
sessionId,
|
|
||||||
prompt: prompts[totpPromptIndex].prompt,
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
} else if (host.password) {
|
} else if (host.password) {
|
||||||
const responses = prompts.map((p) => {
|
const responses = prompts.map((p) => {
|
||||||
@@ -728,7 +716,7 @@ interface AuthFailureRecord {
|
|||||||
class AuthFailureTracker {
|
class AuthFailureTracker {
|
||||||
private failures = new Map<number, AuthFailureRecord>();
|
private failures = new Map<number, AuthFailureRecord>();
|
||||||
private maxRetries = 3;
|
private maxRetries = 3;
|
||||||
private backoffBase = 60000;
|
private backoffBase = 5000;
|
||||||
|
|
||||||
recordFailure(
|
recordFailure(
|
||||||
hostId: number,
|
hostId: number,
|
||||||
@@ -991,8 +979,12 @@ class PollingManager {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async startPollingForHost(host: SSHHostWithCredentials): Promise<void> {
|
async startPollingForHost(
|
||||||
|
host: SSHHostWithCredentials,
|
||||||
|
options?: { statusOnly?: boolean },
|
||||||
|
): Promise<void> {
|
||||||
const statsConfig = this.parseStatsConfig(host.statsConfig);
|
const statsConfig = this.parseStatsConfig(host.statsConfig);
|
||||||
|
const statusOnly = options?.statusOnly ?? false;
|
||||||
|
|
||||||
const existingConfig = this.pollingConfigs.get(host.id);
|
const existingConfig = this.pollingConfigs.get(host.id);
|
||||||
|
|
||||||
@@ -1034,10 +1026,10 @@ class PollingManager {
|
|||||||
this.statusStore.delete(host.id);
|
this.statusStore.delete(host.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (statsConfig.metricsEnabled) {
|
if (!statusOnly && statsConfig.metricsEnabled) {
|
||||||
const intervalMs = statsConfig.metricsInterval * 1000;
|
const intervalMs = statsConfig.metricsInterval * 1000;
|
||||||
|
|
||||||
this.pollHostMetrics(host);
|
await this.pollHostMetrics(host);
|
||||||
|
|
||||||
config.metricsTimer = setInterval(() => {
|
config.metricsTimer = setInterval(() => {
|
||||||
const latestConfig = this.pollingConfigs.get(host.id);
|
const latestConfig = this.pollingConfigs.get(host.id);
|
||||||
@@ -1083,11 +1075,6 @@ class PollingManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async pollHostMetrics(host: SSHHostWithCredentials): Promise<void> {
|
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);
|
const refreshedHost = await fetchHostById(host.id, host.userId);
|
||||||
if (!refreshedHost) {
|
if (!refreshedHost) {
|
||||||
statsLogger.warn("Host not found during metrics polling", {
|
statsLogger.warn("Host not found during metrics polling", {
|
||||||
@@ -1102,6 +1089,13 @@ class PollingManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasExistingMetrics = this.metricsStore.has(refreshedHost.id);
|
||||||
|
|
||||||
|
if (hasExistingMetrics && pollingBackoff.shouldSkip(host.id)) {
|
||||||
|
const backoffInfo = pollingBackoff.getBackoffInfo(host.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const metrics = await collectMetrics(refreshedHost);
|
const metrics = await collectMetrics(refreshedHost);
|
||||||
this.metricsStore.set(refreshedHost.id, {
|
this.metricsStore.set(refreshedHost.id, {
|
||||||
@@ -1118,7 +1112,7 @@ class PollingManager {
|
|||||||
const latestConfig = this.pollingConfigs.get(refreshedHost.id);
|
const latestConfig = this.pollingConfigs.get(refreshedHost.id);
|
||||||
if (latestConfig && latestConfig.statsConfig.metricsEnabled) {
|
if (latestConfig && latestConfig.statsConfig.metricsEnabled) {
|
||||||
const backoffInfo = pollingBackoff.getBackoffInfo(refreshedHost.id);
|
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",
|
operation: "metrics_poll_failed",
|
||||||
hostId: refreshedHost.id,
|
hostId: refreshedHost.id,
|
||||||
hostName: refreshedHost.name,
|
hostName: refreshedHost.name,
|
||||||
@@ -1176,7 +1170,7 @@ class PollingManager {
|
|||||||
const hosts = await fetchAllHosts(userId);
|
const hosts = await fetchAllHosts(userId);
|
||||||
|
|
||||||
for (const host of hosts) {
|
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) {
|
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}`);
|
throw new Error(`Invalid SSH key format for host ${host.ip}`);
|
||||||
}
|
}
|
||||||
} else if (host.authType === "none") {
|
} 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}`,
|
||||||
@@ -1622,8 +1615,11 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
return requestQueue.queueRequest(host.id, async () => {
|
return requestQueue.queueRequest(host.id, async () => {
|
||||||
|
const sessionKey = getSessionKey(host.id, host.userId!);
|
||||||
|
const existingSession = metricsSessions[sessionKey];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await withSshConnection(host, async (client) => {
|
const collectFn = async (client: Client) => {
|
||||||
const cpu = await collectCpuMetrics(client);
|
const cpu = await collectCpuMetrics(client);
|
||||||
const memory = await collectMemoryMetrics(client);
|
const memory = await collectMemoryMetrics(client);
|
||||||
const disk = await collectDiskMetrics(client);
|
const disk = await collectDiskMetrics(client);
|
||||||
@@ -1655,7 +1651,20 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
|||||||
|
|
||||||
metricsCache.set(host.id, result);
|
metricsCache.set(host.id, result);
|
||||||
return 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) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
if (error.message.includes("TOTP authentication required")) {
|
if (error.message.includes("TOTP authentication required")) {
|
||||||
@@ -1970,13 +1979,14 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
} else if (host.password) {
|
|
||||||
const responses = prompts.map((p) =>
|
|
||||||
/password/i.test(p.prompt) ? host.password || "" : "",
|
|
||||||
);
|
|
||||||
finish(responses);
|
|
||||||
} else {
|
} 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);
|
clearTimeout(timeout);
|
||||||
if (!isResolved) {
|
if (!isResolved) {
|
||||||
isResolved = true;
|
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);
|
reject(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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);
|
client.connect(config);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (!isResolved) {
|
||||||
|
isResolved = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
client.connect(config);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await connectionPromise;
|
const result = await connectionPromise;
|
||||||
@@ -2129,6 +2172,25 @@ app.post("/metrics/connect-totp", async (req, res) => {
|
|||||||
reject(new Error("TOTP verification timeout"));
|
reject(new Error("TOTP verification timeout"));
|
||||||
}, 30000);
|
}, 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", () => {
|
session.client.once("ready", () => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
resolve();
|
resolve();
|
||||||
@@ -2136,6 +2198,12 @@ app.post("/metrics/connect-totp", async (req, res) => {
|
|||||||
|
|
||||||
session.client.once("error", (error) => {
|
session.client.once("error", (error) => {
|
||||||
clearTimeout(timeout);
|
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);
|
reject(error);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2159,14 +2227,14 @@ app.post("/metrics/connect-totp", async (req, res) => {
|
|||||||
|
|
||||||
const host = await fetchHostById(session.hostId, userId);
|
const host = await fetchHostById(session.hostId, userId);
|
||||||
if (host) {
|
if (host) {
|
||||||
await pollingManager.startPollingForHost(host);
|
pollingManager.startPollingForHost(host).catch((error) => {
|
||||||
}
|
statsLogger.error("Failed to start polling after TOTP", {
|
||||||
|
operation: "totp_polling_start_error",
|
||||||
statsLogger.info("TOTP verified, metrics collection started", {
|
|
||||||
operation: "totp_verified",
|
|
||||||
hostId: session.hostId,
|
hostId: session.hostId,
|
||||||
sessionId,
|
error: error instanceof Error ? error.message : String(error),
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1696,6 +1696,7 @@
|
|||||||
"totpInvalidCode": "Invalid verification code",
|
"totpInvalidCode": "Invalid verification code",
|
||||||
"totpCancelled": "Metrics collection cancelled",
|
"totpCancelled": "Metrics collection cancelled",
|
||||||
"authenticationFailed": "Authentication failed",
|
"authenticationFailed": "Authentication failed",
|
||||||
|
"noneAuthNotSupported": "Server Stats does not support 'none' authentication type.",
|
||||||
"load": "Load",
|
"load": "Load",
|
||||||
"editLayout": "Edit Layout",
|
"editLayout": "Edit Layout",
|
||||||
"cancelEdit": "Cancel",
|
"cancelEdit": "Cancel",
|
||||||
|
|||||||
@@ -389,7 +389,7 @@ export interface TabContextTab {
|
|||||||
| "home"
|
| "home"
|
||||||
| "terminal"
|
| "terminal"
|
||||||
| "ssh_manager"
|
| "ssh_manager"
|
||||||
| "server"
|
| "server_stats"
|
||||||
| "admin"
|
| "admin"
|
||||||
| "file_manager"
|
| "file_manager"
|
||||||
| "user_profile"
|
| "user_profile"
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ function AppContent() {
|
|||||||
const currentTabData = tabs.find((tab) => tab.id === currentTab);
|
const currentTabData = tabs.find((tab) => tab.id === currentTab);
|
||||||
const showTerminalView =
|
const showTerminalView =
|
||||||
currentTabData?.type === "terminal" ||
|
currentTabData?.type === "terminal" ||
|
||||||
currentTabData?.type === "server" ||
|
currentTabData?.type === "server_stats" ||
|
||||||
currentTabData?.type === "file_manager" ||
|
currentTabData?.type === "file_manager" ||
|
||||||
currentTabData?.type === "tunnel" ||
|
currentTabData?.type === "tunnel" ||
|
||||||
currentTabData?.type === "docker";
|
currentTabData?.type === "docker";
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ export function CommandPalette({
|
|||||||
const title = host.name?.trim()
|
const title = host.name?.trim()
|
||||||
? host.name
|
? host.name
|
||||||
: `${host.username}@${host.ip}:${host.port}`;
|
: `${host.username}@${host.ip}:${host.port}`;
|
||||||
addTab({ type: "server", title, hostConfig: host });
|
addTab({ type: "server_stats", title, hostConfig: host });
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Terminal,
|
Terminal,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
|
Activity,
|
||||||
|
Container,
|
||||||
|
ArrowDownUp,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Status } from "@/components/ui/shadcn-io/status";
|
import { Status } from "@/components/ui/shadcn-io/status";
|
||||||
import { BsLightning } from "react-icons/bs";
|
import { BsLightning } from "react-icons/bs";
|
||||||
@@ -297,6 +300,24 @@ export function Dashboard({
|
|||||||
title: item.hostName,
|
title: item.hostName,
|
||||||
hostConfig: host,
|
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;
|
if (!host) return;
|
||||||
|
|
||||||
addTab({
|
addTab({
|
||||||
type: "server",
|
type: "server_stats",
|
||||||
title: serverName,
|
title: serverName,
|
||||||
hostConfig: host,
|
hostConfig: host,
|
||||||
});
|
});
|
||||||
@@ -544,7 +565,7 @@ export function Dashboard({
|
|||||||
</div>
|
</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 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">
|
<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">
|
<p className="m-0 leading-none truncate">
|
||||||
{t("dashboard.totalTunnels")}
|
{t("dashboard.totalTunnels")}
|
||||||
</p>
|
</p>
|
||||||
@@ -617,8 +638,16 @@ export function Dashboard({
|
|||||||
>
|
>
|
||||||
{item.type === "terminal" ? (
|
{item.type === "terminal" ? (
|
||||||
<Terminal size={20} className="shrink-0" />
|
<Terminal size={20} className="shrink-0" />
|
||||||
) : (
|
) : item.type === "file_manager" ? (
|
||||||
<FolderOpen size={20} className="shrink-0" />
|
<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">
|
<p className="truncate ml-2 font-semibold">
|
||||||
{item.hostName}
|
{item.hostName}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
validateDockerAvailability,
|
validateDockerAvailability,
|
||||||
keepaliveDockerSession,
|
keepaliveDockerSession,
|
||||||
verifyDockerTOTP,
|
verifyDockerTOTP,
|
||||||
|
logActivity,
|
||||||
} from "@/ui/main-axios.ts";
|
} from "@/ui/main-axios.ts";
|
||||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||||
import { AlertCircle } from "lucide-react";
|
import { AlertCircle } from "lucide-react";
|
||||||
@@ -25,6 +26,7 @@ import { ContainerList } from "./components/ContainerList.tsx";
|
|||||||
import { ContainerDetail } from "./components/ContainerDetail.tsx";
|
import { ContainerDetail } from "./components/ContainerDetail.tsx";
|
||||||
import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx";
|
import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx";
|
||||||
import { SSHAuthDialog } from "@/ui/desktop/navigation/SSHAuthDialog.tsx";
|
import { SSHAuthDialog } from "@/ui/desktop/navigation/SSHAuthDialog.tsx";
|
||||||
|
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||||
|
|
||||||
interface DockerManagerProps {
|
interface DockerManagerProps {
|
||||||
hostConfig?: SSHHost;
|
hostConfig?: SSHHost;
|
||||||
@@ -35,6 +37,12 @@ interface DockerManagerProps {
|
|||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TabData {
|
||||||
|
id: number;
|
||||||
|
type: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export function DockerManager({
|
export function DockerManager({
|
||||||
hostConfig,
|
hostConfig,
|
||||||
title,
|
title,
|
||||||
@@ -45,6 +53,10 @@ export function DockerManager({
|
|||||||
}: DockerManagerProps): React.ReactElement {
|
}: DockerManagerProps): React.ReactElement {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { state: sidebarState } = useSidebar();
|
const { state: sidebarState } = useSidebar();
|
||||||
|
const { currentTab, removeTab } = useTabs() as {
|
||||||
|
currentTab: number | null;
|
||||||
|
removeTab: (tabId: number) => void;
|
||||||
|
};
|
||||||
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
||||||
const [sessionId, setSessionId] = React.useState<string | null>(null);
|
const [sessionId, setSessionId] = React.useState<string | null>(null);
|
||||||
const [containers, setContainers] = React.useState<DockerContainer[]>([]);
|
const [containers, setContainers] = React.useState<DockerContainer[]>([]);
|
||||||
@@ -66,6 +78,34 @@ export function DockerManager({
|
|||||||
"no_keyboard" | "auth_failed" | "timeout"
|
"no_keyboard" | "auth_failed" | "timeout"
|
||||||
>("no_keyboard");
|
>("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(() => {
|
React.useEffect(() => {
|
||||||
if (hostConfig?.id !== currentHostConfig?.id) {
|
if (hostConfig?.id !== currentHostConfig?.id) {
|
||||||
setCurrentHostConfig(hostConfig);
|
setCurrentHostConfig(hostConfig);
|
||||||
@@ -172,6 +212,8 @@ export function DockerManager({
|
|||||||
toast.error(
|
toast.error(
|
||||||
validation.error || "Docker is not available on this host",
|
validation.error || "Docker is not available on this host",
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
logDockerActivity();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(
|
toast.error(
|
||||||
@@ -279,6 +321,8 @@ export function DockerManager({
|
|||||||
toast.error(
|
toast.error(
|
||||||
validation.error || "Docker is not available on this host",
|
validation.error || "Docker is not available on this host",
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
logDockerActivity();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -294,6 +338,9 @@ export function DockerManager({
|
|||||||
setTotpSessionId(null);
|
setTotpSessionId(null);
|
||||||
setTotpPrompt("");
|
setTotpPrompt("");
|
||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
|
if (currentTab !== null) {
|
||||||
|
removeTab(currentTab);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAuthSubmit = async (credentials: {
|
const handleAuthSubmit = async (credentials: {
|
||||||
@@ -345,6 +392,8 @@ export function DockerManager({
|
|||||||
|
|
||||||
if (!validation.available) {
|
if (!validation.available) {
|
||||||
toast.error(validation.error || "Docker is not available on this host");
|
toast.error(validation.error || "Docker is not available on this host");
|
||||||
|
} else {
|
||||||
|
logDockerActivity();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : "Failed to connect");
|
toast.error(error instanceof Error ? error.message : "Failed to connect");
|
||||||
@@ -422,15 +471,13 @@ export function DockerManager({
|
|||||||
</div>
|
</div>
|
||||||
<Separator className="p-0.25 w-full" />
|
<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="flex-1 overflow-hidden min-h-0 relative">
|
||||||
<div className="text-center">
|
<SimpleLoader
|
||||||
<SimpleLoader size="lg" />
|
visible={true}
|
||||||
<p className="text-muted-foreground mt-4">
|
message={
|
||||||
{isValidating
|
isValidating ? t("docker.validating") : t("docker.connecting")
|
||||||
? t("docker.validating")
|
}
|
||||||
: t("docker.connectingToHost")}
|
/>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -490,19 +537,15 @@ export function DockerManager({
|
|||||||
</div>
|
</div>
|
||||||
<Separator className="p-0.25 w-full" />
|
<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" ? (
|
{viewMode === "list" ? (
|
||||||
<div className="h-full px-4 py-4">
|
<div className="h-full px-4 py-4">
|
||||||
{sessionId ? (
|
{sessionId ? (
|
||||||
isLoadingContainers && containers.length === 0 ? (
|
isLoadingContainers && containers.length === 0 ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<SimpleLoader
|
||||||
<div className="text-center">
|
visible={true}
|
||||||
<SimpleLoader size="lg" />
|
message={t("docker.loadingContainers")}
|
||||||
<p className="text-muted-foreground mt-4">
|
/>
|
||||||
{t("docker.loadingContainers")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<ContainerList
|
<ContainerList
|
||||||
containers={containers}
|
containers={containers}
|
||||||
|
|||||||
@@ -64,11 +64,11 @@ export function ConsoleTerminal({
|
|||||||
"http://127.0.0.1:30001";
|
"http://127.0.0.1:30001";
|
||||||
const wsProtocol = baseUrl.startsWith("https://") ? "wss://" : "ws://";
|
const wsProtocol = baseUrl.startsWith("https://") ? "wss://" : "ws://";
|
||||||
const wsHost = baseUrl.replace(/^https?:\/\//, "");
|
const wsHost = baseUrl.replace(/^https?:\/\//, "");
|
||||||
return `${wsProtocol}${wsHost}/docker/console/`;
|
return `${wsProtocol}${wsHost}/docker/console`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
return `${protocol}//${window.location.host}/docker/console/`;
|
return `${protocol}//${window.location.host}/docker/console`;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|||||||
@@ -1320,7 +1320,7 @@ export function FileManagerGrid({
|
|||||||
document.body,
|
document.body,
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SimpleLoader visible={isLoading} message={t("common.loading")} />
|
<SimpleLoader visible={isLoading} message={t("common.connecting")} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
stopMetricsPolling,
|
stopMetricsPolling,
|
||||||
submitMetricsTOTP,
|
submitMetricsTOTP,
|
||||||
executeSnippet,
|
executeSnippet,
|
||||||
|
logActivity,
|
||||||
type ServerMetrics,
|
type ServerMetrics,
|
||||||
} from "@/ui/main-axios.ts";
|
} from "@/ui/main-axios.ts";
|
||||||
import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx";
|
import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx";
|
||||||
@@ -76,9 +77,11 @@ export function ServerStats({
|
|||||||
}: ServerProps): React.ReactElement {
|
}: ServerProps): React.ReactElement {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { state: sidebarState } = useSidebar();
|
const { state: sidebarState } = useSidebar();
|
||||||
const { addTab, tabs } = useTabs() as {
|
const { addTab, tabs, currentTab, removeTab } = useTabs() as {
|
||||||
addTab: (tab: { type: string; [key: string]: unknown }) => number;
|
addTab: (tab: { type: string; [key: string]: unknown }) => number;
|
||||||
tabs: TabData[];
|
tabs: TabData[];
|
||||||
|
currentTab: number | null;
|
||||||
|
removeTab: (tabId: number) => void;
|
||||||
};
|
};
|
||||||
const [serverStatus, setServerStatus] = React.useState<"online" | "offline">(
|
const [serverStatus, setServerStatus] = React.useState<"online" | "offline">(
|
||||||
"offline",
|
"offline",
|
||||||
@@ -98,6 +101,10 @@ export function ServerStats({
|
|||||||
const [totpSessionId, setTotpSessionId] = React.useState<string | null>(null);
|
const [totpSessionId, setTotpSessionId] = React.useState<string | null>(null);
|
||||||
const [totpPrompt, setTotpPrompt] = React.useState<string>("");
|
const [totpPrompt, setTotpPrompt] = React.useState<string>("");
|
||||||
const [isPageVisible, setIsPageVisible] = React.useState(!document.hidden);
|
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 => {
|
const statsConfig = React.useMemo((): StatsConfig => {
|
||||||
if (!currentHostConfig?.statsConfig) {
|
if (!currentHostConfig?.statsConfig) {
|
||||||
@@ -140,6 +147,31 @@ export function ServerStats({
|
|||||||
setCurrentHostConfig(hostConfig);
|
setCurrentHostConfig(hostConfig);
|
||||||
}, [hostConfig?.id]);
|
}, [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) => {
|
const handleTOTPSubmit = async (totpCode: string) => {
|
||||||
if (!totpSessionId || !currentHostConfig) return;
|
if (!totpSessionId || !currentHostConfig) return;
|
||||||
|
|
||||||
@@ -147,10 +179,11 @@ export function ServerStats({
|
|||||||
const result = await submitMetricsTOTP(totpSessionId, totpCode);
|
const result = await submitMetricsTOTP(totpSessionId, totpCode);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setTotpRequired(false);
|
setTotpRequired(false);
|
||||||
toast.success(t("serverStats.totpVerified"));
|
setTotpSessionId(null);
|
||||||
const data = await getServerMetricsById(currentHostConfig.id);
|
|
||||||
setMetrics(data);
|
|
||||||
setShowStatsUI(true);
|
setShowStatsUI(true);
|
||||||
|
setTotpVerified(true);
|
||||||
|
} else {
|
||||||
|
toast.error(t("serverStats.totpFailed"));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(t("serverStats.totpFailed"));
|
toast.error(t("serverStats.totpFailed"));
|
||||||
@@ -167,6 +200,9 @@ export function ServerStats({
|
|||||||
console.error("Failed to stop metrics polling:", error);
|
console.error("Failed to stop metrics polling:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (currentTab !== null) {
|
||||||
|
removeTab(currentTab);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderWidget = (widgetType: WidgetType) => {
|
const renderWidget = (widgetType: WidgetType) => {
|
||||||
@@ -301,7 +337,6 @@ export function ServerStats({
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!metricsEnabled || !currentHostConfig?.id) {
|
if (!metricsEnabled || !currentHostConfig?.id) {
|
||||||
setShowStatsUI(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,12 +344,34 @@ export function ServerStats({
|
|||||||
let pollingIntervalId: number | undefined;
|
let pollingIntervalId: number | undefined;
|
||||||
let debounceTimeout: NodeJS.Timeout | undefined;
|
let debounceTimeout: NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
|
if (isActuallyVisible && !metrics) {
|
||||||
|
setIsLoadingMetrics(true);
|
||||||
|
setShowStatsUI(true);
|
||||||
|
} else if (!isActuallyVisible) {
|
||||||
|
setIsLoadingMetrics(false);
|
||||||
|
}
|
||||||
|
|
||||||
const startMetrics = async () => {
|
const startMetrics = async () => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
|
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);
|
setIsLoadingMetrics(true);
|
||||||
|
}
|
||||||
|
setShowStatsUI(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (!totpVerified) {
|
||||||
const result = await startMetricsPolling(currentHostConfig.id);
|
const result = await startMetricsPolling(currentHostConfig.id);
|
||||||
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -326,12 +383,38 @@ export function ServerStats({
|
|||||||
setIsLoadingMetrics(false);
|
setIsLoadingMetrics(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const data = await getServerMetricsById(currentHostConfig.id);
|
let retryCount = 0;
|
||||||
if (!cancelled) {
|
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 (data) {
|
||||||
setMetrics(data);
|
setMetrics(data);
|
||||||
setShowStatsUI(true);
|
if (!hasExistingMetrics) {
|
||||||
setIsLoadingMetrics(false);
|
setIsLoadingMetrics(false);
|
||||||
|
logServerActivity();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pollingIntervalId = window.setInterval(async () => {
|
pollingIntervalId = window.setInterval(async () => {
|
||||||
@@ -355,8 +438,10 @@ export function ServerStats({
|
|||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
console.error("Failed to start metrics polling:", error);
|
console.error("Failed to start metrics polling:", error);
|
||||||
setIsLoadingMetrics(false);
|
setIsLoadingMetrics(false);
|
||||||
setShowStatsUI(false);
|
|
||||||
toast.error(t("serverStats.failedToFetchMetrics"));
|
toast.error(t("serverStats.failedToFetchMetrics"));
|
||||||
|
if (currentTab !== null) {
|
||||||
|
removeTab(currentTab);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -396,6 +481,7 @@ export function ServerStats({
|
|||||||
isActuallyVisible,
|
isActuallyVisible,
|
||||||
metricsEnabled,
|
metricsEnabled,
|
||||||
statsConfig.metricsInterval,
|
statsConfig.metricsInterval,
|
||||||
|
totpVerified,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const topMarginPx = isTopbarOpen ? 74 : 16;
|
const topMarginPx = isTopbarOpen ? 74 : 16;
|
||||||
@@ -427,8 +513,9 @@ export function ServerStats({
|
|||||||
: "bg-canvas text-foreground rounded-lg border-2 border-edge overflow-hidden";
|
: "bg-canvas text-foreground rounded-lg border-2 border-edge overflow-hidden";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={wrapperStyle} className={containerClass}>
|
<div style={wrapperStyle} className={`${containerClass} relative`}>
|
||||||
<div className="h-full w-full flex flex-col">
|
<div className="h-full w-full flex flex-col">
|
||||||
|
{!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 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="flex items-center gap-4 min-w-0">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -454,7 +541,9 @@ export function ServerStats({
|
|||||||
if (currentHostConfig?.id) {
|
if (currentHostConfig?.id) {
|
||||||
try {
|
try {
|
||||||
setIsRefreshing(true);
|
setIsRefreshing(true);
|
||||||
const res = await getServerStatusById(currentHostConfig.id);
|
const res = await getServerStatusById(
|
||||||
|
currentHostConfig.id,
|
||||||
|
);
|
||||||
setServerStatus(
|
setServerStatus(
|
||||||
res?.status === "online" ? "online" : "offline",
|
res?.status === "online" ? "online" : "offline",
|
||||||
);
|
);
|
||||||
@@ -467,7 +556,10 @@ export function ServerStats({
|
|||||||
const err = error as {
|
const err = error as {
|
||||||
code?: string;
|
code?: string;
|
||||||
status?: number;
|
status?: number;
|
||||||
response?: { status?: number; data?: { error?: string } };
|
response?: {
|
||||||
|
status?: number;
|
||||||
|
data?: { error?: string };
|
||||||
|
};
|
||||||
};
|
};
|
||||||
if (
|
if (
|
||||||
err?.code === "TOTP_REQUIRED" ||
|
err?.code === "TOTP_REQUIRED" ||
|
||||||
@@ -569,13 +661,14 @@ export function ServerStats({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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) ||
|
{(metricsEnabled && showStatsUI) ||
|
||||||
(currentHostConfig?.quickActions &&
|
(currentHostConfig?.quickActions &&
|
||||||
currentHostConfig.quickActions.length > 0) ? (
|
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 &&
|
||||||
currentHostConfig.quickActions.length > 0 && (
|
currentHostConfig.quickActions.length > 0 && (
|
||||||
<div className={metricsEnabled && showStatsUI ? "mb-4" : ""}>
|
<div className={metricsEnabled && showStatsUI ? "mb-4" : ""}>
|
||||||
@@ -681,6 +774,7 @@ export function ServerStats({
|
|||||||
)}
|
)}
|
||||||
{metricsEnabled &&
|
{metricsEnabled &&
|
||||||
showStatsUI &&
|
showStatsUI &&
|
||||||
|
!isLoadingMetrics &&
|
||||||
(!metrics && serverStatus === "offline" ? (
|
(!metrics && serverStatus === "offline" ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@@ -695,7 +789,7 @@ export function ServerStats({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : metrics ? (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{enabledWidgets.map((widgetType) => (
|
{enabledWidgets.map((widgetType) => (
|
||||||
<div key={widgetType} className="h-[280px]">
|
<div key={widgetType} className="h-[280px]">
|
||||||
@@ -703,20 +797,19 @@ export function ServerStats({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
) : null)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{metricsEnabled && showStatsUI && (
|
{metricsEnabled && (
|
||||||
<SimpleLoader
|
<SimpleLoader
|
||||||
visible={isLoadingMetrics && !metrics}
|
visible={isLoadingMetrics && !metrics}
|
||||||
message={t("serverStats.loadingMetrics")}
|
message={t("serverStats.connecting")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{totpRequired && (
|
|
||||||
<TOTPDialog
|
<TOTPDialog
|
||||||
isOpen={totpRequired}
|
isOpen={totpRequired}
|
||||||
prompt={totpPrompt}
|
prompt={totpPrompt}
|
||||||
@@ -724,7 +817,6 @@ export function ServerStats({
|
|||||||
onCancel={handleTOTPCancel}
|
onCancel={handleTOTPCancel}
|
||||||
backgroundColor="var(--bg-canvas)"
|
backgroundColor="var(--bg-canvas)"
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
connectTunnel,
|
connectTunnel,
|
||||||
disconnectTunnel,
|
disconnectTunnel,
|
||||||
cancelTunnel,
|
cancelTunnel,
|
||||||
|
logActivity,
|
||||||
} from "@/ui/main-axios.ts";
|
} from "@/ui/main-axios.ts";
|
||||||
import type {
|
import type {
|
||||||
SSHHost,
|
SSHHost,
|
||||||
@@ -27,6 +28,8 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const prevVisibleHostRef = React.useRef<SSHHost | null>(null);
|
const prevVisibleHostRef = React.useRef<SSHHost | null>(null);
|
||||||
|
const activityLoggedRef = React.useRef(false);
|
||||||
|
const activityLoggingRef = React.useRef(false);
|
||||||
|
|
||||||
const haveTunnelConnectionsChanged = (
|
const haveTunnelConnectionsChanged = (
|
||||||
a: TunnelConnection[] = [],
|
a: TunnelConnection[] = [],
|
||||||
@@ -88,6 +91,25 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}, [filterHostKey]);
|
}, [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 fetchTunnelStatuses = useCallback(async () => {
|
||||||
const statusData = await getTunnelStatuses();
|
const statusData = await getTunnelStatuses();
|
||||||
setTunnelStatuses(statusData);
|
setTunnelStatuses(statusData);
|
||||||
@@ -120,6 +142,12 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [fetchTunnelStatuses]);
|
}, [fetchTunnelStatuses]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visibleHosts.length > 0 && visibleHosts[0]) {
|
||||||
|
logTunnelActivity(visibleHosts[0]);
|
||||||
|
}
|
||||||
|
}, [visibleHosts.length > 0 ? visibleHosts[0]?.id : null]);
|
||||||
|
|
||||||
const handleTunnelAction = async (
|
const handleTunnelAction = async (
|
||||||
action: "connect" | "disconnect" | "cancel",
|
action: "connect" | "disconnect" | "cancel",
|
||||||
host: SSHHost,
|
host: SSHHost,
|
||||||
|
|||||||
@@ -1537,7 +1537,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
|||||||
? host.name
|
? host.name
|
||||||
: `${host.username}@${host.ip}:${host.port}`;
|
: `${host.username}@${host.ip}:${host.port}`;
|
||||||
addTab({
|
addTab({
|
||||||
type: "server",
|
type: "server_stats",
|
||||||
title,
|
title,
|
||||||
hostConfig: host,
|
hostConfig: host,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ export function SSHToolsSidebar({
|
|||||||
const splittableTabs = tabs.filter(
|
const splittableTabs = tabs.filter(
|
||||||
(tab: TabData) =>
|
(tab: TabData) =>
|
||||||
tab.type === "terminal" ||
|
tab.type === "terminal" ||
|
||||||
tab.type === "server" ||
|
tab.type === "server_stats" ||
|
||||||
tab.type === "file_manager" ||
|
tab.type === "file_manager" ||
|
||||||
tab.type === "tunnel" ||
|
tab.type === "tunnel" ||
|
||||||
tab.type === "docker",
|
tab.type === "docker",
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export function AppView({
|
|||||||
tabs.filter(
|
tabs.filter(
|
||||||
(tab: TabData) =>
|
(tab: TabData) =>
|
||||||
tab.type === "terminal" ||
|
tab.type === "terminal" ||
|
||||||
tab.type === "server" ||
|
tab.type === "server_stats" ||
|
||||||
tab.type === "file_manager" ||
|
tab.type === "file_manager" ||
|
||||||
tab.type === "tunnel" ||
|
tab.type === "tunnel" ||
|
||||||
tab.type === "docker",
|
tab.type === "docker",
|
||||||
@@ -345,7 +345,7 @@ export function AppView({
|
|||||||
splitScreen={allSplitScreenTab.length > 0}
|
splitScreen={allSplitScreenTab.length > 0}
|
||||||
onClose={() => removeTab(t.id)}
|
onClose={() => removeTab(t.id)}
|
||||||
/>
|
/>
|
||||||
) : t.type === "server" ? (
|
) : t.type === "server_stats" ? (
|
||||||
<ServerView
|
<ServerView
|
||||||
hostConfig={t.hostConfig}
|
hostConfig={t.hostConfig}
|
||||||
title={t.title}
|
title={t.title}
|
||||||
|
|||||||
@@ -367,7 +367,7 @@ export function TopNavbar({
|
|||||||
Array.isArray(allSplitScreenTab) &&
|
Array.isArray(allSplitScreenTab) &&
|
||||||
allSplitScreenTab.includes(tab.id);
|
allSplitScreenTab.includes(tab.id);
|
||||||
const isTerminal = tab.type === "terminal";
|
const isTerminal = tab.type === "terminal";
|
||||||
const isServer = tab.type === "server";
|
const isServer = tab.type === "server_stats";
|
||||||
const isFileManager = tab.type === "file_manager";
|
const isFileManager = tab.type === "file_manager";
|
||||||
const isTunnel = tab.type === "tunnel";
|
const isTunnel = tab.type === "tunnel";
|
||||||
const isDocker = tab.type === "docker";
|
const isDocker = tab.type === "docker";
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
|||||||
{shouldShowMetrics && (
|
{shouldShowMetrics && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() =>
|
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"
|
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 (
|
if (
|
||||||
tabType === "terminal" ||
|
tabType === "terminal" ||
|
||||||
tabType === "server" ||
|
tabType === "server_stats" ||
|
||||||
tabType === "file_manager" ||
|
tabType === "file_manager" ||
|
||||||
tabType === "tunnel" ||
|
tabType === "tunnel" ||
|
||||||
tabType === "docker" ||
|
tabType === "docker" ||
|
||||||
tabType === "user_profile"
|
tabType === "user_profile"
|
||||||
) {
|
) {
|
||||||
const isServer = tabType === "server";
|
const isServer = tabType === "server_stats";
|
||||||
const isFileManager = tabType === "file_manager";
|
const isFileManager = tabType === "file_manager";
|
||||||
const isTunnel = tabType === "tunnel";
|
const isTunnel = tabType === "tunnel";
|
||||||
const isDocker = tabType === "docker";
|
const isDocker = tabType === "docker";
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export function TabProvider({ children }: TabProviderProps) {
|
|||||||
desiredTitle: string | undefined,
|
desiredTitle: string | undefined,
|
||||||
): string {
|
): string {
|
||||||
const defaultTitle =
|
const defaultTitle =
|
||||||
tabType === "server"
|
tabType === "server_stats"
|
||||||
? t("nav.serverStats")
|
? t("nav.serverStats")
|
||||||
: tabType === "file_manager"
|
: tabType === "file_manager"
|
||||||
? t("nav.fileManager")
|
? t("nav.fileManager")
|
||||||
@@ -139,7 +139,7 @@ export function TabProvider({ children }: TabProviderProps) {
|
|||||||
const id = nextTabId.current++;
|
const id = nextTabId.current++;
|
||||||
const needsUniqueTitle =
|
const needsUniqueTitle =
|
||||||
tabData.type === "terminal" ||
|
tabData.type === "terminal" ||
|
||||||
tabData.type === "server" ||
|
tabData.type === "server_stats" ||
|
||||||
tabData.type === "file_manager" ||
|
tabData.type === "file_manager" ||
|
||||||
tabData.type === "tunnel" ||
|
tabData.type === "tunnel" ||
|
||||||
tabData.type === "docker";
|
tabData.type === "docker";
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function TabDropdown(): React.ReactElement {
|
|||||||
return <Home className="h-4 w-4" />;
|
return <Home className="h-4 w-4" />;
|
||||||
case "terminal":
|
case "terminal":
|
||||||
return <TerminalIcon className="h-4 w-4" />;
|
return <TerminalIcon className="h-4 w-4" />;
|
||||||
case "server":
|
case "server_stats":
|
||||||
return <ServerIcon className="h-4 w-4" />;
|
return <ServerIcon className="h-4 w-4" />;
|
||||||
case "file_manager":
|
case "file_manager":
|
||||||
return <FolderIcon className="h-4 w-4" />;
|
return <FolderIcon className="h-4 w-4" />;
|
||||||
@@ -54,7 +54,7 @@ export function TabDropdown(): React.ReactElement {
|
|||||||
switch (tab.type) {
|
switch (tab.type) {
|
||||||
case "home":
|
case "home":
|
||||||
return t("nav.home");
|
return t("nav.home");
|
||||||
case "server":
|
case "server_stats":
|
||||||
return tab.title || t("nav.serverStats");
|
return tab.title || t("nav.serverStats");
|
||||||
case "file_manager":
|
case "file_manager":
|
||||||
return tab.title || t("nav.fileManager");
|
return tab.title || t("nav.fileManager");
|
||||||
|
|||||||
@@ -3085,7 +3085,7 @@ export interface UptimeInfo {
|
|||||||
export interface RecentActivityItem {
|
export interface RecentActivityItem {
|
||||||
id: number;
|
id: number;
|
||||||
userId: string;
|
userId: string;
|
||||||
type: "terminal" | "file_manager";
|
type: "terminal" | "file_manager" | "server_stats" | "tunnel" | "docker";
|
||||||
hostId: number;
|
hostId: number;
|
||||||
hostName: string;
|
hostName: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
@@ -3114,7 +3114,7 @@ export async function getRecentActivity(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function logActivity(
|
export async function logActivity(
|
||||||
type: "terminal" | "file_manager",
|
type: "terminal" | "file_manager" | "server_stats" | "tunnel" | "docker",
|
||||||
hostId: number,
|
hostId: number,
|
||||||
hostName: string,
|
hostName: string,
|
||||||
): Promise<{ message: string; id: number | string }> {
|
): Promise<{ message: string; id: number | string }> {
|
||||||
|
|||||||
Reference in New Issue
Block a user