From ff399ec0bc8e8fbb039484097b5420f3cc06c36c Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 27 May 2026 14:10:19 -0500 Subject: [PATCH] feat: update c2s tunnel to match new UI --- src/ui/user/C2STunnelPresetManager.tsx | 1083 +++++++++++++----------- 1 file changed, 594 insertions(+), 489 deletions(-) diff --git a/src/ui/user/C2STunnelPresetManager.tsx b/src/ui/user/C2STunnelPresetManager.tsx index 82431cd9..e5dd90fd 100644 --- a/src/ui/user/C2STunnelPresetManager.tsx +++ b/src/ui/user/C2STunnelPresetManager.tsx @@ -1,17 +1,7 @@ import React from "react"; import { Button } from "@/components/button.tsx"; import { Input } from "@/components/input.tsx"; -import { Label } from "@/components/label.tsx"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/select.tsx"; -import { Switch } from "@/components/switch.tsx"; -import { TunnelInlineControls } from "@/features/tunnel/TunnelInlineControls.tsx"; -import { TunnelModeSelector } from "@/features/tunnel/TunnelModeSelector.tsx"; +import { FakeSwitch } from "@/components/section-card.tsx"; import { getTunnelModeDescription, getTunnelPortLabels, @@ -28,9 +18,21 @@ import type { C2STunnelPreset, SSHHost, TunnelConnection, + TunnelMode, TunnelStatus, } from "@/types/index.js"; -import { Activity, Download, Pencil, Plus, Save, Trash2 } from "lucide-react"; +import { + Activity, + ChevronDown, + Download, + Loader2, + Pencil, + Play, + Plus, + Save, + Square, + Trash2, +} from "lucide-react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; @@ -134,6 +136,44 @@ function stripClientTunnelDiagnostics(tunnel: ClientTunnel): TunnelConnection { return presetTunnel; } +function getStatusKind(status?: TunnelStatus) { + const value = status?.status?.toUpperCase() || "DISCONNECTED"; + if (value === "CONNECTED") return "connected"; + if (value === "ERROR" || value === "FAILED") return "error"; + if ( + value === "CONNECTING" || + value === "DISCONNECTING" || + value === "RETRYING" || + value === "WAITING" + ) { + return "connecting"; + } + return "disconnected"; +} + +function getStatusTitle( + status: TunnelStatus | undefined, + statusText: string, + t: ReturnType["t"], +) { + if (!status) return statusText; + const details = []; + if (status.reason) details.push(status.reason); + if (status.retryCount && status.maxRetries) { + details.push( + t("tunnels.attempt", { + current: status.retryCount, + max: status.maxRetries, + }), + ); + } + if (status.nextRetryIn) { + details.push(t("tunnels.nextRetryIn", { seconds: status.nextRetryIn })); + } + if (status.errorType && !status.reason) details.push(status.errorType); + return details.length > 0 ? details.join("\n") : statusText; +} + export function C2STunnelPresetManager(): React.ReactElement { const { t } = useTranslation(); const [localConfig, setLocalConfig] = React.useState([]); @@ -156,6 +196,7 @@ export function C2STunnelPresetManager(): React.ReactElement { ); const [selectedPresetId, setSelectedPresetId] = React.useState(""); const [presetName, setPresetName] = React.useState(""); + const [openTunnels, setOpenTunnels] = React.useState>(new Set()); const isElectron = typeof window !== "undefined" && window.electronAPI?.isElectron === true; @@ -186,6 +227,14 @@ export function C2STunnelPresetManager(): React.ReactElement { ); const hasPresets = presets.length > 0; + function toggleTunnel(index: number) { + setOpenTunnels((current) => { + const next = new Set(current); + next.has(index) ? next.delete(index) : next.add(index); + return next; + }); + } + const getTunnelName = React.useCallback( (tunnel: ClientTunnel, index: number) => [ @@ -216,7 +265,6 @@ export function C2STunnelPresetManager(): React.ReactElement { const getTunnelDisplayName = React.useCallback( (tunnel: ClientTunnel, index: number) => { if (tunnel.displayName?.trim()) return tunnel.displayName.trim(); - const mode = getTunnelMode(tunnel); const endpointName = getEndpointName(tunnel); if (mode === "remote") { @@ -256,7 +304,6 @@ export function C2STunnelPresetManager(): React.ReactElement { const mode = getTunnelMode(tunnel); const bindHost = getEffectiveBindHost(tunnel.bindHost); const endpointName = getEndpointName(tunnel); - if (mode === "remote") { return t("tunnels.summaryClientRemote", { endpoint: endpointName, @@ -289,7 +336,6 @@ export function C2STunnelPresetManager(): React.ReactElement { const refreshLocalConfig = React.useCallback(async () => { if (!isElectron) return; - const [config, defaultName, nextHosts] = await Promise.all([ window.electronAPI.getC2STunnelConfig(), window.electronAPI.getC2STunnelPresetDefaultName(), @@ -308,7 +354,6 @@ export function C2STunnelPresetManager(): React.ReactElement { React.useEffect(() => { if (!isElectron) return; - Promise.all([refreshLocalConfig(), refreshPresets()]).catch(() => { setPresets([]); }); @@ -316,37 +361,26 @@ export function C2STunnelPresetManager(): React.ReactElement { React.useEffect(() => { if (!isElectron) return; - const refreshStatuses = async () => { const statuses = await window.electronAPI.getC2STunnelStatuses(); setTunnelStatuses(statuses as Record); }; - refreshStatuses().catch(() => {}); const unsubscribe = window.electronAPI.onC2STunnelStatuses?.((statuses) => { setTunnelStatuses(statuses as Record); }); - return () => unsubscribe?.(); }, [isElectron]); React.useEffect(() => { const previousStatuses = previousTunnelStatusesRef.current; - for (const [tunnelName, status] of Object.entries(tunnelStatuses)) { const previous = previousStatuses[tunnelName]; const statusChanged = previous?.status !== status.status || previous?.reason !== status.reason || previous?.retryCount !== status.retryCount; - if (!statusChanged) continue; - - console.info("[tunnels] Client tunnel status changed", { - tunnelName, - status, - }); - const statusValue = status.status?.toUpperCase(); const hasFailureDetail = statusValue === "ERROR" || @@ -354,29 +388,19 @@ export function C2STunnelPresetManager(): React.ReactElement { (Boolean(status.errorType) && Boolean(status.reason) && previous?.reason !== status.reason); - if (hasFailureDetail) { const message = status.reason || t("tunnels.manualControlError"); - console.error("[tunnels] Client tunnel failed", { - tunnelName, - status, - }); - toast.error(message, { - id: `client-tunnel-error-${tunnelName}`, - }); + toast.error(message, { id: `client-tunnel-error-${tunnelName}` }); } } - previousTunnelStatusesRef.current = tunnelStatuses; }, [t, tunnelStatuses]); const validateLocalConfig = (config: ClientTunnel[]) => { const autoStartListeners = new Set(); - for (const tunnel of config) { const bindHost = getEffectiveBindHost(tunnel.bindHost); const mode = getTunnelMode(tunnel); - if (!isValidIPv4(bindHost)) { return mode === "remote" ? t("tunnels.invalidLocalTargetIp") @@ -411,22 +435,17 @@ export function C2STunnelPresetManager(): React.ReactElement { autoStartListeners.add(listenerKey); } } - return null; }; const saveLocalConfig = async (config: ClientTunnel[]) => { const normalizedConfig = config.map(normalizeClientTunnel); const validationError = validateLocalConfig(normalizedConfig); - if (validationError) { - throw new Error(validationError); - } - + if (validationError) throw new Error(validationError); const result = await window.electronAPI.saveC2STunnelConfig(normalizedConfig); - if (!result.success) { + if (!result.success) throw new Error(result.error || t("tunnels.localSaveError")); - } setLocalConfig(normalizedConfig); setSavedLocalConfig(normalizedConfig); }; @@ -447,7 +466,6 @@ export function C2STunnelPresetManager(): React.ReactElement { const handleEndpointChange = (index: number, hostId: string) => { const host = sshHosts.find((item) => String(item.id) === hostId); if (!host) return; - updateTunnel(index, { sourceHostId: host.id, sourceHostName: host.name, @@ -492,22 +510,14 @@ export function C2STunnelPresetManager(): React.ReactElement { setTunnelMetadata(index, { lastError: validationError }); return; } - setTunnelTests((current) => ({ ...current, [tunnelName]: true })); - console.info("[tunnels] Testing client tunnel", { - tunnelName, - tunnel: normalizedTunnel, - }); - try { const result = await window.electronAPI.testC2STunnel( normalizedTunnel, index, ); - if (!result.success) { + if (!result.success) throw new Error(result.error || t("tunnels.tunnelTestFailed")); - } - setTunnelMetadata(index, { lastTestedAt: new Date().toISOString(), lastError: "", @@ -516,10 +526,6 @@ export function C2STunnelPresetManager(): React.ReactElement { } catch (error) { const message = error instanceof Error ? error.message : t("tunnels.tunnelTestFailed"); - console.error("[tunnels] Client tunnel test failed", { - tunnelName, - error, - }); setTunnelMetadata(index, { lastError: message }); toast.error(message); } finally { @@ -539,21 +545,14 @@ export function C2STunnelPresetManager(): React.ReactElement { setTunnelMetadata(index, { lastError: validationError }); return; } - setTunnelActions((current) => ({ ...current, [tunnelName]: true })); - console.info("[tunnels] Starting client tunnel", { - tunnelName, - tunnel: normalizedTunnel, - }); - try { const result = await window.electronAPI.startC2STunnel( normalizedTunnel, index, ); - if (!result.success) { + if (!result.success) throw new Error(result.error || t("tunnels.manualControlError")); - } const statuses = await window.electronAPI.getC2STunnelStatuses(); setTunnelStatuses(statuses as Record); setTunnelMetadata(index, { @@ -566,10 +565,6 @@ export function C2STunnelPresetManager(): React.ReactElement { error instanceof Error ? error.message : t("tunnels.manualControlError"); - console.error("[tunnels] Failed to start client tunnel", { - tunnelName, - error, - }); setTunnelMetadata(index, { lastError: message }); toast.error(message); } finally { @@ -580,12 +575,10 @@ export function C2STunnelPresetManager(): React.ReactElement { const handleTunnelStop = async (tunnel: ClientTunnel, index: number) => { const tunnelName = getTunnelName(tunnel, index); setTunnelActions((current) => ({ ...current, [tunnelName]: true })); - try { const result = await window.electronAPI.stopC2STunnel(tunnelName); - if (!result.success) { + if (!result.success) throw new Error(result.error || t("tunnels.manualControlError")); - } const statuses = await window.electronAPI.getC2STunnelStatuses(); setTunnelStatuses(statuses as Record); toast.success(t("tunnels.clientTunnelStopped")); @@ -602,7 +595,6 @@ export function C2STunnelPresetManager(): React.ReactElement { const handleSavePreset = async () => { if (!presetName.trim()) return; - try { await saveLocalConfig(localConfig); await createC2STunnelPreset({ @@ -620,7 +612,6 @@ export function C2STunnelPresetManager(): React.ReactElement { const handleLoadPreset = async () => { if (!selectedPreset || selectedMatchesCurrent) return; - try { await saveLocalConfig(selectedPreset.config.map(normalizeClientTunnel)); toast.success(t("profile.c2sPresetLoaded")); @@ -635,7 +626,6 @@ export function C2STunnelPresetManager(): React.ReactElement { const handleRenamePreset = async () => { if (!selectedPreset || !presetName.trim()) return; - try { await updateC2STunnelPreset(selectedPreset.id, { name: presetName.trim(), @@ -649,7 +639,6 @@ export function C2STunnelPresetManager(): React.ReactElement { const handleDeletePreset = async () => { if (!selectedPreset) return; - try { await deleteC2STunnelPreset(selectedPreset.id); setSelectedPresetId(""); @@ -662,11 +651,8 @@ export function C2STunnelPresetManager(): React.ReactElement { if (!isElectron) { return ( -
-

- {t("profile.c2sTunnelPresets")} -

-

+

+

{t("profile.c2sTunnelPresetsUnavailable")}

@@ -674,419 +660,538 @@ export function C2STunnelPresetManager(): React.ReactElement { } return ( -
-
-
-
-

- {t("tunnels.clientTunnels")} -

-

- {t("profile.c2sTunnelConfigDesc")} -

-
-
- - {hasUnsavedLocalChanges && ( - - {t("common.unsavedChanges")} - - )} - -
-
- -
- {localConfig.length > 0 ? ( - localConfig.map((tunnel, index) => { - const mode = getTunnelMode(tunnel); - const modeDescription = getTunnelModeDescription( - "client", - mode, - { - sourcePort: tunnel.sourcePort, - endpointPort: tunnel.endpointPort, - }, - t, - ); - const tunnelName = getTunnelName(tunnel, index); - const tunnelStatus = tunnelStatuses[tunnelName]; - const isTunnelActionLoading = Boolean(tunnelActions[tunnelName]); - const isTunnelTestLoading = Boolean(tunnelTests[tunnelName]); - const startDisabled = !tunnel.sourceHostId; - const startDisabledReason = t("tunnels.endpointSshHostRequired"); - const { sourcePortLabel, endpointPortLabel } = - getTunnelPortLabels("client", mode, t); - const tunnelSummary = getTunnelSummary(tunnel); - const statusError = - tunnelStatus?.reason || - (tunnelStatus?.errorType ? String(tunnelStatus.errorType) : ""); - const lastError = statusError || tunnel.lastError || ""; - const lastStarted = formatDateTime(tunnel.lastStartedAt); - const lastTested = formatDateTime(tunnel.lastTestedAt); - - return ( -
-
-
- - - updateTunnel(index, { - displayName: event.target.value, - }) - } - placeholder={getTunnelDisplayName(tunnel, index)} - className="h-8 max-w-md" - /> -
-
- - handleTunnelStart(tunnel, index)} - onStop={() => handleTunnelStop(tunnel, index)} - startDisabled={startDisabled} - startDisabledReason={startDisabledReason} - /> - -
-
- -
-
- -
- - updateTunnel(index, { - mode, - tunnelType: getTunnelTypeForMode(mode), - }) - } - /> -
-
-
- -
-
- - -
- - {tunnel.mode !== "dynamic" && ( -
- - - updateTunnel(index, { - endpointPort: Number(event.target.value), - }) - } - placeholder={t("placeholders.defaultEndpointPort")} - /> -
- )} - {tunnel.mode === "dynamic" && ( -
- )} - -
- - - updateTunnel(index, { - bindHost: event.target.value.trim(), - }) - } - placeholder={getBindPlaceholder(mode)} - /> -
- -
- - - updateTunnel(index, { - sourcePort: Number(event.target.value), - }) - } - placeholder={t("placeholders.defaultPort")} - /> -
-
- -

- {modeDescription} -

-
- - {t("tunnels.route")} - {" "} - {tunnelSummary} -
- {mode === "remote" && ( -

- {t("tunnels.clientRemoteServerNote")} -

- )} - {(lastError || lastStarted || lastTested) && ( -
- {lastStarted && ( - - {t("tunnels.lastStarted")}: {lastStarted} - - )} - {lastTested && ( - - {t("tunnels.lastTested")}: {lastTested} - - )} - {lastError && ( - - {t("tunnels.lastError")}: {lastError} - - )} -
- )} - -
-
- - - updateTunnel(index, { - maxRetries: Number(event.target.value), - }) - } - placeholder={t("placeholders.maxRetries")} - /> -

- {t("tunnels.maxRetriesDescription")} -

-
- -
- - - updateTunnel(index, { - retryInterval: Number(event.target.value), - }) - } - placeholder={t("placeholders.retryInterval")} - /> -

- {t("tunnels.retryIntervalDescription")} -

-
- -
-
- - - updateTunnel(index, { autoStart: checked }) - } - /> -
-

- {t( - tunnel.autoStart - ? "tunnels.clientAutoStartDesc" - : "tunnels.clientManualStartDesc", - )} -

-
-
-
- ); - }) - ) : ( -

- {t("tunnels.noClientTunnels")} -

+
+ {/* Tunnel list header */} +
+ + {t("tunnels.clientTunnels")} + +
+ {hasUnsavedLocalChanges && ( + + {t("common.unsavedChanges")} + )} +
-
-
-

- {t("profile.c2sTunnelPresets")} -

-

- {t("profile.c2sTunnelPresetsDesc")} -

+ {/* Tunnel items */} +
+ {localConfig.length === 0 ? ( +
+ {t("tunnels.noClientTunnels")} +
+ ) : ( + localConfig.map((tunnel, index) => { + const mode = getTunnelMode(tunnel); + const tunnelName = getTunnelName(tunnel, index); + const tunnelStatus = tunnelStatuses[tunnelName]; + const isTunnelActionLoading = Boolean(tunnelActions[tunnelName]); + const isTunnelTestLoading = Boolean(tunnelTests[tunnelName]); + const startDisabled = !tunnel.sourceHostId; + const { sourcePortLabel, endpointPortLabel } = getTunnelPortLabels( + "client", + mode, + t, + ); + const tunnelSummary = getTunnelSummary(tunnel); + const modeDescription = getTunnelModeDescription( + "client", + mode, + { + sourcePort: tunnel.sourcePort, + endpointPort: tunnel.endpointPort, + }, + t, + ); + const statusError = + tunnelStatus?.reason || + (tunnelStatus?.errorType ? String(tunnelStatus.errorType) : ""); + const lastError = statusError || tunnel.lastError || ""; + const lastStarted = formatDateTime(tunnel.lastStartedAt); + const lastTested = formatDateTime(tunnel.lastTestedAt); + const isOpen = openTunnels.has(index); + + const kind = getStatusKind(tunnelStatus); + const isDisconnected = kind === "disconnected"; + const statusText = + kind === "connected" + ? t("tunnels.connected") + : kind === "connecting" + ? t("tunnels.connecting") + : kind === "error" + ? t("tunnels.error") + : t("tunnels.disconnected"); + const statusTitle = getStatusTitle(tunnelStatus, statusText, t); + const statusClass = + kind === "connected" + ? "text-accent-brand border-accent-brand/40 bg-accent-brand/10" + : kind === "connecting" + ? "text-blue-400 border-blue-400/40 bg-blue-400/10" + : kind === "error" + ? "text-destructive border-destructive/40 bg-destructive/10" + : "text-muted-foreground border-border bg-muted/30"; + + return ( +
+ {/* Always-visible header */} + + + {/* Expanded body */} + {isOpen && ( +
+ {/* Action buttons */} +
+ + + {isTunnelActionLoading ? ( + + ) : isDisconnected ? ( + + ) : ( + + )} + + +
+ + {/* Display name */} +
+ + + updateTunnel(index, { displayName: e.target.value }) + } + placeholder={getTunnelDisplayName(tunnel, index)} + className="h-7 text-xs bg-muted/50 border-border rounded-none" + /> +
+ + {/* Mode */} +
+ + + + {modeDescription} + +
+ + {/* SSH host */} +
+ + +
+ + {/* Ports */} +
+
+ + + updateTunnel(index, { + sourcePort: Number(e.target.value), + }) + } + placeholder={t("placeholders.defaultPort")} + className="h-7 text-xs bg-muted/50 border-border rounded-none" + /> +
+ {mode !== "dynamic" && ( +
+ + + updateTunnel(index, { + endpointPort: Number(e.target.value), + }) + } + placeholder={t("placeholders.defaultEndpointPort")} + className="h-7 text-xs bg-muted/50 border-border rounded-none" + /> +
+ )} +
+ + {/* Bind IP */} +
+ + + updateTunnel(index, { + bindHost: e.target.value.trim(), + }) + } + placeholder={getBindPlaceholder(mode)} + className="h-7 text-xs bg-muted/50 border-border rounded-none" + /> +
+ + {/* Route summary */} +
+ + {t("tunnels.route")} + {" "} + {tunnelSummary} +
+ + {mode === "remote" && ( +

+ {t("tunnels.clientRemoteServerNote")} +

+ )} + + {/* Last activity */} + {(lastError || lastStarted || lastTested) && ( +
+ {lastStarted && ( + + {t("tunnels.lastStarted")}: {lastStarted} + + )} + {lastTested && ( + + {t("tunnels.lastTested")}: {lastTested} + + )} + {lastError && ( + + {t("tunnels.lastError")}: {lastError} + + )} +
+ )} + + {/* Retries */} +
+
+ + + updateTunnel(index, { + maxRetries: Number(e.target.value), + }) + } + placeholder={t("placeholders.maxRetries")} + className="h-7 text-xs bg-muted/50 border-border rounded-none" + /> +
+
+ + + updateTunnel(index, { + retryInterval: Number(e.target.value), + }) + } + placeholder={t("placeholders.retryInterval")} + className="h-7 text-xs bg-muted/50 border-border rounded-none" + /> +
+
+ + {/* Auto-start */} +
+
+ + {t("tunnels.autoStart")} + + + {t( + tunnel.autoStart + ? "tunnels.clientAutoStartDesc" + : "tunnels.clientManualStartDesc", + )} + +
+ + updateTunnel(index, { autoStart: checked }) + } + /> +
+
+ )} +
+ ); + }) + )} +
+ + {/* Save local config button */} + + + {/* Presets section */} +
+ + {t("profile.c2sTunnelPresets")} + + +
+ +
+ setPresetName(e.target.value)} + placeholder={t("profile.c2sPresetNamePlaceholder")} + className="h-7 text-xs bg-muted/50 border-border rounded-none flex-1 min-w-0" + /> + +
+ + {t("profile.c2sCurrentLocalConfig", { count: localConfig.length })} +
-
-
- -
- setPresetName(event.target.value)} - placeholder={t("profile.c2sPresetNamePlaceholder")} - /> - -
-

- {t("profile.c2sCurrentLocalConfig", { - count: localConfig.length, - })} -

-
+
+ + + + {t("profile.c2sPresetSyncNote")} + +
-
- -
- - - - -
-

- {t("profile.c2sPresetSyncNote")} -

-
+
+ + +