fix: Squash commit of several fixes and features for many different elements

This commit is contained in:
LukeGus
2025-10-29 18:12:44 -05:00
parent 562d8c96fd
commit ae73f9ca55
32 changed files with 3149 additions and 2057 deletions

View File

@@ -231,6 +231,51 @@ export function AdminSettings({
};
const handleTogglePasswordLogin = async (checked: boolean) => {
// If disabling password login, warn the user
if (!checked) {
// Check if OIDC is configured
const hasOIDCConfigured =
oidcConfig.client_id &&
oidcConfig.client_secret &&
oidcConfig.issuer_url &&
oidcConfig.authorization_url &&
oidcConfig.token_url;
if (!hasOIDCConfigured) {
toast.error(t("admin.cannotDisablePasswordLoginWithoutOIDC"), {
duration: 5000,
});
return;
}
confirmWithToast(
t("admin.confirmDisablePasswordLogin"),
async () => {
setPasswordLoginLoading(true);
try {
await updatePasswordLoginAllowed(checked);
setAllowPasswordLogin(checked);
// Auto-disable registration when password login is disabled
if (allowRegistration) {
await updateRegistrationAllowed(false);
setAllowRegistration(false);
toast.success(t("admin.passwordLoginAndRegistrationDisabled"));
} else {
toast.success(t("admin.passwordLoginDisabled"));
}
} catch {
toast.error(t("admin.failedToUpdatePasswordLoginStatus"));
} finally {
setPasswordLoginLoading(false);
}
},
"destructive",
);
return;
}
// Enabling password login - proceed normally
setPasswordLoginLoading(true);
try {
await updatePasswordLoginAllowed(checked);
@@ -552,9 +597,14 @@ export function AdminSettings({
<Checkbox
checked={allowRegistration}
onCheckedChange={handleToggleRegistration}
disabled={regLoading}
disabled={regLoading || !allowPasswordLogin}
/>
{t("admin.allowNewAccountRegistration")}
{!allowPasswordLogin && (
<span className="text-xs text-muted-foreground">
({t("admin.requiresPasswordLogin")})
</span>
)}
</label>
<label className="flex items-center gap-2">
<Checkbox
@@ -588,6 +638,15 @@ export function AdminSettings({
</Button>
</div>
{!allowPasswordLogin && (
<Alert variant="destructive">
<AlertTitle>{t("admin.criticalWarning")}</AlertTitle>
<AlertDescription>
{t("admin.oidcRequiredWarning")}
</AlertDescription>
</Alert>
)}
{oidcError && (
<Alert variant="destructive">
<AlertTitle>{t("common.error")}</AlertTitle>
@@ -733,6 +792,48 @@ export function AdminSettings({
type="button"
variant="outline"
onClick={async () => {
// Check if password login is enabled
if (!allowPasswordLogin) {
confirmWithToast(
t("admin.confirmDisableOIDCWarning"),
async () => {
const emptyConfig = {
client_id: "",
client_secret: "",
issuer_url: "",
authorization_url: "",
token_url: "",
identifier_path: "",
name_path: "",
scopes: "",
userinfo_url: "",
};
setOidcConfig(emptyConfig);
setOidcError(null);
setOidcLoading(true);
try {
await disableOIDCConfig();
toast.success(
t("admin.oidcConfigurationDisabled"),
);
} catch (err: unknown) {
setOidcError(
(
err as {
response?: { data?: { error?: string } };
}
)?.response?.data?.error ||
t("admin.failedToDisableOidcConfig"),
);
} finally {
setOidcLoading(false);
}
},
"destructive",
);
return;
}
const emptyConfig = {
client_id: "",
client_secret: "",

View File

@@ -233,6 +233,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
return;
}
// Set flags IMMEDIATELY to prevent race conditions
activityLoggingRef.current = true;
activityLoggedRef.current = true;
@@ -240,6 +241,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
const hostName =
currentHost.name || `${currentHost.username}@${currentHost.ip}`;
await logActivity("file_manager", currentHost.id, hostName);
// Don't reset activityLoggedRef on success - we want to prevent future calls
} catch (err) {
console.warn("Failed to log file manager activity:", err);
// Reset on error so it can be retried
@@ -337,7 +339,10 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
initialLoadDoneRef.current = true;
// Log activity for recent connections (after successful directory load)
logFileManagerActivity();
// Only log if TOTP was not required (if TOTP is required, we'll log after verification)
if (!result?.requires_totp) {
logFileManagerActivity();
}
} catch (dirError: unknown) {
console.error("Failed to load initial directory:", dirError);
}

View File

@@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useState, useEffect, useRef } from "react";
import { HostManagerViewer } from "@/ui/Desktop/Apps/Host Manager/HostManagerViewer.tsx";
import {
Tabs,
@@ -17,10 +17,13 @@ import type { SSHHost, HostManagerProps } from "../../../types/index";
export function HostManager({
isTopbarOpen,
initialTab = "host_viewer",
hostConfig,
}: HostManagerProps): React.ReactElement {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState(initialTab);
const [editingHost, setEditingHost] = useState<SSHHost | null>(null);
const [editingHost, setEditingHost] = useState<SSHHost | null>(
hostConfig || null,
);
const [editingCredential, setEditingCredential] = useState<{
id: number;
@@ -28,6 +31,16 @@ export function HostManager({
username: string;
} | null>(null);
const { state: sidebarState } = useSidebar();
const prevHostConfigRef = useRef<SSHHost | undefined>(hostConfig);
// Update editing host when hostConfig prop changes
useEffect(() => {
if (hostConfig && hostConfig !== prevHostConfigRef.current) {
setEditingHost(hostConfig);
setActiveTab(initialTab || "add_host");
prevHostConfigRef.current = hostConfig;
}
}, [hostConfig, initialTab]);
const handleEditHost = (host: SSHHost) => {
setEditingHost(host);

File diff suppressed because it is too large Load Diff

View File

@@ -43,11 +43,14 @@ import {
Pencil,
FolderMinus,
Copy,
Activity,
Clock,
} from "lucide-react";
import type {
SSHHost,
SSHManagerHostViewerProps,
} from "../../../../types/index.js";
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
const { t } = useTranslation();
@@ -122,6 +125,10 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
toast.success(t("hosts.hostDeletedSuccessfully", { name: hostName }));
await fetchHosts();
window.dispatchEvent(new CustomEvent("ssh-hosts:changed"));
// Refresh backend polling to remove deleted host
const { refreshServerPolling } = await import("@/ui/main-axios.ts");
refreshServerPolling();
} catch {
toast.error(t("hosts.failedToDeleteHost"));
}
@@ -385,6 +392,48 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
}
};
// Helper function to parse stats config and format monitoring status
const getMonitoringStatus = (host: SSHHost) => {
try {
const statsConfig = host.statsConfig
? JSON.parse(host.statsConfig)
: DEFAULT_STATS_CONFIG;
const formatInterval = (seconds: number): string => {
if (seconds >= 60) {
const minutes = Math.round(seconds / 60);
return `${minutes}m`;
}
return `${seconds}s`;
};
const statusEnabled = statsConfig.statusCheckEnabled !== false;
const metricsEnabled = statsConfig.metricsEnabled !== false;
const statusInterval = statusEnabled
? formatInterval(statsConfig.statusCheckInterval || 30)
: null;
const metricsInterval = metricsEnabled
? formatInterval(statsConfig.metricsInterval || 30)
: null;
return {
statusEnabled,
metricsEnabled,
statusInterval,
metricsInterval,
bothDisabled: !statusEnabled && !metricsEnabled,
};
} catch {
return {
statusEnabled: true,
metricsEnabled: true,
statusInterval: "30s",
metricsInterval: "30s",
bothDisabled: false,
};
}
};
const filteredAndSortedHosts = useMemo(() => {
let filtered = hosts;
@@ -1088,6 +1137,49 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
{t("hosts.fileManagerBadge")}
</Badge>
)}
{/* Monitoring Status Badges */}
{(() => {
const monitoringStatus =
getMonitoringStatus(host);
if (monitoringStatus.bothDisabled) {
return (
<Badge
variant="outline"
className="text-xs px-1 py-0 text-muted-foreground"
>
<Activity className="h-2 w-2 mr-0.5" />
{t("hosts.monitoringDisabledBadge")}
</Badge>
);
}
return (
<>
{monitoringStatus.statusEnabled && (
<Badge
variant="outline"
className="text-xs px-1 py-0"
>
<Activity className="h-2 w-2 mr-0.5" />
{t("hosts.statusMonitoring")}:{" "}
{monitoringStatus.statusInterval}
</Badge>
)}
{monitoringStatus.metricsEnabled && (
<Badge
variant="outline"
className="text-xs px-1 py-0"
>
<Clock className="h-2 w-2 mr-0.5" />
{t("hosts.metricsMonitoring")}:{" "}
{monitoringStatus.metricsInterval}
</Badge>
)}
</>
);
})()}
</div>
</div>
</div>

View File

@@ -80,22 +80,27 @@ export function Server({
const [isRefreshing, setIsRefreshing] = React.useState(false);
const [showStatsUI, setShowStatsUI] = React.useState(true);
const enabledWidgets = React.useMemo((): WidgetType[] => {
// Parse stats config for monitoring settings
const statsConfig = React.useMemo((): StatsConfig => {
if (!currentHostConfig?.statsConfig) {
return DEFAULT_STATS_CONFIG.enabledWidgets;
return DEFAULT_STATS_CONFIG;
}
try {
const parsed =
typeof currentHostConfig.statsConfig === "string"
? JSON.parse(currentHostConfig.statsConfig)
: currentHostConfig.statsConfig;
return parsed?.enabledWidgets || DEFAULT_STATS_CONFIG.enabledWidgets;
return { ...DEFAULT_STATS_CONFIG, ...parsed };
} catch (error) {
console.error("Failed to parse statsConfig:", error);
return DEFAULT_STATS_CONFIG.enabledWidgets;
return DEFAULT_STATS_CONFIG;
}
}, [currentHostConfig?.statsConfig]);
const enabledWidgets = statsConfig.enabledWidgets;
const statusCheckEnabled = statsConfig.statusCheckEnabled !== false;
const metricsEnabled = statsConfig.metricsEnabled !== false;
React.useEffect(() => {
setCurrentHostConfig(hostConfig);
}, [hostConfig]);
@@ -176,7 +181,13 @@ export function Server({
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
}, [hostConfig?.id]);
// Separate effect for status monitoring
React.useEffect(() => {
if (!statusCheckEnabled || !currentHostConfig?.id || !isVisible) {
setServerStatus("offline");
return;
}
let cancelled = false;
let intervalId: number | undefined;
@@ -196,15 +207,34 @@ export function Server({
} else if (err?.response?.status === 504) {
setServerStatus("offline");
} else if (err?.response?.status === 404) {
// Status not available - monitoring disabled
setServerStatus("offline");
} else {
setServerStatus("offline");
}
toast.error(t("serverStats.failedToFetchStatus"));
}
}
};
fetchStatus();
intervalId = window.setInterval(fetchStatus, 10000); // Poll backend every 10 seconds
return () => {
cancelled = true;
if (intervalId) window.clearInterval(intervalId);
};
}, [currentHostConfig?.id, isVisible, statusCheckEnabled]);
// Separate effect for metrics monitoring
React.useEffect(() => {
if (!metricsEnabled || !currentHostConfig?.id || !isVisible) {
setShowStatsUI(false);
return;
}
let cancelled = false;
let intervalId: number | undefined;
const fetchMetrics = async () => {
if (!currentHostConfig?.id) return;
try {
@@ -221,19 +251,25 @@ export function Server({
}
} catch (error: unknown) {
if (!cancelled) {
setMetrics(null);
setShowStatsUI(false);
const err = error as {
code?: string;
response?: { status?: number; data?: { error?: string } };
};
if (
if (err?.response?.status === 404) {
// Metrics not available - monitoring disabled
setMetrics(null);
setShowStatsUI(false);
} else if (
err?.code === "TOTP_REQUIRED" ||
(err?.response?.status === 403 &&
err?.response?.data?.error === "TOTP_REQUIRED")
) {
setMetrics(null);
setShowStatsUI(false);
toast.error(t("serverStats.totpUnavailable"));
} else {
setMetrics(null);
setShowStatsUI(false);
toast.error(t("serverStats.failedToFetchMetrics"));
}
}
@@ -244,20 +280,14 @@ export function Server({
}
};
if (currentHostConfig?.id && isVisible) {
fetchStatus();
fetchMetrics();
intervalId = window.setInterval(() => {
fetchStatus();
fetchMetrics();
}, 30000);
}
fetchMetrics();
intervalId = window.setInterval(fetchMetrics, 10000); // Poll backend every 10 seconds
return () => {
cancelled = true;
if (intervalId) window.clearInterval(intervalId);
};
}, [currentHostConfig?.id, isVisible]);
}, [currentHostConfig?.id, isVisible, metricsEnabled]);
const topMarginPx = isTopbarOpen ? 74 : 16;
const leftMarginPx = sidebarState === "collapsed" ? 16 : 8;
@@ -297,12 +327,14 @@ export function Server({
{currentHostConfig?.folder} / {title}
</h1>
</div>
<Status
status={serverStatus}
className="!bg-transparent !p-0.75 flex-shrink-0"
>
<StatusIndicator />
</Status>
{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
@@ -410,7 +442,7 @@ export function Server({
<Separator className="p-0.25 w-full" />
<div className="flex-1 overflow-y-auto min-h-0">
{showStatsUI && (
{metricsEnabled && showStatsUI && (
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4 max-h-[50vh] overflow-y-auto">
{isLoadingMetrics && !metrics ? (
<div className="flex items-center justify-center py-8">

View File

@@ -478,7 +478,7 @@ export function SnippetsSidebar({
{t("common.cancel")}
</Button>
<Button onClick={handleSubmit} className="flex-1">
{editingSnippet ? t("common.update") : t("common.create")}
{editingSnippet ? t("snippets.edit") : t("snippets.create")}
</Button>
</div>
</div>

View File

@@ -95,14 +95,17 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
const wasDisconnectedBySSH = useRef(false);
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const [visible, setVisible] = useState(false);
const [isReady, setIsReady] = useState(false);
const [isConnected, setIsConnected] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [isFitted, setIsFitted] = useState(false);
const [, setConnectionError] = useState<string | null>(null);
const [, setIsAuthenticated] = useState(false);
const [totpRequired, setTotpRequired] = useState(false);
const [totpPrompt, setTotpPrompt] = useState<string>("");
const [isPasswordPrompt, setIsPasswordPrompt] = useState(false);
const isVisibleRef = useRef<boolean>(false);
const isFittingRef = useRef(false);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const reconnectAttempts = useRef(0);
const maxReconnectAttempts = 3;
@@ -129,6 +132,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
return;
}
// Set flags IMMEDIATELY to prevent race conditions
activityLoggingRef.current = true;
activityLoggedRef.current = true;
@@ -136,6 +140,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
const hostName =
hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`;
await logActivity("terminal", hostConfig.id, hostName);
// Don't reset activityLoggedRef on success - we want to prevent future calls
} catch (err) {
console.warn("Failed to log terminal activity:", err);
// Reset on error so it can be retried
@@ -186,6 +191,32 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
}
}
function performFit() {
if (
!fitAddonRef.current ||
!terminal ||
!isVisibleRef.current ||
isFittingRef.current
) {
return;
}
isFittingRef.current = true;
requestAnimationFrame(() => {
try {
fitAddonRef.current?.fit();
if (terminal && terminal.cols > 0 && terminal.rows > 0) {
scheduleNotify(terminal.cols, terminal.rows);
}
hardRefresh();
setIsFitted(true);
} finally {
isFittingRef.current = false;
}
});
}
function handleTotpSubmit(code: string) {
if (webSocketRef.current && code) {
webSocketRef.current.send(
@@ -727,7 +758,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
minimumContrastRatio: config.minimumContrastRatio,
letterSpacing: config.letterSpacing,
lineHeight: config.lineHeight,
bellStyle: config.bellStyle as "none" | "sound",
bellStyle: config.bellStyle as "none" | "sound" | "visual" | "both",
theme: {
background: themeColors.background,
@@ -852,11 +883,9 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
const resizeObserver = new ResizeObserver(() => {
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
resizeTimeout.current = setTimeout(() => {
if (!isVisibleRef.current) return;
fitAddonRef.current?.fit();
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
hardRefresh();
}, 150);
if (!isVisibleRef.current || !isReady) return;
performFit();
}, 50); // Reduced from 150ms to 50ms for snappier response
});
resizeObserver.observe(xtermRef.current);
@@ -868,6 +897,9 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
shouldNotReconnectRef.current = true;
isReconnectingRef.current = false;
setIsConnecting(false);
setVisible(false);
setIsReady(false);
isFittingRef.current = false;
resizeObserver.disconnect();
element?.removeEventListener("contextmenu", handleContextMenu);
element?.removeEventListener("keydown", handleMacKeyboard, true);
@@ -899,11 +931,16 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
: Promise.resolve();
readyFonts.then(() => {
setTimeout(() => {
requestAnimationFrame(() => {
fitAddonRef.current?.fit();
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
if (terminal && terminal.cols > 0 && terminal.rows > 0) {
scheduleNotify(terminal.cols, terminal.rows);
}
hardRefresh();
setVisible(true);
setIsReady(true);
if (terminal && !splitScreen) {
terminal.focus();
}
@@ -921,46 +958,74 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
const rows = terminal.rows;
connectToHost(cols, rows);
}, 200);
});
});
}, [terminal, hostConfig, visible, isConnected, isConnecting, splitScreen]);
useEffect(() => {
if (isVisible && fitAddonRef.current) {
setTimeout(() => {
fitAddonRef.current?.fit();
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
hardRefresh();
if (terminal && !splitScreen) {
terminal.focus();
}
}, 0);
if (terminal && !splitScreen) {
setTimeout(() => {
terminal.focus();
}, 100);
if (!isVisible || !isReady || !fitAddonRef.current || !terminal) {
// Reset fitted state when becoming invisible
if (!isVisible && isFitted) {
setIsFitted(false);
}
return;
}
}, [isVisible, splitScreen, terminal]);
// When becoming visible, we need to:
// 1. Mark as not fitted
// 2. Clear any rendering artifacts
// 3. Fit to the container size
// 4. Mark as fitted (happens in performFit)
setIsFitted(false);
// Use double requestAnimationFrame to ensure container has laid out
let rafId1: number;
let rafId2: number;
rafId1 = requestAnimationFrame(() => {
rafId2 = requestAnimationFrame(() => {
// Force a hard refresh to clear any artifacts
hardRefresh();
// Fit the terminal to the new size
performFit();
// Focus will happen after isFitted becomes true
});
});
return () => {
if (rafId1) cancelAnimationFrame(rafId1);
if (rafId2) cancelAnimationFrame(rafId2);
};
}, [isVisible, isReady, splitScreen, terminal]);
// Focus the terminal after it's been fitted and is visible
useEffect(() => {
if (!fitAddonRef.current) return;
setTimeout(() => {
fitAddonRef.current?.fit();
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
hardRefresh();
if (terminal && !splitScreen && isVisible) {
if (
isFitted &&
isVisible &&
isReady &&
!isConnecting &&
terminal &&
!splitScreen
) {
// Use requestAnimationFrame to ensure the terminal is actually visible in the DOM
const rafId = requestAnimationFrame(() => {
terminal.focus();
}
}, 0);
}, [splitScreen, isVisible, terminal]);
});
return () => cancelAnimationFrame(rafId);
}
}, [isFitted, isVisible, isReady, isConnecting, terminal, splitScreen]);
return (
<div className="h-full w-full relative" style={{ backgroundColor }}>
<div
ref={xtermRef}
className={`h-full w-full transition-opacity duration-200 ${visible && isVisible && !isConnecting ? "opacity-100" : "opacity-0"}`}
className="h-full w-full"
style={{
visibility:
isReady && !isConnecting && isFitted ? "visible" : "hidden",
opacity: isReady && !isConnecting && isFitted ? 1 : 0,
}}
onClick={() => {
if (terminal && !splitScreen) {
terminal.focus();

View File

@@ -165,6 +165,7 @@ function AppContent() {
onSelectView={handleSelectView}
isTopbarOpen={isTopbarOpen}
initialTab={currentTabData?.initialTab}
hostConfig={currentTabData?.hostConfig}
/>
</div>
)}

View File

@@ -1,8 +1,14 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useState, useMemo } from "react";
import { Status, StatusIndicator } from "@/components/ui/shadcn-io/status";
import { Button } from "@/components/ui/button";
import { ButtonGroup } from "@/components/ui/button-group";
import { EllipsisVertical, Terminal } from "lucide-react";
import {
EllipsisVertical,
Terminal,
Server,
FolderOpen,
Pencil,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuTrigger,
@@ -12,9 +18,11 @@ import {
import { useTabs } from "@/ui/Desktop/Navigation/Tabs/TabContext";
import { getServerStatusById } from "@/ui/main-axios";
import type { HostProps } from "../../../../types";
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
export function Host({ host }: HostProps): React.ReactElement {
export function Host({ host: initialHost }: HostProps): React.ReactElement {
const { addTab } = useTabs();
const [host, setHost] = useState(initialHost);
const [serverStatus, setServerStatus] = useState<
"online" | "offline" | "degraded"
>("degraded");
@@ -25,7 +33,47 @@ export function Host({ host }: HostProps): React.ReactElement {
? host.name
: `${host.username}@${host.ip}:${host.port}`;
// Update host when prop changes
useEffect(() => {
setHost(initialHost);
}, [initialHost]);
// Listen for host changes to immediately update config
useEffect(() => {
const handleHostsChanged = async () => {
const { getSSHHosts } = await import("@/ui/main-axios.ts");
const hosts = await getSSHHosts();
const updatedHost = hosts.find((h) => h.id === host.id);
if (updatedHost) {
setHost(updatedHost);
}
};
window.addEventListener("ssh-hosts:changed", handleHostsChanged);
return () =>
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
}, [host.id]);
// Parse stats config for monitoring settings
const statsConfig = useMemo(() => {
try {
return host.statsConfig
? JSON.parse(host.statsConfig)
: DEFAULT_STATS_CONFIG;
} catch {
return DEFAULT_STATS_CONFIG;
}
}, [host.statsConfig]);
const shouldShowStatus = statsConfig.statusCheckEnabled !== false;
useEffect(() => {
// Don't poll if status monitoring is disabled
if (!shouldShowStatus) {
setServerStatus("offline");
return;
}
let cancelled = false;
const fetchStatus = async () => {
@@ -41,6 +89,9 @@ export function Host({ host }: HostProps): React.ReactElement {
setServerStatus("offline");
} else if (err?.response?.status === 504) {
setServerStatus("degraded");
} else if (err?.response?.status === 404) {
// Status not available - monitoring disabled
setServerStatus("offline");
} else {
setServerStatus("offline");
}
@@ -49,13 +100,13 @@ export function Host({ host }: HostProps): React.ReactElement {
};
fetchStatus();
const intervalId = window.setInterval(fetchStatus, 30000);
const intervalId = window.setInterval(fetchStatus, 10000); // Poll backend every 10 seconds
return () => {
cancelled = true;
if (intervalId) window.clearInterval(intervalId);
};
}, [host.id]);
}, [host.id, shouldShowStatus]);
const handleTerminalClick = () => {
addTab({ type: "terminal", title, hostConfig: host });
@@ -64,12 +115,14 @@ export function Host({ host }: HostProps): React.ReactElement {
return (
<div>
<div className="flex items-center gap-2">
<Status
status={serverStatus}
className="!bg-transparent !p-0.75 flex-shrink-0"
>
<StatusIndicator />
</Status>
{shouldShowStatus && (
<Status
status={serverStatus}
className="!bg-transparent !p-0.75 flex-shrink-0"
>
<StatusIndicator />
</Status>
)}
<p className="font-semibold flex-1 min-w-0 break-words text-sm">
{host.name || host.ip}
@@ -101,29 +154,39 @@ export function Host({ host }: HostProps): React.ReactElement {
<DropdownMenuContent
align="start"
side="right"
className="min-w-[160px]"
className="w-56 bg-dark-bg border-dark-border text-white"
>
<DropdownMenuItem
className="font-semibold"
onClick={() =>
addTab({ type: "server", title, hostConfig: host })
}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
>
Open Server Details
<Server className="h-4 w-4" />
<span className="flex-1">Open Server Details</span>
</DropdownMenuItem>
<DropdownMenuItem
className="font-semibold"
onClick={() =>
addTab({ type: "file_manager", title, hostConfig: host })
}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
>
Open File Manager
<FolderOpen className="h-4 w-4" />
<span className="flex-1">Open File Manager</span>
</DropdownMenuItem>
<DropdownMenuItem
className="font-semibold"
onClick={() => alert("Settings clicked")}
onClick={() =>
addTab({
type: "ssh_manager",
title: "Host Manager",
hostConfig: host,
initialTab: "add_host",
})
}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
>
Edit
<Pencil className="h-4 w-4" />
<span className="flex-1">Edit</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -115,7 +115,7 @@ export function LeftSidebar({
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
const openSshManagerTab = () => {
if (sshManagerTab || isSplitScreenActive) return;
const id = addTab({ type: "ssh_manager" });
const id = addTab({ type: "ssh_manager", title: "Host Manager" });
setCurrentTab(id);
};
const adminTab = tabList.find((t) => t.type === "admin");

View File

@@ -31,6 +31,7 @@ interface TabContextType {
port: number;
},
) => void;
updateTab: (tabId: number, updates: Partial<Omit<Tab, "id">>) => void;
}
const TabContext = createContext<TabContextType | undefined>(undefined);
@@ -96,6 +97,35 @@ export function TabProvider({ children }: TabProviderProps) {
}
const addTab = (tabData: Omit<Tab, "id">): number => {
// Check if an ssh_manager tab already exists
if (tabData.type === "ssh_manager") {
const existingTab = tabs.find((t) => t.type === "ssh_manager");
if (existingTab) {
// Update the existing tab with new data
// Create a new object reference to force React to detect the change
setTabs((prev) =>
prev.map((t) =>
t.id === existingTab.id
? {
...t,
hostConfig: tabData.hostConfig
? { ...tabData.hostConfig }
: undefined,
initialTab: tabData.initialTab,
// Add a timestamp to force re-render
_updateTimestamp: Date.now(),
}
: t,
),
);
setCurrentTab(existingTab.id);
setAllSplitScreenTab((prev) =>
prev.filter((tid) => tid !== existingTab.id),
);
return existingTab.id;
}
}
const id = nextTabId.current++;
const needsUniqueTitle =
tabData.type === "terminal" ||
@@ -203,6 +233,12 @@ export function TabProvider({ children }: TabProviderProps) {
);
};
const updateTab = (tabId: number, updates: Partial<Omit<Tab, "id">>) => {
setTabs((prev) =>
prev.map((tab) => (tab.id === tabId ? { ...tab, ...updates } : tab)),
);
};
const value: TabContextType = {
tabs,
currentTab,
@@ -214,6 +250,7 @@ export function TabProvider({ children }: TabProviderProps) {
getTab,
reorderTabs,
updateHostConfig,
updateTab,
};
return <TabContext.Provider value={value}>{children}</TabContext.Provider>;

View File

@@ -13,6 +13,11 @@ const languages = [
{ code: "en", name: "English", nativeName: "English" },
{ code: "zh", name: "Chinese", nativeName: "中文" },
{ code: "de", name: "German", nativeName: "Deutsch" },
{
code: "ptbr",
name: "Brazilian Portuguese",
nativeName: "Português Brasileiro",
},
];
export function LanguageSwitcher() {

View File

@@ -161,6 +161,14 @@ export function PasswordReset({ userInfo }: PasswordResetProps) {
<>
{resetStep === "initiate" && (
<>
<Alert variant="destructive" className="mb-4">
<AlertTitle>Warning: Data Loss</AlertTitle>
<AlertDescription>
Resetting your password will delete all your saved SSH hosts,
credentials, and encrypted data. This action cannot be undone.
Only use this if you have forgotten your password.
</AlertDescription>
</Alert>
<div className="flex flex-col gap-4">
<Button
type="button"