996 lines
34 KiB
TypeScript
996 lines
34 KiB
TypeScript
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<string, Omit<StatusDisplay, 'labelKey'> & { labelKey: string }> = {
|
||
connected: {
|
||
icon: <Wifi className="h-3 w-3" />,
|
||
labelKey: 'status.connected',
|
||
colorClass: 'text-feedback-success',
|
||
animateClass: '',
|
||
},
|
||
connecting: {
|
||
icon: <Loader2 className="h-3 w-3 animate-spin" />,
|
||
labelKey: 'status.connecting',
|
||
colorClass: 'text-feedback-warning',
|
||
animateClass: 'animate-pulse',
|
||
},
|
||
background: {
|
||
icon: <Wifi className="h-3 w-3" />,
|
||
labelKey: 'status.background',
|
||
colorClass: 'text-feedback-success',
|
||
animateClass: '',
|
||
},
|
||
suspended: {
|
||
icon: <WifiOff className="h-3 w-3" />,
|
||
labelKey: 'status.suspended',
|
||
colorClass: 'text-feedback-warning',
|
||
animateClass: '',
|
||
},
|
||
reconnecting: {
|
||
icon: <Loader2 className="h-3 w-3 animate-spin" />,
|
||
labelKey: 'status.reconnecting',
|
||
colorClass: 'text-feedback-warning',
|
||
animateClass: 'animate-pulse',
|
||
},
|
||
error: {
|
||
icon: <AlertCircle className="h-3 w-3" />,
|
||
labelKey: 'status.error',
|
||
colorClass: 'text-feedback-error',
|
||
animateClass: '',
|
||
},
|
||
disconnected: {
|
||
icon: <WifiOff className="h-3 w-3" />,
|
||
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<HostMetrics | null>(null)
|
||
const [stale, setStale] = useState(false)
|
||
const cacheRef = useRef<Map<string, HostMetrics>>(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<HTMLDivElement>(null)
|
||
const disksRef = useRef<HTMLDivElement>(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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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 (
|
||
<div
|
||
ref={stripRef}
|
||
className={`ml-3 flex min-w-0 items-center gap-2 overflow-hidden whitespace-nowrap font-mono tabular-nums ${
|
||
stale ? 'opacity-60' : 'opacity-100'
|
||
}`}
|
||
title={tipParts.join(' · ')}
|
||
>
|
||
{osLabel && (
|
||
<span className="max-w-[10rem] shrink truncate text-content-secondary" title={osLabel}>
|
||
{osLabel}
|
||
</span>
|
||
)}
|
||
{tz && <span className="shrink-0 text-txt-faint">{tz}</span>}
|
||
{metrics?.cpuPercent != null && (
|
||
<span className={`shrink-0 ${pctClass(metrics.cpuPercent)}`}>CPU {formatCpuPercent(metrics.cpuPercent)}</span>
|
||
)}
|
||
{metrics?.memPercent != null && (
|
||
<span className={`shrink-0 ${pctClass(metrics.memPercent)}`}>MEM {metrics.memPercent}%</span>
|
||
)}
|
||
{transferLabel && (
|
||
<span className="flex shrink-0 items-center gap-1 text-txt-muted">
|
||
<HardDrive className="h-3 w-3" />
|
||
{transferLabel}
|
||
</span>
|
||
)}
|
||
<div ref={disksRef} className="flex shrink-0 items-center gap-2">
|
||
{visibleDisks.map((d) => (
|
||
<span key={d.mount} className={`shrink-0 ${pctClass(d.percent)}`}>
|
||
{shortMount(d.mount)} {d.percent}%
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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<string | null>(null)
|
||
const [forwardSaving, setForwardSaving] = useState(false)
|
||
const [forwardRules, setForwardRules] = useState<ForwardRule[]>([])
|
||
const [forwardTunnels, setForwardTunnels] = useState<SSHForwardStatus[]>([])
|
||
|
||
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<string, { running: boolean; boundPort?: number }>()
|
||
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<string | undefined>(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<boolean> => {
|
||
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<boolean> => {
|
||
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 (
|
||
<div className="app-statusbar border-dark-700/40 bg-dark-800/95 flex h-7 select-none items-center px-3 text-[11px] text-content-primary">
|
||
<button
|
||
type="button"
|
||
onClick={togglePanel}
|
||
className={`hover:bg-dark-700/70 group -ml-1 flex shrink-0 cursor-pointer items-center gap-1.5 whitespace-nowrap rounded-md px-1.5 py-0.5 transition-colors ${statusCfg.colorClass} ${statusCfg.animateClass}`}
|
||
title={t('common:connectionPanel.title')}
|
||
>
|
||
{statusCfg.icon && <span className="transition-colors duration-300">{statusCfg.icon}</span>}
|
||
<span className="font-medium transition-colors duration-300">{statusLabel}</span>
|
||
{hostLabel && (
|
||
<>
|
||
<span className="mx-0.5 text-txt-faint">·</span>
|
||
<span className="max-w-[280px] truncate font-mono text-content-secondary" title={hostLabel}>
|
||
{hostLabel}
|
||
</span>
|
||
</>
|
||
)}
|
||
{totalConnections > 0 && (
|
||
<span className="ml-1 rounded-full bg-accent-blue/20 px-1.5 py-px text-[10px] font-medium tabular-nums text-accent-blue">
|
||
{totalConnections}
|
||
</span>
|
||
)}
|
||
<ChevronUp className="h-3 w-3 text-txt-faint transition-transform duration-200 group-hover:rotate-180" />
|
||
</button>
|
||
|
||
{metricsEnabled && (
|
||
<RemoteHostMetricsStrip
|
||
metrics={metrics}
|
||
stale={stale}
|
||
fallbackOs={sessionOsPretty}
|
||
transferLabel={transferLabel}
|
||
/>
|
||
)}
|
||
|
||
<div data-statusbar-spacer className="min-w-2 flex-1" />
|
||
|
||
{socksAvailable && (
|
||
<button
|
||
type="button"
|
||
onClick={openForwardDrawer}
|
||
onContextMenu={(event) => void handleCopySocks(event)}
|
||
disabled={forwardSaving}
|
||
title={
|
||
socksRunning
|
||
? t('common:statusBar.forwardOpenRunning', { port: dynamicListenPort })
|
||
: t('common:statusBar.forwardOpenIdle', { port: dynamicListenPort })
|
||
}
|
||
aria-label={t('common:statusBar.forwardOpenIdle', { port: dynamicListenPort })}
|
||
aria-pressed={socksRunning}
|
||
className={`mr-2 flex shrink-0 items-center gap-1 whitespace-nowrap rounded-md px-1.5 py-0.5 transition-colors disabled:opacity-50 ${
|
||
socksRunning
|
||
? 'bg-accent-blue/20 text-accent-blue hover:bg-accent-blue/30'
|
||
: 'hover:bg-dark-700/70 text-txt-muted hover:text-content-secondary'
|
||
}`}
|
||
>
|
||
{forwardSaving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Globe className="h-3.5 w-3.5" />}
|
||
<span className="tabular-nums">
|
||
{t('common:statusBar.socksLabel')} :{dynamicListenPort}
|
||
</span>
|
||
</button>
|
||
)}
|
||
|
||
<div className="flex items-center gap-1 tabular-nums text-txt-faint">
|
||
<Clock className="h-3 w-3" />
|
||
<span>{formatTime(currentTime)}</span>
|
||
</div>
|
||
|
||
<ForwardRuleDrawer
|
||
open={forwardDrawerOpen}
|
||
editing={false}
|
||
draft={forwardDraft}
|
||
error={forwardDrawerError}
|
||
rules={forwardRules}
|
||
runtimes={forwardRuntimes}
|
||
busy={forwardSaving}
|
||
viaSession={hostLabel}
|
||
onChange={(next) => {
|
||
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)}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|