feat: add to readme, a few qol changes, and improve server stats in general

This commit is contained in:
LukeGus
2025-12-31 01:12:51 -06:00
parent 34803ccf4e
commit c79a2bfcd8
49 changed files with 559 additions and 284 deletions

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

BIN
repo-images/Image 9.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

View File

@@ -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'",
});
}

View File

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

View File

@@ -2395,4 +2395,4 @@
"switchToLight": "قم بتشغيل الضوء",
"switchToDark": "التبديل إلى الوضع الداكن"
}
}
}

View File

@@ -2395,4 +2395,4 @@
"switchToLight": "আলোতে স্যুইচ করুন",
"switchToDark": "ডার্ক এ স্যুইচ করুন"
}
}
}

View File

@@ -2395,4 +2395,4 @@
"switchToLight": "Přepnout na světlo",
"switchToDark": "Přepnout na tmavou"
}
}
}

View File

@@ -2395,4 +2395,4 @@
"switchToLight": "Auf Licht umschalten",
"switchToDark": "Auf Dunkel umschalten"
}
}
}

View File

@@ -2395,4 +2395,4 @@
"switchToLight": "Αλλαγή σε Φως",
"switchToDark": "Αλλαγή σε Σκούρο"
}
}
}

View File

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

View File

@@ -2395,4 +2395,4 @@
"switchToLight": "Cambiar a la luz",
"switchToDark": "Cambiar a oscuro"
}
}
}

View File

@@ -2395,4 +2395,4 @@
"switchToLight": "Passer à la lumière",
"switchToDark": "Passer au mode sombre"
}
}
}

View File

@@ -2395,4 +2395,4 @@
"switchToLight": "מעבר לתאורה",
"switchToDark": "עבור למצב כהה"
}
}
}

View File

@@ -2395,4 +2395,4 @@
"switchToLight": "लाइट पर स्विच करें",
"switchToDark": "डार्क मोड पर स्विच करें"
}
}
}

View File

@@ -2395,4 +2395,4 @@
"switchToLight": "Beralih ke Cahaya",
"switchToDark": "Beralih ke Gelap"
}
}
}

View File

@@ -2395,4 +2395,4 @@
"switchToLight": "Passa alla luce",
"switchToDark": "Passa a Scuro"
}
}
}

View File

@@ -2395,4 +2395,4 @@
"switchToLight": "光に切り替える",
"switchToDark": "ダークに切り替える"
}
}
}

View File

@@ -2395,4 +2395,4 @@
"switchToLight": "조명으로 전환",
"switchToDark": "어둡게 전환"
}
}
}

View File

@@ -2395,4 +2395,4 @@
"switchToLight": "Bytt til lys",
"switchToDark": "Bytt til mørkt"
}
}
}

View File

@@ -2395,4 +2395,4 @@
"switchToLight": "Schakel over naar Licht",
"switchToDark": "Schakel over naar Donker"
}
}
}

View File

@@ -2395,4 +2395,4 @@
"switchToLight": "Przełącz na światło",
"switchToDark": "Przełącz na ciemność"
}
}
}

View File

@@ -2395,4 +2395,4 @@
"switchToLight": "Alternar para luz",
"switchToDark": "Mudar para o modo escuro"
}
}
}

View File

@@ -2395,4 +2395,4 @@
"switchToLight": "Comutare la lumină",
"switchToDark": "Comutare la Întuneric"
}
}
}

View File

@@ -2395,4 +2395,4 @@
"switchToLight": "Переключиться на свет",
"switchToDark": "Переключиться на темный"
}
}
}

View File

@@ -2395,4 +2395,4 @@
"switchToLight": "Växla till ljus",
"switchToDark": "Växla till mörkt"
}
}
}

View File

@@ -2395,4 +2395,4 @@
"switchToLight": "เปลี่ยนเป็นโหมดสว่าง",
"switchToDark": "เปลี่ยนเป็นโหมดมืด"
}
}
}

View File

@@ -2395,4 +2395,4 @@
"switchToLight": "Işığa geç",
"switchToDark": "Koyu moda geç"
}
}
}

View File

@@ -2395,4 +2395,4 @@
"switchToLight": "Перейти на світлий режим",
"switchToDark": "Перейти на темний режим"
}
}
}

View File

@@ -2395,4 +2395,4 @@
"switchToLight": "Chuyển sang chế độ sáng",
"switchToDark": "Chuyển sang chế độ Tối"
}
}
}

View File

@@ -2395,4 +2395,4 @@
"switchToLight": "切换到灯光",
"switchToDark": "切换到黑暗模式"
}
}
}

View File

@@ -389,7 +389,7 @@ export interface TabContextTab {
| "home"
| "terminal"
| "ssh_manager"
| "server"
| "server_stats"
| "admin"
| "file_manager"
| "user_profile"

View File

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

View File

@@ -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);
};

View File

@@ -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}

View File

@@ -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}

View File

@@ -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(() => {

View File

@@ -1320,7 +1320,7 @@ export function FileManagerGrid({
document.body,
)}
<SimpleLoader visible={isLoading} message={t("common.loading")} />
<SimpleLoader visible={isLoading} message={t("common.connecting")} />
</div>
);
}

View File

@@ -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>
);
}

View File

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

View File

@@ -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,
});

View File

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

View File

@@ -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}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 }> {