fix: Squash commit of several fixes and features for many different elements
This commit is contained in:
@@ -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: "",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -165,6 +165,7 @@ function AppContent() {
|
||||
onSelectView={handleSelectView}
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
initialTab={currentTabData?.initialTab}
|
||||
hostConfig={currentTabData?.hostConfig}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user