import { Wifi, WifiOff, AlertCircle, Loader2, HardDrive, Clock, ChevronUp, Globe } from 'lucide-react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useTabStore, type ConnectionStatus } from '../../stores/tabStore' import { usePoolStore } from '../../stores/connection/poolStore' import type { ConnectionState } from '../../stores/connection' import { useConnectionPanelStore } from '../../stores/connectionPanelStore' import { useSessionStore, useEntitlementStore } from '../../stores' import type { HostMetrics } from '../../../shared/types/host-metrics.types' import { formatLocaleTime, translateErrorMessage, translateIpcError } from '../../i18n/format' import { useToast } from '../../hooks/useToast' import ForwardRuleDrawer, { emptyForwardDraft } from '../connection/ForwardRuleDrawer' import { DEFAULT_DYNAMIC_PORT, parseForwardRules, portForwardStorageKey, resolveDynamicPort, resolveForwardTargetHost, resolveLocalBindHost, resolveRemoteBindHost, type ForwardRule, } from '../connection/forwardRules' import { startNativeForward, stopNativeForward } from '../connection/forwardRuntime' import { forgetForwardIntent, forwardIntentKey, rememberForwardIntent, restoreForwardIntent, tunnelCoversRule, } from '../connection/forwardIntent' import type { SSHForwardStatus } from '../../../shared/types/ipc.types' // ============== Status Display Config ============== interface StatusDisplay { icon: React.ReactNode labelKey: string colorClass: string animateClass: string } const STATUS_CONFIG: Record & { labelKey: string }> = { connected: { icon: , labelKey: 'status.connected', colorClass: 'text-feedback-success', animateClass: '', }, connecting: { icon: , labelKey: 'status.connecting', colorClass: 'text-feedback-warning', animateClass: 'animate-pulse', }, background: { icon: , labelKey: 'status.background', colorClass: 'text-feedback-success', animateClass: '', }, suspended: { icon: , labelKey: 'status.suspended', colorClass: 'text-feedback-warning', animateClass: '', }, reconnecting: { icon: , labelKey: 'status.reconnecting', colorClass: 'text-feedback-warning', animateClass: 'animate-pulse', }, error: { icon: , labelKey: 'status.error', colorClass: 'text-feedback-error', animateClass: '', }, disconnected: { icon: , labelKey: 'status.disconnected', colorClass: 'text-txt-muted', animateClass: '', }, } const METRICS_INTERVAL_MS = 8000 const WARN_PCT = 85 function formatBytes(bytes: number): string { if (bytes === 0) return '0 B' const units = ['B', 'KB', 'MB', 'GB'] const k = 1024 const i = Math.floor(Math.log(bytes) / Math.log(k)) const value = bytes / Math.pow(k, i) return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}` } function shortMount(mount: string): string { if (mount === '/') return '/' const parts = mount.split('/').filter(Boolean) return parts.length ? `/${parts[parts.length - 1]}` : mount } /** +0800 → UTC+8;+0000 → UTC+0;+0530 → UTC+5:30 */ function formatUtcOffset(offset?: string): string | null { if (!offset || offset === '-') return null const normalized = offset === 'Z' || offset === 'z' ? '+0000' : offset const match = normalized.match(/^([+-])(\d{2})(\d{2})$/) if (!match) return null const sign = match[1] === '-' ? '-' : '+' const hours = Number(match[2]) const minutes = Number(match[3]) if (Number.isNaN(hours) || Number.isNaN(minutes)) return null if (minutes === 0) return `UTC${sign}${hours}` return `UTC${sign}${hours}:${String(minutes).padStart(2, '0')}` } function timezoneLabel(metrics: HostMetrics): string | null { const utc = formatUtcOffset(metrics.timezoneOffset) if (utc) return utc if (metrics.timezone && metrics.timezone !== '-') return metrics.timezone return null } function timezoneTooltip(metrics: HostMetrics): string | null { const utc = formatUtcOffset(metrics.timezoneOffset) const iana = metrics.timezone && metrics.timezone !== '-' ? metrics.timezone : null if (utc && iana && iana !== utc) return `${utc} · ${iana}` return utc ?? iana } function formatCpuPercent(pct: number): string { return `${pct.toFixed(1)}%` } function pctClass(pct?: number): string { if (pct == null) return 'text-txt-faint' if (pct >= WARN_PCT) return 'text-feedback-warning' return 'text-txt-muted' } function mapTabStatusToConnectionState(status?: ConnectionStatus): ConnectionState | undefined { if (!status) return undefined return status as ConnectionState } function useRemoteHostMetrics(connectionId: string | undefined, enabled: boolean) { const [metrics, setMetrics] = useState(null) const [stale, setStale] = useState(false) const cacheRef = useRef>(new Map()) const inFlightRef = useRef(false) const poll = useCallback(async (id: string) => { if (inFlightRef.current || document.hidden) return if (!window.opsxApi?.sshHostMetrics) return inFlightRef.current = true try { const result = await window.opsxApi.sshHostMetrics(id) if (result.skipped) { setStale(true) return } if (result.success && result.metrics) { cacheRef.current.set(id, result.metrics) setMetrics(result.metrics) setStale(false) } } catch { setStale(true) } finally { inFlightRef.current = false } }, []) useEffect(() => { if (!enabled || !connectionId) { setMetrics(null) setStale(false) return } const cached = cacheRef.current.get(connectionId) if (cached) { setMetrics(cached) setStale(true) } else { setMetrics(null) setStale(false) } void poll(connectionId) const timer = window.setInterval(() => void poll(connectionId), METRICS_INTERVAL_MS) const onVisibility = () => { if (!document.hidden) void poll(connectionId) } document.addEventListener('visibilitychange', onVisibility) return () => { window.clearInterval(timer) document.removeEventListener('visibilitychange', onVisibility) } }, [connectionId, enabled, poll]) return { metrics, stale } } const DISK_SLOT_THRESHOLDS = [ { slots: 5, min: 360 }, { slots: 4, min: 290 }, { slots: 3, min: 220 }, { slots: 2, min: 150 }, { slots: 1, min: 72 }, { slots: 0, min: 0 }, ] as const /** Deadband so showing/hiding disks cannot oscillate slot count. */ const DISK_SLOT_HYSTERESIS_PX = 36 function diskSlotsForSpare(spare: number, current: number, withHysteresis: boolean): number { let next = 0 for (const row of DISK_SLOT_THRESHOLDS) { if (spare >= row.min) { next = row.slots break } } if (!withHysteresis || next === current) return next if (next > current) { const need = DISK_SLOT_THRESHOLDS.find((row) => row.slots === next)?.min ?? 0 return spare >= need + DISK_SLOT_HYSTERESIS_PX ? next : current } const keep = DISK_SLOT_THRESHOLDS.find((row) => row.slots === current)?.min ?? 0 return spare < keep - DISK_SLOT_HYSTERESIS_PX ? next : current } function RemoteHostMetricsStrip({ metrics, stale, fallbackOs, transferLabel, }: { metrics: HostMetrics | null stale: boolean fallbackOs?: string | null transferLabel?: string | null }) { const { t } = useTranslation('common') const stripRef = useRef(null) const disksRef = useRef(null) const measuredRef = useRef(false) const [diskSlots, setDiskSlots] = useState(0) const osLabel = (metrics?.osPrettyName || fallbackOs || '').trim() || null const tz = metrics ? timezoneLabel(metrics) : null const tzTip = metrics ? timezoneTooltip(metrics) : null const rankedDisks = useMemo( () => (metrics?.disks ?? []) .slice() .sort((a, b) => b.percent - a.percent || a.mount.localeCompare(b.mount)) .slice(0, 5), [metrics?.disks] ) const visibleDisks = rankedDisks.slice(0, diskSlots) const memDetail = metrics?.memUsedKb != null && metrics?.memTotalKb != null ? ` (${formatBytes(metrics.memUsedKb * 1024)}/${formatBytes(metrics.memTotalKb * 1024)})` : '' const tipParts = [ osLabel, tzTip ? t('metrics.timezone', { value: tzTip }) : null, metrics?.cpuPercent != null ? `CPU ${formatCpuPercent(metrics.cpuPercent)}` : null, metrics?.memPercent != null ? t('metrics.memory', { percent: metrics.memPercent, detail: memDetail }) : null, transferLabel ? t('metrics.sessionTransfer', { value: transferLabel.replace(/^↑↓\s*/, '') }) : null, ...rankedDisks.map( (d) => `${d.mount} ${d.percent}% (${formatBytes(d.usedKb * 1024)}/${formatBytes(d.totalKb * 1024)})` ), ].filter(Boolean) useEffect(() => { const el = stripRef.current const parent = el?.parentElement if (!el || !parent) return const updateSlots = () => { const spacer = parent.querySelector('[data-statusbar-spacer]') const disksEl = disksRef.current // spacer shrinks when disks appear — add disks width back so capacity is stable. const spare = (spacer?.clientWidth ?? 0) + (disksEl?.offsetWidth ?? 0) const useHysteresis = measuredRef.current measuredRef.current = true setDiskSlots((prev) => diskSlotsForSpare(spare, prev, useHysteresis)) } updateSlots() const observer = new ResizeObserver(updateSlots) observer.observe(parent) const spacer = parent.querySelector('[data-statusbar-spacer]') if (spacer) observer.observe(spacer) return () => observer.disconnect() }, []) // Disk chips mount after first metrics poll; re-check once data arrives. useEffect(() => { if (!rankedDisks.length) return const parent = stripRef.current?.parentElement if (!parent) return const spacer = parent.querySelector('[data-statusbar-spacer]') const spare = (spacer?.clientWidth ?? 0) + (disksRef.current?.offsetWidth ?? 0) setDiskSlots((prev) => diskSlotsForSpare(spare, prev, measuredRef.current)) measuredRef.current = true }, [rankedDisks.length]) const hasAny = osLabel || tz || metrics?.cpuPercent != null || metrics?.memPercent != null || transferLabel || rankedDisks.length > 0 if (!hasAny) return null return (
{osLabel && ( {osLabel} )} {tz && {tz}} {metrics?.cpuPercent != null && ( CPU {formatCpuPercent(metrics.cpuPercent)} )} {metrics?.memPercent != null && ( MEM {metrics.memPercent}% )} {transferLabel && ( {transferLabel} )}
{visibleDisks.map((d) => ( {shortMount(d.mount)} {d.percent}% ))}
) } export default function StatusBar() { const { t } = useTranslation(['common', 'terminal']) const togglePanel = useConnectionPanelStore((s) => s.toggle) const requirePro = useEntitlementStore((s) => s.requirePro) const { toast } = useToast() const [currentTime, setCurrentTime] = useState(new Date()) const [socksRunning, setSocksRunning] = useState(false) const [forwardDrawerOpen, setForwardDrawerOpen] = useState(false) const [forwardDraft, setForwardDraft] = useState(emptyForwardDraft()) const [forwardDrawerError, setForwardDrawerError] = useState(null) const [forwardSaving, setForwardSaving] = useState(false) const [forwardRules, setForwardRules] = useState([]) const [forwardTunnels, setForwardTunnels] = useState([]) const activeTabId = useTabStore((s) => s.activeTabId) const tabs = useTabStore((s) => s.tabs) const connections = usePoolStore((s) => s.connections) const sessions = useSessionStore((s) => s.sessions) const totalConnections = connections.size const activeTab = useMemo(() => tabs.find((t) => t.id === activeTabId), [tabs, activeTabId]) const { connectionState, hostLabel, stats, connectionId, isRemoteSsh, sessionOsPretty } = useMemo(() => { if (!activeTab || activeTab.type === 'welcome' || activeTab.type === 'settings') { return { connectionState: undefined, hostLabel: undefined, stats: undefined, connectionId: undefined, isRemoteSsh: false, sessionOsPretty: undefined as string | undefined, } } if (activeTab.type === 'serial' || activeTab.type === 'rdp' || activeTab.type === 'vnc') { return { connectionState: mapTabStatusToConnectionState(activeTab.connectionStatus), hostLabel: activeTab.type === 'serial' ? activeTab.serialConfig ? `${activeTab.serialConfig.path} · ${activeTab.serialConfig.baudRate}` : activeTab.title : activeTab.title, stats: undefined, connectionId: undefined, isRemoteSsh: false, sessionOsPretty: undefined as string | undefined, } } if (activeTab.isLocal) { return { connectionState: mapTabStatusToConnectionState(activeTab.connectionStatus), hostLabel: t('terminal:toolbar.labelLocal'), stats: undefined, connectionId: undefined, isRemoteSsh: false, sessionOsPretty: undefined as string | undefined, } } const session = activeTab.sessionId ? sessions.find((s) => s.id === activeTab.sessionId) : undefined const sessionOsPretty = session?.osPrettyName const instance = activeTab.connectionInstance if (instance?.connectionId) { const conn = connections.get(instance.connectionId) if (conn) { return { connectionState: conn.state, hostLabel: `${conn.config.username}@${conn.config.host}:${conn.config.port || 22}`, stats: conn.stats, connectionId: instance.connectionId, isRemoteSsh: true, sessionOsPretty, } } } if (session) { return { connectionState: mapTabStatusToConnectionState(activeTab.connectionStatus), hostLabel: `${session.username}@${session.host}:${session.port || 22}`, stats: undefined, connectionId: instance?.connectionId, isRemoteSsh: Boolean(instance?.connectionId), sessionOsPretty, } } const state = mapTabStatusToConnectionState(activeTab.connectionStatus) const config = activeTab.connectionConfig const label = config ? `${config.username}@${config.host}:${config.port || 22}` : undefined return { connectionState: state, hostLabel: label, stats: undefined, connectionId: instance?.connectionId, isRemoteSsh: Boolean(instance?.connectionId), sessionOsPretty, } }, [activeTab, connections, sessions, t]) const metricsEnabled = isRemoteSsh && Boolean(connectionId) && (connectionState === 'connected' || connectionState === 'background') const socksAvailable = metricsEnabled const { metrics, stale } = useRemoteHostMetrics(connectionId, metricsEnabled) const forwardStorageKey = useMemo(() => { if (!activeTabId) return null return portForwardStorageKey(activeTab?.sessionId, activeTabId, connectionId) }, [activeTab?.sessionId, activeTabId, connectionId]) const intentKey = useMemo( () => (activeTabId ? forwardIntentKey(activeTab?.sessionId, activeTabId) : null), [activeTab?.sessionId, activeTabId] ) const forwardRuntimes = useMemo(() => { const map = new Map() for (const tunnel of forwardTunnels) { map.set(tunnel.forwardId, { running: Boolean(tunnel.running), boundPort: tunnel.boundPort, }) } // Rule ids can diverge from runtime ids (reconnect / legacy). Match by kind+listen port. for (const rule of forwardRules) { if (map.get(rule.id)?.running) continue const tunnel = forwardTunnels.find((item) => tunnelCoversRule([item], rule)) if (tunnel) { map.set(rule.id, { running: true, boundPort: tunnel.boundPort }) } } return map }, [forwardRules, forwardTunnels]) const dynamicListenPort = useMemo(() => { const dynamic = forwardRules.find((rule) => rule.type === 'dynamic') const runtimePort = dynamic ? forwardRuntimes.get(dynamic.id)?.boundPort : undefined const tunnelPort = forwardTunnels.find((tunnel) => tunnel.kind === 'dynamic')?.boundPort return resolveDynamicPort(runtimePort || tunnelPort || dynamic?.localPort) }, [forwardRules, forwardRuntimes, forwardTunnels]) const normalizeRules = useCallback( (rules: ForwardRule[]): ForwardRule[] => { const localRemote = rules.filter((rule) => rule.type !== 'dynamic') const dynamic = rules.find((rule) => rule.type === 'dynamic') || ({ id: `pf-dynamic-${connectionId || 'default'}`, type: 'dynamic' as const, name: t('common:portForward.socksRuleName'), localPort: DEFAULT_DYNAMIC_PORT, } satisfies ForwardRule) return [ { ...dynamic, type: 'dynamic', localPort: resolveDynamicPort(dynamic.localPort), name: dynamic.name?.trim() || t('common:portForward.socksRuleName'), }, ...localRemote, ] }, [connectionId, t] ) const persistForwardRules = useCallback( async (next: ForwardRule[]) => { const normalized = normalizeRules(next) setForwardRules(normalized) if (!forwardStorageKey || !window.opsxApi?.settingsSet) return normalized await window.opsxApi.settingsSet(forwardStorageKey, normalized) return normalized }, [forwardStorageKey, normalizeRules] ) const refreshForwardState = useCallback(async () => { if (!connectionId || !window.opsxApi?.sshForwardList) { setSocksRunning(false) setForwardTunnels([]) return } try { const result = await window.opsxApi.sshForwardList(connectionId) const tunnels = result?.success ? result.tunnels || [] : [] setForwardTunnels(tunnels) setSocksRunning(tunnels.some((tunnel) => tunnel.kind === 'dynamic' && tunnel.running)) } catch { setSocksRunning(false) setForwardTunnels([]) } }, [connectionId]) const loadForwardRules = useCallback(async () => { if (!forwardStorageKey || !window.opsxApi?.settingsGet) { setForwardRules(normalizeRules([])) return } const loaded = await window.opsxApi.settingsGet(forwardStorageKey) const parsed = parseForwardRules(loaded?.value) const normalized = normalizeRules(parsed) setForwardRules(normalized) // Persist normalized single-dynamic shape once. if (JSON.stringify(parsed) !== JSON.stringify(normalized)) { await window.opsxApi.settingsSet?.(forwardStorageKey, normalized) } }, [forwardStorageKey, normalizeRules]) useEffect(() => { if (!socksAvailable) { setForwardRules([]) setForwardTunnels([]) setSocksRunning(false) setForwardDrawerOpen(false) return } void loadForwardRules() void refreshForwardState() const timer = window.setInterval(() => void refreshForwardState(), 2500) return () => window.clearInterval(timer) }, [socksAvailable, loadForwardRules, refreshForwardState]) const previousForwardConnectionRef = useRef(undefined) useEffect(() => { previousForwardConnectionRef.current = undefined }, [intentKey]) const openForwardDrawer = useCallback(() => { if (!connectionId || !activeTabId) return if (!requirePro('portForward')) return void loadForwardRules() void refreshForwardState() setForwardDraft({ ...emptyForwardDraft(), type: 'local', name: t('common:portForward.defaultLocalName'), localBindHost: '127.0.0.1', localPort: 8080, remoteHost: '127.0.0.1', remotePort: 80, }) setForwardDrawerError(null) setForwardDrawerOpen(true) }, [activeTabId, connectionId, loadForwardRules, refreshForwardState, requirePro, t]) const closeForwardDrawer = useCallback(() => { if (forwardSaving) return setForwardDrawerOpen(false) setForwardDrawerError(null) setForwardDraft(emptyForwardDraft()) }, [forwardSaving]) const validPort = (value: unknown): value is number => typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 65535 const startForwardRule = useCallback( async (rule: ForwardRule): Promise => { if (!connectionId) return false const result = await startNativeForward(connectionId, rule) if (!result.success) { setForwardDrawerError( translateIpcError(result.error, result.errorCode) || t('common:portForward.errorStartFailed') ) return false } if (intentKey) rememberForwardIntent(intentKey, rule) await refreshForwardState() return true }, [connectionId, intentKey, refreshForwardState, t] ) useEffect(() => { if (!socksAvailable || !connectionId || !intentKey) return const previous = previousForwardConnectionRef.current previousForwardConnectionRef.current = connectionId if (!previous || previous === connectionId) return void (async () => { const listed = await window.opsxApi?.sshForwardList?.(connectionId) const tunnels = listed?.success ? listed.tunnels || [] : [] const restored = await restoreForwardIntent( intentKey, (rule) => tunnelCoversRule(tunnels, rule), (rule) => startForwardRule(rule) ) if (restored > 0) { toast.success(t('common:statusBar.forwardResumed', { count: restored })) } })() }, [socksAvailable, connectionId, intentKey, startForwardRule, t, toast]) const stopForwardRule = useCallback( async (rule: ForwardRule): Promise => { const result = await stopNativeForward(connectionId, rule) if (!result.success) { setForwardDrawerError( translateIpcError(result.error, result.errorCode) || t('common:portForward.errorStopFailed') ) return false } if (intentKey) forgetForwardIntent(intentKey, rule.id) await refreshForwardState() return true }, [connectionId, intentKey, refreshForwardState, t] ) const handleStartRule = useCallback( async (rule: ForwardRule) => { if (!connectionId || forwardSaving) return if (forwardRuntimes.get(rule.id)?.running) { toast.success(t('common:statusBar.forwardStarted')) return } setForwardSaving(true) setForwardDrawerError(null) try { let target = rule if (rule.type === 'dynamic') { const existing = forwardRules.find((item) => item.type === 'dynamic') if (existing) { target = { ...existing, localPort: resolveDynamicPort(rule.localPort || existing.localPort), } } else { target = { id: `pf-${crypto.randomUUID()}`, type: 'dynamic', name: t('common:portForward.socksRuleName'), localPort: resolveDynamicPort(rule.localPort), } await persistForwardRules([...forwardRules, target]) } } const ok = await startForwardRule(target) if (ok) { toast.success( target.type === 'dynamic' ? t('common:statusBar.forwardStartedDynamic', { port: resolveDynamicPort(target.localPort), }) : t('common:statusBar.forwardStarted') ) } } catch (error) { setForwardDrawerError( translateErrorMessage(error instanceof Error ? error.message : String(error)) || t('common:portForward.errorStartFailed') ) } finally { setForwardSaving(false) } }, [connectionId, forwardRules, forwardRuntimes, forwardSaving, persistForwardRules, startForwardRule, t, toast] ) const handleStopRule = useCallback( async (rule: ForwardRule) => { if (forwardSaving) return setForwardSaving(true) setForwardDrawerError(null) try { await stopForwardRule(rule) } catch (error) { setForwardDrawerError( (error instanceof Error ? error.message : String(error)) || t('common:portForward.errorStopFailed') ) } finally { setForwardSaving(false) } }, [forwardSaving, stopForwardRule, t] ) const handleDeleteRule = useCallback( async (rule: ForwardRule) => { if (rule.type === 'dynamic' || forwardSaving) return setForwardSaving(true) setForwardDrawerError(null) try { if (forwardRuntimes.get(rule.id)?.running) { const stopped = await stopForwardRule(rule) if (!stopped) return } await persistForwardRules(forwardRules.filter((item) => item.id !== rule.id)) } catch (error) { setForwardDrawerError( (error instanceof Error ? error.message : String(error)) || t('common:portForward.errorStopFailed') ) } finally { setForwardSaving(false) } }, [forwardRules, forwardRuntimes, forwardSaving, persistForwardRules, stopForwardRule, t] ) const handleSaveForwardRule = useCallback(async () => { if (!connectionId || !activeTabId || !window.opsxApi || forwardSaving) return // Dynamic footer action = toggle the single SOCKS rule. if (forwardDraft.type === 'dynamic') { const dynamic = forwardRules.find((rule) => rule.type === 'dynamic') || ({ id: `pf-${crypto.randomUUID()}`, type: 'dynamic' as const, name: t('common:portForward.socksRuleName'), localPort: resolveDynamicPort(forwardDraft.localPort), } satisfies ForwardRule) const running = Boolean(forwardRuntimes.get(dynamic.id)?.running) || socksRunning if (running) { await handleStopRule(dynamic) } else { await handleStartRule({ ...dynamic, localPort: resolveDynamicPort(forwardDraft.localPort || dynamic.localPort), }) } return } if (!forwardDraft.name?.trim() || !validPort(forwardDraft.localPort)) { setForwardDrawerError(t('common:portForward.errorNamePort')) return } if (!validPort(forwardDraft.remotePort)) { setForwardDrawerError( forwardDraft.type === 'local' ? t('common:portForward.errorTargetAddress') : t('common:portForward.errorRemoteListenAddress') ) return } if (!forwardDraft.remoteHost?.trim()) { setForwardDrawerError( forwardDraft.type === 'local' ? t('common:portForward.errorTargetAddress') : t('common:portForward.errorLocalTargetAddress') ) return } const rule: ForwardRule = { id: `pf-${crypto.randomUUID()}`, type: forwardDraft.type || 'local', name: forwardDraft.name.trim(), localPort: forwardDraft.localPort, localBindHost: forwardDraft.type === 'local' ? resolveLocalBindHost(forwardDraft.localBindHost) : undefined, remoteBindHost: forwardDraft.type === 'remote' ? resolveRemoteBindHost(forwardDraft.remoteBindHost) : undefined, remoteHost: resolveForwardTargetHost(forwardDraft.remoteHost), remotePort: forwardDraft.remotePort, } setForwardSaving(true) setForwardDrawerError(null) try { const ok = await startForwardRule(rule) if (!ok) return const next = [ ...forwardRules.filter((item) => item.type === 'dynamic').slice(0, 1), ...forwardRules.filter((item) => item.type !== 'dynamic'), rule, ] await persistForwardRules(next) setForwardDraft({ ...emptyForwardDraft(), type: 'local', name: t('common:portForward.defaultLocalName'), }) toast.success(t('common:statusBar.forwardStarted')) } catch (error) { setForwardDrawerError( translateErrorMessage(error instanceof Error ? error.message : String(error)) || t('common:portForward.errorStartFailed') ) } finally { setForwardSaving(false) } }, [ activeTabId, connectionId, forwardDraft, forwardRules, forwardRuntimes, forwardSaving, handleStartRule, handleStopRule, persistForwardRules, socksRunning, startForwardRule, t, toast, ]) const handleCopySocks = useCallback( async (event: { preventDefault: () => void }) => { event.preventDefault() const port = dynamicListenPort const value = `socks5://127.0.0.1:${port}` try { await window.opsxApi?.clipboardWriteText(value) toast.success(t('common:statusBar.socksCopied', { port })) } catch { // ignore } }, [dynamicListenPort, t, toast] ) useEffect(() => { const timer = setInterval(() => { setCurrentTime(new Date()) }, 1000) return () => clearInterval(timer) }, []) const formatTime = (date: Date) => formatLocaleTime(date, { hour: '2-digit', minute: '2-digit', second: '2-digit', }) const statusKey = connectionState ?? 'disconnected' const statusCfg = STATUS_CONFIG[statusKey] ?? STATUS_CONFIG.disconnected const statusLabel = t(statusCfg.labelKey) const transferLabel = stats && stats.bytesTransferred > 0 ? `↑↓ ${formatBytes(stats.bytesTransferred)}` : null return (
{metricsEnabled && ( )}
{socksAvailable && ( )}
{formatTime(currentTime)}
{ setForwardDraft({ type: next.type || 'local', name: next.name || '', localPort: next.type === 'dynamic' ? resolveDynamicPort(next.localPort) : next.localPort || 0, localBindHost: next.type === 'local' ? resolveLocalBindHost(next.localBindHost) : '', remoteBindHost: next.type === 'remote' ? resolveRemoteBindHost(next.remoteBindHost) : '', remoteHost: next.remoteHost || '', remotePort: next.remotePort || 0, }) setForwardDrawerError(null) }} onClose={closeForwardDrawer} onSave={() => void handleSaveForwardRule()} onStartRule={(rule) => void handleStartRule(rule)} onStopRule={(rule) => void handleStopRule(rule)} onDeleteRule={(rule) => void handleDeleteRule(rule)} />
) }