Files
opsxshell/src/components/layout/StatusBar.tsx
T

996 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}