v1.10.0 #471

Merged
LukeGus merged 106 commits from dev-1.10.0 into main 2026-01-01 04:20:12 +00:00
13 changed files with 649 additions and 293 deletions
Showing only changes of commit faebdf7374 - Show all commits

View File

@@ -2,8 +2,8 @@ import express from "express";
import cors from "cors";
import cookieParser from "cookie-parser";
import { getDb } from "./database/db/index.js";
import { recentActivity, sshData } from "./database/db/schema.js";
import { eq, and, desc } from "drizzle-orm";
import { recentActivity, sshData, hostAccess } from "./database/db/schema.js";
import { eq, and, desc, or } from "drizzle-orm";
import { dashboardLogger } from "./utils/logger.js";
import { SimpleDBOps } from "./utils/simple-db-ops.js";
import { AuthManager } from "./utils/auth-manager.js";
@@ -164,7 +164,7 @@ app.post("/activity/log", async (req, res) => {
entriesToDelete.forEach((key) => activityRateLimiter.delete(key));
}
const hosts = await SimpleDBOps.select(
const ownedHosts = await SimpleDBOps.select(
getDb()
.select()
.from(sshData)
@@ -173,8 +173,19 @@ app.post("/activity/log", async (req, res) => {
userId,
);
if (hosts.length === 0) {
return res.status(404).json({ error: "Host not found" });
if (ownedHosts.length === 0) {
const sharedHosts = await getDb()
.select()
.from(hostAccess)
.where(
and(eq(hostAccess.hostId, hostId), eq(hostAccess.userId, userId)),
);
if (sharedHosts.length === 0) {
return res
.status(404)
.json({ error: "Host not found or access denied" });
}
}
const result = (await SimpleDBOps.insert(

View File

@@ -1440,7 +1440,7 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
let fileBuffer;
try {
if (typeof content === "string") {
fileBuffer = Buffer.from(content, "utf8");
fileBuffer = Buffer.from(content, "base64");
} else if (Buffer.isBuffer(content)) {
fileBuffer = content;
} else {
@@ -1649,7 +1649,7 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => {
let fileBuffer;
try {
if (typeof content === "string") {
fileBuffer = Buffer.from(content, "utf8");
fileBuffer = Buffer.from(content, "base64");
} else if (Buffer.isBuffer(content)) {
fileBuffer = content;
} else {

View File

@@ -9,6 +9,7 @@ import { eq, and } from "drizzle-orm";
import { statsLogger, sshLogger } from "../utils/logger.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js";
import { AuthManager } from "../utils/auth-manager.js";
import { PermissionManager } from "../utils/permission-manager.js";
import type { AuthenticatedRequest, ProxyNode } from "../../types/index.js";
import { collectCpuMetrics } from "./widgets/cpu-collector.js";
import { collectMemoryMetrics } from "./widgets/memory-collector.js";
@@ -218,6 +219,13 @@ interface PendingTOTPSession {
totpAttempts: number;
}
interface MetricsViewer {
sessionId: string;
userId: string;
hostId: number;
lastHeartbeat: number;
}
const metricsSessions: Record<string, MetricsSession> = {};
const pendingTOTPSessions: Record<string, PendingTOTPSession> = {};
@@ -868,6 +876,7 @@ const metricsCache = new MetricsCache();
const authFailureTracker = new AuthFailureTracker();
const pollingBackoff = new PollingBackoff();
const authManager = AuthManager.getInstance();
const permissionManager = PermissionManager.getInstance();
type HostStatus = "online" | "offline";
@@ -931,6 +940,7 @@ interface HostPollingConfig {
statsConfig: StatsConfig;
statusTimer?: NodeJS.Timeout;
metricsTimer?: NodeJS.Timeout;
viewerUserId?: string;
}
class PollingManager {
@@ -943,6 +953,15 @@ class PollingManager {
timestamp: number;
}
>();
private activeViewers = new Map<number, Set<string>>();
private viewerDetails = new Map<string, MetricsViewer>();
private viewerCleanupInterval: NodeJS.Timeout;
constructor() {
this.viewerCleanupInterval = setInterval(() => {
this.cleanupInactiveViewers();
}, 60000);
}
parseStatsConfig(statsConfigStr?: string | StatsConfig): StatsConfig {
if (!statsConfigStr) {
@@ -981,10 +1000,11 @@ class PollingManager {
async startPollingForHost(
host: SSHHostWithCredentials,
options?: { statusOnly?: boolean },
options?: { statusOnly?: boolean; viewerUserId?: string },
): Promise<void> {
const statsConfig = this.parseStatsConfig(host.statsConfig);
const statusOnly = options?.statusOnly ?? false;
const viewerUserId = options?.viewerUserId;
const existingConfig = this.pollingConfigs.get(host.id);
@@ -1009,17 +1029,18 @@ class PollingManager {
const config: HostPollingConfig = {
host,
statsConfig,
viewerUserId,
};
if (statsConfig.statusCheckEnabled) {
const intervalMs = statsConfig.statusCheckInterval * 1000;
this.pollHostStatus(host);
this.pollHostStatus(host, viewerUserId);
config.statusTimer = setInterval(() => {
const latestConfig = this.pollingConfigs.get(host.id);
if (latestConfig && latestConfig.statsConfig.statusCheckEnabled) {
this.pollHostStatus(latestConfig.host);
this.pollHostStatus(latestConfig.host, latestConfig.viewerUserId);
}
}, intervalMs);
} else {
@@ -1029,12 +1050,12 @@ class PollingManager {
if (!statusOnly && statsConfig.metricsEnabled) {
const intervalMs = statsConfig.metricsInterval * 1000;
await this.pollHostMetrics(host);
await this.pollHostMetrics(host, viewerUserId);
config.metricsTimer = setInterval(() => {
const latestConfig = this.pollingConfigs.get(host.id);
if (latestConfig && latestConfig.statsConfig.metricsEnabled) {
this.pollHostMetrics(latestConfig.host);
this.pollHostMetrics(latestConfig.host, latestConfig.viewerUserId);
}
}, intervalMs);
} else {
@@ -1044,13 +1065,13 @@ class PollingManager {
this.pollingConfigs.set(host.id, config);
}
private async pollHostStatus(host: SSHHostWithCredentials): Promise<void> {
const refreshedHost = await fetchHostById(host.id, host.userId);
private async pollHostStatus(
host: SSHHostWithCredentials,
viewerUserId?: string,
): Promise<void> {
const userId = viewerUserId || host.userId;
const refreshedHost = await fetchHostById(host.id, userId);
if (!refreshedHost) {
statsLogger.warn("Host not found during status polling", {
operation: "poll_host_status",
hostId: host.id,
});
return;
}
@@ -1074,13 +1095,13 @@ class PollingManager {
}
}
private async pollHostMetrics(host: SSHHostWithCredentials): Promise<void> {
const refreshedHost = await fetchHostById(host.id, host.userId);
private async pollHostMetrics(
host: SSHHostWithCredentials,
viewerUserId?: string,
): Promise<void> {
const userId = viewerUserId || host.userId;
const refreshedHost = await fetchHostById(host.id, userId);
if (!refreshedHost) {
statsLogger.warn("Host not found during metrics polling", {
operation: "poll_host_metrics",
hostId: host.id,
});
return;
}
@@ -1194,7 +1215,87 @@ class PollingManager {
}
}
registerViewer(hostId: number, sessionId: string, userId: string): void {
if (!this.activeViewers.has(hostId)) {
this.activeViewers.set(hostId, new Set());
}
this.activeViewers.get(hostId)!.add(sessionId);
this.viewerDetails.set(sessionId, {
sessionId,
userId,
hostId,
lastHeartbeat: Date.now(),
});
if (this.activeViewers.get(hostId)!.size === 1) {
this.startMetricsForHost(hostId, userId);
}
}
updateHeartbeat(sessionId: string): boolean {
const viewer = this.viewerDetails.get(sessionId);
if (viewer) {
viewer.lastHeartbeat = Date.now();
return true;
}
return false;
}
unregisterViewer(hostId: number, sessionId: string): void {
const viewers = this.activeViewers.get(hostId);
if (viewers) {
viewers.delete(sessionId);
if (viewers.size === 0) {
this.activeViewers.delete(hostId);
this.stopMetricsForHost(hostId);
}
}
this.viewerDetails.delete(sessionId);
}
private async startMetricsForHost(
hostId: number,
userId: string,
): Promise<void> {
try {
const host = await fetchHostById(hostId, userId);
if (host) {
await this.startPollingForHost(host, { viewerUserId: userId });
}
} catch (error) {
statsLogger.error("Failed to start metrics polling", {
operation: "start_metrics_error",
hostId,
error: error instanceof Error ? error.message : String(error),
});
}
}
private stopMetricsForHost(hostId: number): void {
this.stopMetricsOnly(hostId);
}
private cleanupInactiveViewers(): void {
const now = Date.now();
const maxInactivity = 120000;
for (const [sessionId, viewer] of this.viewerDetails.entries()) {
if (now - viewer.lastHeartbeat > maxInactivity) {
statsLogger.warn("Cleaning up inactive viewer", {
operation: "cleanup_inactive_viewer",
sessionId,
hostId: viewer.hostId,
inactiveFor: Math.floor((now - viewer.lastHeartbeat) / 1000),
});
this.unregisterViewer(viewer.hostId, sessionId);
}
}
}
destroy(): void {
clearInterval(this.viewerCleanupInterval);
for (const hostId of this.pollingConfigs.keys()) {
this.stopPollingForHost(hostId);
}
@@ -1297,11 +1398,23 @@ async function fetchHostById(
return undefined;
}
const accessInfo = await permissionManager.canAccessHost(
userId,
id,
"read",
);
if (!accessInfo.hasAccess) {
statsLogger.warn(`User ${userId} cannot access host ${id}`, {
operation: "fetch_host_access_denied",
userId,
hostId: id,
});
return undefined;
}
const hosts = await SimpleDBOps.select(
getDb()
.select()
.from(sshData)
.where(and(eq(sshData.id, id), eq(sshData.userId, userId))),
getDb().select().from(sshData).where(eq(sshData.id, id)),
"ssh_data",
userId,
);
@@ -1362,16 +1475,43 @@ async function resolveHostCredentials(
if (host.credentialId) {
try {
const ownerId = host.userId;
const isSharedHost = userId !== ownerId;
if (isSharedHost) {
const { SharedCredentialManager } =
await import("../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
host.id as number,
userId,
);
baseHost.credentialId = host.credentialId;
baseHost.authType = sharedCred.authType;
if (!host.overrideCredentialUsername) {
baseHost.username = sharedCred.username;
}
if (sharedCred.password) {
baseHost.password = sharedCred.password;
}
if (sharedCred.key) {
baseHost.key = sharedCred.key;
}
if (sharedCred.keyPassword) {
baseHost.keyPassword = sharedCred.keyPassword;
}
if (sharedCred.keyType) {
baseHost.keyType = sharedCred.keyType;
}
} else {
const credentials = await SimpleDBOps.select(
getDb()
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, host.credentialId as number),
eq(sshCredentials.userId, userId),
),
),
.where(eq(sshCredentials.id, host.credentialId as number)),
"ssh_credentials",
userId,
);
@@ -1379,9 +1519,12 @@ async function resolveHostCredentials(
if (credentials.length > 0) {
const credential = credentials[0];
baseHost.credentialId = credential.id;
baseHost.username = credential.username;
baseHost.authType = credential.auth_type || credential.authType;
if (!host.overrideCredentialUsername) {
baseHost.username = credential.username;
}
if (credential.password) {
baseHost.password = credential.password;
}
@@ -1398,6 +1541,7 @@ async function resolveHostCredentials(
} else {
addLegacyCredentials(baseHost, host);
}
}
} catch (error) {
statsLogger.warn(
`Failed to resolve credential ${host.credentialId} for host ${host.id}: ${error instanceof Error ? error.message : "Unknown error"}`,
@@ -1928,6 +2072,7 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
requires_totp?: boolean;
sessionId?: string;
prompt?: string;
viewerSessionId?: string;
}>((resolve, reject) => {
let isResolved = false;
@@ -2006,15 +2151,10 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
};
scheduleMetricsSessionCleanup(sessionKey);
pollingManager.startPollingForHost(host).catch((error) => {
statsLogger.error("Failed to start polling after connection", {
operation: "start_polling_error",
hostId: host.id,
error: error instanceof Error ? error.message : String(error),
});
});
const viewerSessionId = `viewer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
pollingManager.registerViewer(host.id, viewerSessionId, userId);
resolve({ success: true });
resolve({ success: true, viewerSessionId });
}
});
@@ -2082,6 +2222,7 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
app.post("/metrics/stop/:id", validateHostId, async (req, res) => {
const id = Number(req.params.id);
const userId = (req as AuthenticatedRequest).userId;
const { viewerSessionId } = req.body;
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
return res.status(401).json({
@@ -2098,7 +2239,11 @@ app.post("/metrics/stop/:id", validateHostId, async (req, res) => {
cleanupMetricsSession(sessionKey);
}
if (viewerSessionId && typeof viewerSessionId === "string") {
pollingManager.unregisterViewer(id, viewerSessionId);
} else {
pollingManager.stopMetricsOnly(id);
}
res.json({ success: true });
} catch (error) {
@@ -2225,18 +2370,10 @@ app.post("/metrics/connect-totp", async (req, res) => {
delete pendingTOTPSessions[sessionId];
const host = await fetchHostById(session.hostId, userId);
if (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),
});
});
}
const viewerSessionId = `viewer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
pollingManager.registerViewer(session.hostId, viewerSessionId, userId);
res.json({ success: true });
res.json({ success: true, viewerSessionId });
} catch (error) {
statsLogger.error("TOTP verification failed", {
operation: "totp_verification_failed",
@@ -2259,6 +2396,101 @@ app.post("/metrics/connect-totp", async (req, res) => {
}
});
app.post("/metrics/heartbeat", async (req, res) => {
const { viewerSessionId } = req.body;
const userId = (req as AuthenticatedRequest).userId;
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
if (!viewerSessionId || typeof viewerSessionId !== "string") {
return res.status(400).json({ error: "Invalid viewerSessionId" });
}
try {
const success = pollingManager.updateHeartbeat(viewerSessionId);
if (success) {
res.json({ success: true });
} else {
res.status(404).json({ error: "Viewer session not found" });
}
} catch (error) {
statsLogger.error("Failed to update heartbeat", {
operation: "heartbeat_error",
viewerSessionId,
error: error instanceof Error ? error.message : String(error),
});
res.status(500).json({ error: "Failed to update heartbeat" });
}
});
app.post("/metrics/register-viewer", async (req, res) => {
const { hostId } = req.body;
const userId = (req as AuthenticatedRequest).userId;
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
if (!hostId || typeof hostId !== "number") {
return res.status(400).json({ error: "Invalid hostId" });
}
try {
const viewerSessionId = `viewer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
pollingManager.registerViewer(hostId, viewerSessionId, userId);
res.json({ success: true, viewerSessionId });
} catch (error) {
statsLogger.error("Failed to register viewer", {
operation: "register_viewer_error",
hostId,
userId,
error: error instanceof Error ? error.message : String(error),
});
res.status(500).json({ error: "Failed to register viewer" });
}
});
app.post("/metrics/unregister-viewer", async (req, res) => {
const { hostId, viewerSessionId } = req.body;
const userId = (req as AuthenticatedRequest).userId;
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
if (!hostId || typeof hostId !== "number") {
return res.status(400).json({ error: "Invalid hostId" });
}
if (!viewerSessionId || typeof viewerSessionId !== "string") {
return res.status(400).json({ error: "Invalid viewerSessionId" });
}
try {
pollingManager.unregisterViewer(hostId, viewerSessionId);
res.json({ success: true });
} catch (error) {
statsLogger.error("Failed to unregister viewer", {
operation: "unregister_viewer_error",
hostId,
viewerSessionId,
error: error instanceof Error ? error.message : String(error),
});
res.status(500).json({ error: "Failed to unregister viewer" });
}
});
process.on("SIGINT", () => {
pollingManager.destroy();
connectionPool.destroy();

View File

@@ -828,6 +828,9 @@
"hostAddedSuccessfully": "Host \"{{name}}\" added successfully!",
"hostDeletedSuccessfully": "Host \"{{name}}\" deleted successfully!",
"failedToSaveHost": "Failed to save host. Please try again.",
"savingHost": "Saving host...",
"updatingHost": "Updating host...",
"cloningHost": "Cloning host...",
"enableTerminal": "Enable Terminal",
"enableTerminalDesc": "Enable/disable host visibility in Terminal tab",
"enableTunnel": "Enable Tunnel",

View File

@@ -422,35 +422,6 @@ export function UserEditDialog({
<Separator />
{showPasswordReset && (
<>
<div className="space-y-3">
<Label className="text-base font-semibold flex items-center gap-2">
<Key className="h-4 w-4" />
{t("admin.passwordManagement")}
</Label>
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>{t("common.warning")}</AlertTitle>
<AlertDescription>
{t("admin.passwordResetWarning")}
</AlertDescription>
</Alert>
<Button
variant="destructive"
onClick={handlePasswordReset}
disabled={passwordResetLoading}
className="w-full"
>
{passwordResetLoading
? t("admin.resettingPassword")
: t("admin.resetUserPassword")}
</Button>
</div>
<Separator />
</>
)}
<div className="space-y-4">
<Label className="text-base font-semibold flex items-center gap-2">
<UserCog className="h-4 w-4" />

View File

@@ -43,34 +43,6 @@ export function ConsoleTerminal({
const fitAddonRef = React.useRef<FitAddon | null>(null);
const pingIntervalRef = React.useRef<NodeJS.Timeout | null>(null);
const getWebSocketBaseUrl = React.useCallback(() => {
const isElectronApp = isElectron();
const isDev =
!isElectronApp &&
process.env.NODE_ENV === "development" &&
(window.location.port === "3000" ||
window.location.port === "5173" ||
window.location.port === "");
if (isDev) {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//localhost:30008`;
}
if (isElectronApp) {
const baseUrl =
(window as { configuredServerUrl?: string }).configuredServerUrl ||
"http://127.0.0.1:30001";
const wsProtocol = baseUrl.startsWith("https://") ? "wss://" : "ws://";
const wsHost = baseUrl.replace(/^https?:\/\//, "");
return `${wsProtocol}${wsHost}/docker/console/`;
}
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${window.location.host}/docker/console/`;
}, []);
React.useEffect(() => {
if (!terminal) return;
@@ -175,7 +147,31 @@ export function ConsoleTerminal({
fitAddonRef.current.fit();
}
const wsUrl = `${getWebSocketBaseUrl()}?token=${encodeURIComponent(token)}`;
const isElectronApp = isElectron();
const isDev =
!isElectronApp &&
process.env.NODE_ENV === "development" &&
(window.location.port === "3000" ||
window.location.port === "5173" ||
window.location.port === "");
const baseWsUrl = isDev
? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30008`
: isElectronApp
? (() => {
const baseUrl =
(window as { configuredServerUrl?: string })
.configuredServerUrl || "http://127.0.0.1:30001";
const wsProtocol = baseUrl.startsWith("https://")
? "wss://"
: "ws://";
const wsHost = baseUrl.replace(/^https?:\/\//, "");
return `${wsProtocol}${wsHost}/docker/console/`;
})()
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/docker/console/`;
const wsUrl = `${baseWsUrl}?token=${encodeURIComponent(token)}`;
const ws = new WebSocket(wsUrl);
ws.onopen = () => {

View File

@@ -32,16 +32,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip.tsx";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog.tsx";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
interface ContainerCardProps {
container: DockerContainer;
@@ -59,12 +50,12 @@ export function ContainerCard({
onRefresh,
}: ContainerCardProps): React.ReactElement {
const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
const [isStarting, setIsStarting] = React.useState(false);
const [isStopping, setIsStopping] = React.useState(false);
const [isRestarting, setIsRestarting] = React.useState(false);
const [isPausing, setIsPausing] = React.useState(false);
const [isRemoving, setIsRemoving] = React.useState(false);
const [showRemoveDialog, setShowRemoveDialog] = React.useState(false);
const statusColors = {
running: {
@@ -191,13 +182,28 @@ export function ContainerCard({
}
};
const handleRemove = async () => {
const handleRemove = async (e: React.MouseEvent) => {
e.stopPropagation();
const containerName = container.name.startsWith("/")
? container.name.slice(1)
: container.name;
let confirmMessage = t("docker.confirmRemoveContainer", {
name: containerName,
});
if (container.state === "running") {
confirmMessage += " " + t("docker.runningContainerWarning");
}
confirmWithToast(
confirmMessage,
async () => {
setIsRemoving(true);
try {
const force = container.state === "running";
await removeDockerContainer(sessionId, container.id, force);
toast.success(t("docker.containerRemoved", { name: container.name }));
setShowRemoveDialog(false);
toast.success(t("docker.containerRemoved", { name: containerName }));
onRefresh?.();
} catch (error) {
toast.error(
@@ -208,6 +214,10 @@ export function ContainerCard({
} finally {
setIsRemoving(false);
}
},
t("common.remove"),
t("common.cancel"),
);
};
const isLoading =
@@ -405,56 +415,18 @@ export function ContainerCard({
size="sm"
variant="outline"
className="h-8 text-red-400 hover:text-red-300 hover:bg-red-500/20"
onClick={(e) => {
e.stopPropagation();
setShowRemoveDialog(true);
}}
onClick={handleRemove}
disabled={isLoading}
>
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("docker.remove")}</TooltipContent>{" "}
<TooltipContent>{t("docker.remove")}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</CardContent>
</Card>
<AlertDialog open={showRemoveDialog} onOpenChange={setShowRemoveDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("docker.removeContainer")}</AlertDialogTitle>
<AlertDialogDescription>
{t("docker.confirmRemoveContainer", {
name: container.name.startsWith("/")
? container.name.slice(1)
: container.name,
})}
{container.state === "running" && (
<div className="mt-2 text-yellow-400">
{t("docker.runningContainerWarning")}
</div>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isRemoving}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleRemove();
}}
disabled={isRemoving}
className="bg-red-600 hover:bg-red-700"
>
{isRemoving ? t("docker.removing") : t("common.remove")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -329,6 +329,22 @@ export function FileViewer({
const fileTypeInfo = getFileType(file.name);
const getImageDataUrl = (content: string, fileName: string): string => {
const ext = fileName.split(".").pop()?.toLowerCase() || "";
if (ext === "svg") {
try {
const base64 = btoa(unescape(encodeURIComponent(content)));
return `data:image/svg+xml;base64,${base64}`;
} catch (e) {
console.error("Failed to encode SVG:", e);
return "";
}
}
return `data:image/*;base64,${content}`;
};
const WARNING_SIZE = 50 * 1024 * 1024;
const MAX_SIZE = Number.MAX_SAFE_INTEGER;
@@ -353,15 +369,6 @@ export function FileViewer({
} else {
setShowLargeFileWarning(false);
}
if (
fileTypeInfo.type === "image" &&
file.name.toLowerCase().endsWith(".svg") &&
content
) {
setImageLoading(false);
setImageLoadError(false);
}
}, [
content,
savedContent,
@@ -716,21 +723,11 @@ export function FileViewer({
</Button>
)}
</div>
) : file.name.toLowerCase().endsWith(".svg") ? (
<div
className="max-w-full max-h-full flex items-center justify-center"
style={{ maxHeight: "calc(100vh - 200px)" }}
dangerouslySetInnerHTML={{ __html: content }}
onLoad={() => {
setImageLoading(false);
setImageLoadError(false);
}}
/>
) : (
<PhotoProvider maskOpacity={0.7}>
<PhotoView src={`data:image/*;base64,${content}`}>
<PhotoView src={getImageDataUrl(content, file.name)}>
<img
src={`data:image/*;base64,${content}`}
src={getImageDataUrl(content, file.name)}
alt={file.name}
className="max-w-full max-h-full object-contain rounded-lg shadow-sm cursor-pointer hover:shadow-lg transition-shadow"
style={{ maxHeight: "calc(100vh - 200px)" }}

View File

@@ -102,6 +102,9 @@ export function ServerStats({
const [totpPrompt, setTotpPrompt] = React.useState<string>("");
const [isPageVisible, setIsPageVisible] = React.useState(!document.hidden);
const [totpVerified, setTotpVerified] = React.useState(false);
const [viewerSessionId, setViewerSessionId] = React.useState<string | null>(
null,
);
const activityLoggedRef = React.useRef(false);
const activityLoggingRef = React.useRef(false);
@@ -137,6 +140,21 @@ export function ServerStats({
const isActuallyVisible = isVisible && isPageVisible;
React.useEffect(() => {
if (!viewerSessionId || !isActuallyVisible) return;
const heartbeatInterval = setInterval(async () => {
try {
const { sendMetricsHeartbeat } = await import("@/ui/main-axios.ts");
await sendMetricsHeartbeat(viewerSessionId);
} catch (error) {
console.error("Failed to send heartbeat:", error);
}
}, 30000);
return () => clearInterval(heartbeatInterval);
}, [viewerSessionId, isActuallyVisible]);
React.useEffect(() => {
if (hostConfig?.id !== currentHostConfig?.id) {
setServerStatus("offline");
@@ -182,6 +200,9 @@ export function ServerStats({
setTotpSessionId(null);
setShowStatsUI(true);
setTotpVerified(true);
if (result.viewerSessionId) {
setViewerSessionId(result.viewerSessionId);
}
} else {
toast.error(t("serverStats.totpFailed"));
}
@@ -383,6 +404,10 @@ export function ServerStats({
setIsLoadingMetrics(false);
return;
}
if (result.viewerSessionId) {
setViewerSessionId(result.viewerSessionId);
}
}
let retryCount = 0;
@@ -453,7 +478,10 @@ export function ServerStats({
}
if (currentHostConfig?.id) {
try {
await stopMetricsPolling(currentHostConfig.id);
await stopMetricsPolling(
currentHostConfig.id,
viewerSessionId || undefined,
);
} catch (error) {
console.error("Failed to stop metrics polling:", error);
}

View File

@@ -96,6 +96,11 @@ export function DiskWidget({ metrics, metricsHistory }: DiskWidgetProps) {
color: "#fff",
}}
formatter={(value: number) => [`${value.toFixed(1)}%`, "Disk"]}
cursor={{
stroke: "#fb923c",
strokeWidth: 1,
strokeDasharray: "3 3",
}}
/>
<Area
type="monotone"
@@ -104,6 +109,12 @@ export function DiskWidget({ metrics, metricsHistory }: DiskWidgetProps) {
strokeWidth={2}
fill="url(#diskGradient)"
animationDuration={300}
activeDot={{
r: 4,
fill: "#fb923c",
stroke: "#fff",
strokeWidth: 2,
}}
/>
</AreaChart>
</ResponsiveContainer>

View File

@@ -102,6 +102,11 @@ export function MemoryWidget({ metrics, metricsHistory }: MemoryWidgetProps) {
`${value.toFixed(1)}%`,
"Memory",
]}
cursor={{
stroke: "#34d399",
strokeWidth: 1,
strokeDasharray: "3 3",
}}
/>
<Area
type="monotone"
@@ -110,6 +115,12 @@ export function MemoryWidget({ metrics, metricsHistory }: MemoryWidgetProps) {
strokeWidth={2}
fill="url(#memoryGradient)"
animationDuration={300}
activeDot={{
r: 4,
fill: "#34d399",
stroke: "#fff",
strokeWidth: 2,
}}
/>
</AreaChart>
</ResponsiveContainer>

View File

@@ -129,6 +129,7 @@ import { HostTunnelTab } from "./tabs/HostTunnelTab";
import { HostFileManagerTab } from "./tabs/HostFileManagerTab";
import { HostStatisticsTab } from "./tabs/HostStatisticsTab";
import { HostSharingTab } from "./tabs/HostSharingTab";
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
interface User {
id: string;
@@ -168,7 +169,7 @@ export function HostManagerEditor({
const [keyInputMethod, setKeyInputMethod] = useState<"upload" | "paste">(
"upload",
);
const isSubmittingRef = useRef(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [activeTab, setActiveTab] = useState("general");
const [formError, setFormError] = useState<string | null>(null);
@@ -473,6 +474,7 @@ export function HostManagerEditor({
const form = useForm<FormData>({
resolver: zodResolver(formSchema) as any,
mode: "all",
defaultValues: {
name: "",
ip: "",
@@ -509,8 +511,45 @@ export function HostManagerEditor({
},
});
const watchedFields = form.watch();
const formState = form.formState;
const isFormValid = React.useMemo(() => {
const values = form.getValues();
if (!values.ip || !values.username) return false;
if (authTab === "password") {
return !!(values.password && values.password.trim() !== "");
} else if (authTab === "key") {
return !!(values.key && values.keyType);
} else if (authTab === "credential") {
return !!values.credentialId;
} else if (authTab === "none") {
return true;
}
return false;
}, [watchedFields, authTab]);
useEffect(() => {
if (authTab === "credential") {
const updateAuthFields = async () => {
form.setValue("authType", authTab, { shouldValidate: true });
if (authTab === "password") {
form.setValue("key", null, { shouldValidate: true });
form.setValue("keyPassword", "", { shouldValidate: true });
form.setValue("keyType", "auto", { shouldValidate: true });
form.setValue("credentialId", null, { shouldValidate: true });
} else if (authTab === "key") {
form.setValue("password", "", { shouldValidate: true });
form.setValue("credentialId", null, { shouldValidate: true });
} else if (authTab === "credential") {
form.setValue("password", "", { shouldValidate: true });
form.setValue("key", null, { shouldValidate: true });
form.setValue("keyPassword", "", { shouldValidate: true });
form.setValue("keyType", "auto", { shouldValidate: true });
const currentCredentialId = form.getValues("credentialId");
const overrideUsername = form.getValues("overrideCredentialUsername");
if (currentCredentialId && !overrideUsername) {
@@ -518,10 +557,23 @@ export function HostManagerEditor({
(c) => c.id === currentCredentialId,
);
if (selectedCredential) {
form.setValue("username", selectedCredential.username);
form.setValue("username", selectedCredential.username, {
shouldValidate: true,
});
}
}
} else if (authTab === "none") {
form.setValue("password", "", { shouldValidate: true });
form.setValue("key", null, { shouldValidate: true });
form.setValue("keyPassword", "", { shouldValidate: true });
form.setValue("keyType", "auto", { shouldValidate: true });
form.setValue("credentialId", null, { shouldValidate: true });
}
await form.trigger();
};
updateAuthFields();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [authTab, credentials]);
@@ -690,36 +742,14 @@ export function HostManagerEditor({
}, [editingHost]);
const onSubmit = async (data: FormData) => {
await form.trigger();
try {
isSubmittingRef.current = true;
setIsSubmitting(true);
setFormError(null);
if (!data.name || data.name.trim() === "") {
data.name = `${data.username}@${data.ip}`;
}
if (data.statsConfig) {
const statusInterval = data.statsConfig.statusCheckInterval || 30;
const metricsInterval = data.statsConfig.metricsInterval || 30;
if (statusInterval < 5 || statusInterval > 3600) {
toast.error(t("hosts.intervalValidation"));
setActiveTab("statistics");
setFormError(t("hosts.intervalValidation"));
isSubmittingRef.current = false;
return;
}
if (metricsInterval < 5 || metricsInterval > 3600) {
toast.error(t("hosts.intervalValidation"));
setActiveTab("statistics");
setFormError(t("hosts.intervalValidation"));
isSubmittingRef.current = false;
return;
}
}
const submitData: Partial<SSHHost> = {
...data,
};
@@ -799,40 +829,72 @@ export function HostManagerEditor({
toast.error(t("hosts.failedToSaveHost") + ": " + errorMessage);
console.error("Failed to save host:", error);
} finally {
isSubmittingRef.current = false;
setIsSubmitting(false);
}
};
const handleFormError = () => {
const errors = form.formState.errors;
const TAB_PRIORITY = [
"general",
"terminal",
"tunnel",
"file_manager",
"docker",
"statistics",
] as const;
if (
errors.ip ||
errors.port ||
errors.username ||
errors.name ||
errors.folder ||
errors.tags ||
errors.pin ||
errors.password ||
errors.key ||
errors.keyPassword ||
errors.keyType ||
errors.credentialId ||
errors.forceKeyboardInteractive ||
errors.jumpHosts
) {
setActiveTab("general");
} else if (errors.enableTerminal || errors.terminalConfig) {
setActiveTab("terminal");
} else if (errors.enableDocker) {
setActiveTab("docker");
} else if (errors.enableTunnel || errors.tunnelConnections) {
setActiveTab("tunnel");
} else if (errors.enableFileManager || errors.defaultPath) {
setActiveTab("file_manager");
} else if (errors.statsConfig) {
setActiveTab("statistics");
const FIELD_TO_TAB_MAP: Record<string, string> = {
ip: "general",
port: "general",
username: "general",
name: "general",
folder: "general",
tags: "general",
pin: "general",
password: "general",
key: "general",
keyPassword: "general",
keyType: "general",
credentialId: "general",
overrideCredentialUsername: "general",
forceKeyboardInteractive: "general",
jumpHosts: "general",
authType: "general",
notes: "general",
useSocks5: "general",
socks5Host: "general",
socks5Port: "general",
socks5Username: "general",
socks5Password: "general",
socks5ProxyChain: "general",
quickActions: "general",
enableTerminal: "terminal",
terminalConfig: "terminal",
enableDocker: "docker",
enableTunnel: "tunnel",
tunnelConnections: "tunnel",
enableFileManager: "file_manager",
defaultPath: "file_manager",
statsConfig: "statistics",
};
const handleFormError = async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
const errors = form.formState.errors;
const errorFields = Object.keys(errors);
if (errorFields.length === 0) return;
for (const tab of TAB_PRIORITY) {
const hasErrorInTab = errorFields.some((field) => {
const baseField = field.split(".")[0].split("[")[0];
return FIELD_TO_TAB_MAP[baseField] === tab;
});
if (hasErrorInTab) {
setActiveTab(tab);
return;
}
}
};
@@ -994,7 +1056,19 @@ export function HostManagerEditor({
}, [sshConfigDropdownOpen]);
return (
<div className="flex-1 flex flex-col h-full min-h-0 w-full">
<div className="flex-1 flex flex-col h-full min-h-0 w-full relative">
<SimpleLoader
visible={isSubmitting}
message={
editingHost?.id
? t("hosts.updatingHost")
: editingHost
? t("hosts.cloningHost")
: t("hosts.savingHost")
}
backgroundColor="var(--bg-base)"
/>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit, handleFormError)}
@@ -1130,7 +1204,12 @@ export function HostManagerEditor({
<footer className="shrink-0 w-full pb-0">
<Separator className="p-0.25" />
{!editingHost?.isShared && (
<Button className="translate-y-2" type="submit" variant="outline">
<Button
className="translate-y-2"
type="submit"
variant="outline"
disabled={!isFormValid || isSubmitting}
>
{editingHost
? editingHost.id
? t("hosts.updateHost")

View File

@@ -1983,6 +1983,7 @@ export async function startMetricsPolling(hostId: number): Promise<{
requires_totp?: boolean;
sessionId?: string;
prompt?: string;
viewerSessionId?: string;
}> {
try {
const response = await statsApi.post(`/metrics/start/${hostId}`);
@@ -1993,20 +1994,64 @@ export async function startMetricsPolling(hostId: number): Promise<{
}
}
export async function stopMetricsPolling(hostId: number): Promise<void> {
export async function stopMetricsPolling(
hostId: number,
viewerSessionId?: string,
): Promise<void> {
try {
await statsApi.post(`/metrics/stop/${hostId}`);
await statsApi.post(`/metrics/stop/${hostId}`, { viewerSessionId });
} catch (error) {
handleApiError(error, "stop metrics polling");
throw error;
}
}
export async function sendMetricsHeartbeat(
viewerSessionId: string,
): Promise<void> {
try {
await statsApi.post("/metrics/heartbeat", { viewerSessionId });
} catch (error) {
handleApiError(error, "send metrics heartbeat");
throw error;
}
}
export async function registerMetricsViewer(
hostId: number,
): Promise<{ success: boolean; viewerSessionId: string }> {
try {
const response = await statsApi.post("/metrics/register-viewer", {
hostId,
});
return response.data;
} catch (error) {
handleApiError(error, "register metrics viewer");
throw error;
}
}
export async function unregisterMetricsViewer(
hostId: number,
viewerSessionId: string,
): Promise<void> {
try {
await statsApi.post("/metrics/unregister-viewer", {
hostId,
viewerSessionId,
});
} catch (error) {
handleApiError(error, "unregister metrics viewer");
throw error;
}
}
export async function submitMetricsTOTP(
sessionId: string,
totpCode: string,
): Promise<{
success: boolean;
viewerSessionId?: string;
}> {
try {
const response = await statsApi.post("/metrics/connect-totp", {