Initial Super N2N desktop application
Build Windows Portable Package / portable (push) Has been cancelled

This commit is contained in:
2026-09-01 13:30:52 +08:00
commit c955401fb4
78 changed files with 8266 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
import { invoke } from '@tauri-apps/api/core'
export const isTauri = typeof window !== 'undefined' && Boolean(window.__TAURI_INTERNALS__)
export async function nativeCall(command, args, fallback) {
if (!isTauri) return fallback()
try {
return await invoke(command, args)
} catch (error) {
throw new Error(String(error))
}
}
export const startService = (request) => nativeCall('start_service', { request }, () => {
throw new Error('浏览器预览无法启动原生服务')
})
export const stopService = (service) => nativeCall('stop_service', { service }, () => {
throw new Error('浏览器预览没有受管进程')
})
export const serviceStatus = (service) => nativeCall('service_status', { service }, () => ({
service,
running: false,
pid: null,
started_at: 0,
message: '浏览器预览没有受管进程',
}))
export const queryManagement = (request) => nativeCall('management_query', { request }, () => {
throw new Error('浏览器预览不支持管理 API 查询')
})
export const runPing = (request, fallback) => nativeCall('run_ping', { request }, fallback)
export const runTcping = (request, fallback) => nativeCall('run_tcping', { request }, fallback)
export const checkFirewall = (fallback) => nativeCall('firewall_status', {}, fallback)
export const tapTraffic = (tapDevice) => nativeCall('tap_traffic', { request: { tapDevice } }, () => {
throw new Error('浏览器预览无法读取 TAP 流量')
})
export const exportLog = (contents) => nativeCall('export_log', { contents }, () => null)
+341
View File
@@ -0,0 +1,341 @@
import { useEffect, useRef, useState } from 'react'
import { createRoot } from 'react-dom/client'
import {
ArrowDown, ArrowUp, CheckCircle2, ChevronDown, CircleAlert, Gauge, Globe2, Network, Play,
Download, Router, ScrollText, Server, Settings2, Square, Wifi,
} from 'lucide-react'
import { exportLog, isTauri, serviceStatus, startService, stopService, tapTraffic } from './lib/tauri'
import './styles.css'
const initialEdge = {
serverHost: '',
serverPort: '7777',
community: '',
dhcp: true,
ip: '',
encryptionKey: '',
managementPort: '5644',
tapDevice: 'n2n0',
}
function App() {
const [home, setHome] = useState('edge')
const [edge, setEdge] = useState(initialEdge)
const [edgeRunning, setEdgeRunning] = useState(false)
const [advancedOpen, setAdvancedOpen] = useState(false)
const [logOpen, setLogOpen] = useState(false)
const [logs, setLogs] = useState([])
const [traffic, setTraffic] = useState(null)
const [message, setMessage] = useState(null)
const lastTapSample = useRef(null)
const appendLog = (text, level = 'info') => {
const time = new Date().toLocaleTimeString('zh-CN', { hour12: false })
setLogs((current) => [{ time, level, text }, ...current].slice(0, 100))
}
useEffect(() => {
let active = true
serviceStatus('edge')
.then((status) => {
if (!active) return
setEdgeRunning(Boolean(status.running))
appendLog(status.running ? '检测到 Edge 已连接' : 'Edge 已就绪,等待连接', status.running ? 'success' : 'info')
})
.catch(() => {})
return () => { active = false }
}, [])
useEffect(() => {
if (!edgeRunning) {
lastTapSample.current = null
setTraffic(null)
return undefined
}
let active = true
let timer
const poll = async () => {
try {
const sample = await tapTraffic(edge.tapDevice)
const sampledAt = Date.now()
const previous = lastTapSample.current
const elapsedSeconds = previous ? Math.max((sampledAt - previous.sampledAt) / 1000, .25) : 1
const downRate = previous ? Math.max(0, sample.receivedBytes - previous.receivedBytes) / elapsedSeconds : 0
const upRate = previous ? Math.max(0, sample.sentBytes - previous.sentBytes) / elapsedSeconds : 0
lastTapSample.current = { ...sample, sampledAt }
if (active) setTraffic({ ...sample, downRate, upRate, error: null })
} catch (error) {
if (active) setTraffic((current) => ({ ...current, error: error.message }))
} finally {
if (active) timer = window.setTimeout(poll, 1000)
}
}
poll()
return () => {
active = false
window.clearTimeout(timer)
}
}, [edgeRunning, edge.tapDevice])
const updateEdge = (field, value) => setEdge((current) => ({ ...current, [field]: value }))
const notify = (text, tone = 'error') => {
setMessage({ text, tone })
window.setTimeout(() => setMessage(null), 3200)
}
const toggleEdge = async () => {
if (edgeRunning) {
try {
const status = await stopService('edge')
setEdgeRunning(Boolean(status.running))
appendLog('已断开 N2N 网络')
notify('已断开 N2N 网络', 'neutral')
} catch (error) {
appendLog('停止连接失败:' + error.message, 'error')
notify('无法停止连接:' + error.message)
}
return
}
if (!edge.serverHost.trim()) {
appendLog('连接校验:未填写服务器 IP 或域名', 'warning')
notify('请填写服务器 IP 或域名')
return
}
if (!edge.community.trim()) {
appendLog('连接校验:未填写群组名称', 'warning')
notify('请填写群组名称')
return
}
if (!edge.dhcp && !edge.ip.trim()) {
appendLog('连接校验:静态 IP 未填写', 'warning')
notify('关闭 DHCP 后,请填写本机 IP')
return
}
try {
const status = await startService({
service: 'edge',
binaryPath: './n2n/edge.exe',
args: buildEdgeArgs(edge),
})
setEdgeRunning(Boolean(status.running))
appendLog('已连接到群组 ' + edge.community, 'success')
notify('已连接到 ' + edge.community, 'success')
} catch (error) {
appendLog('连接失败:' + error.message, 'error')
notify('无法建立连接:' + error.message)
}
}
return <main className="app-shell">
<header className="app-header">
<div className="brand"><span className="brand-icon"><Network size={19} /></span><span>Super N2N</span></div>
<div className="runtime"><span className={edgeRunning ? 'status-light live' : 'status-light'} />{edgeRunning ? '已连接' : '未连接'}</div>
</header>
<section className="workspace" aria-label="N2N 连接设置">
<div className="mode-switch" role="tablist" aria-label="选择主页">
<button role="tab" aria-selected={home === 'edge'} className={home === 'edge' ? 'active' : ''} onClick={() => setHome('edge')}>
<Router size={18} /> 加入网络
</button>
<button role="tab" aria-selected={home === 'supernode'} className={home === 'supernode' ? 'active' : ''} onClick={() => setHome('supernode')}>
<Server size={18} /> 创建服务器
</button>
</div>
{home === 'edge'
? <EdgeHome edge={edge} updateEdge={updateEdge} running={edgeRunning} traffic={traffic} advancedOpen={advancedOpen} setAdvancedOpen={setAdvancedOpen} logOpen={logOpen} setLogOpen={setLogOpen} logs={logs} onToggle={toggleEdge} onExportLog={() => exportLogs(logs, notify)} />
: <SupernodeHome />}
</section>
<footer className="app-footer"><span>{isTauri ? '本机服务控制已就绪' : '浏览器预览模式'}</span><span>n2n 3.1.1</span></footer>
{message && <Notice {...message} />}
</main>
}
function EdgeHome({ edge, updateEdge, running, traffic, advancedOpen, setAdvancedOpen, logOpen, setLogOpen, logs, onToggle, onExportLog }) {
const toggleAdvanced = () => {
setLogOpen(false)
setAdvancedOpen((current) => !current)
}
const toggleLog = () => {
setAdvancedOpen(false)
setLogOpen((current) => !current)
}
return <div className="connection-flow">
<div className="intro">
<div className="intro-icon"><Wifi size={28} /></div>
<div>
<p className="eyebrow">EDGE</p>
<h1>加入一个网络</h1>
<p className="intro-copy">填写服务器和群组信息然后连接</p>
</div>
</div>
<section className="connection-card">
<div className="field-stack">
<label className="input-group">
<span className="field-title"><Globe2 size={18} />服务器</span>
<span className="server-inputs">
<input value={edge.serverHost} onChange={(event) => updateEdge('serverHost', event.target.value)} placeholder="IP 地址或域名" autoComplete="off" disabled={running} aria-label="服务器 IP 或域名" />
<span className="port-separator">:</span>
<input className="port-input" inputMode="numeric" value={edge.serverPort} onChange={(event) => updateEdge('serverPort', event.target.value)} placeholder="7777" disabled={running} aria-label="服务器端口" />
</span>
</label>
<label className="input-group">
<span className="field-title"><Network size={18} />群组名称</span>
<input value={edge.community} onChange={(event) => updateEdge('community', event.target.value)} placeholder="例如:my-network" autoComplete="off" disabled={running} />
</label>
<div className="input-group">
<span className="ip-heading"><span className="field-title"><Router size={18} />本机 IP</span><DhcpToggle checked={edge.dhcp} disabled={running} onChange={(checked) => updateEdge('dhcp', checked)} /></span>
<input value={edge.dhcp ? '' : edge.ip} onChange={(event) => updateEdge('ip', event.target.value)} placeholder={edge.dhcp ? '开启 DHCP 后由服务器分配' : '例如:10.0.0.2'} autoComplete="off" disabled={edge.dhcp || running} aria-describedby="ip-help" />
<small id="ip-help">{edge.dhcp ? 'DHCP 已开启,连接后自动获取 IP。' : '使用静态 IP 时,请确保它没有被其他设备占用。'}</small>
</div>
</div>
<button className={running ? 'connect-button stop' : 'connect-button'} onClick={onToggle}>
{running ? <Square size={19} fill="currentColor" /> : <Play size={20} fill="currentColor" />}
{running ? '断开连接' : '连接网络'}
</button>
{running && <TrafficStrip traffic={traffic} tapDevice={edge.tapDevice} />}
</section>
<div className="secondary-actions">
<section className="advanced-section">
<button className="advanced-toggle" onClick={toggleAdvanced} aria-expanded={advancedOpen}>
<span><Settings2 size={17} />高级设置</span><ChevronDown size={18} className={advancedOpen ? 'rotated' : ''} />
</button>
{advancedOpen && <div className="advanced-fields">
<label><span>加密密钥</span><input type="password" value={edge.encryptionKey} onChange={(event) => updateEdge('encryptionKey', event.target.value)} placeholder="可选" disabled={running} /></label>
<label><span>管理端口</span><input inputMode="numeric" value={edge.managementPort} onChange={(event) => updateEdge('managementPort', event.target.value)} disabled={running} /></label>
<label><span>TAP 设备名称</span><input value={edge.tapDevice} onChange={(event) => updateEdge('tapDevice', event.target.value)} disabled={running} /></label>
</div>}
</section>
<LogPanel open={logOpen} onToggle={toggleLog} logs={logs} onExport={onExportLog} />
</div>
</div>
}
function TrafficStrip({ traffic, tapDevice }) {
if (!traffic || traffic.error) {
return <div className="traffic-strip pending" role="status" title={traffic?.error || ''}>
<span className="traffic-device"><Gauge size={17} />TAP {tapDevice}</span>
<strong>{traffic?.error ? '无法读取流量' : '正在读取流量…'}</strong>
</div>
}
return <section className="traffic-strip" aria-label={'TAP ' + traffic.interfaceName + ' 实时流量'}>
<div className="traffic-device"><Gauge size={17} /><span>TAP</span><strong>{traffic.interfaceName}</strong></div>
<TrafficMetric direction="down" label="下载" rate={traffic.downRate} total={traffic.receivedBytes} />
<TrafficMetric direction="up" label="上传" rate={traffic.upRate} total={traffic.sentBytes} />
</section>
}
function TrafficMetric({ direction, label, rate, total }) {
const Icon = direction === 'down' ? ArrowDown : ArrowUp
return <output className={'traffic-metric ' + direction}>
<span><Icon size={15} />{label}</span><strong>{formatRate(rate)}</strong><small>累计 {formatBytes(total)}</small>
</output>
}
function DhcpToggle({ checked, disabled, onChange }) {
return <label className="dhcp-toggle">
<input type="checkbox" checked={checked} disabled={disabled} onChange={(event) => onChange(event.target.checked)} />
<span className="toggle-track" aria-hidden="true"><i /></span>
<span>DHCP</span>
</label>
}
function SupernodeHome() {
return <div className="coming-soon">
<div className="coming-icon"><Server size={29} /></div>
<p className="eyebrow">SUPERNODE</p>
<h1>创建服务器</h1>
<p>服务器配置即将加入此页面</p>
</div>
}
function LogPanel({ open, onToggle, logs, onExport }) {
return <section className="log-section">
<button className="log-toggle" onClick={onToggle} aria-expanded={open}>
<span><ScrollText size={17} />运行日志 <small>{logs.length}</small></span><ChevronDown size={18} className={open ? 'rotated' : ''} />
</button>
{open && <div className="log-content">
<div className="log-toolbar"><span>本次会话</span><button className="export-log" onClick={onExport} disabled={logs.length === 0}><Download size={16} />导出 .log</button></div>
{logs.length === 0
? <p className="empty-log">暂无运行记录连接或校验网络后事件会显示在这里</p>
: <ol className="log-list">{logs.map((log, index) => <li key={log.time + index} className={log.level}><time>{log.time}</time><span>{log.text}</span></li>)}</ol>}
</div>}
</section>
}
function Notice({ text, tone }) {
const Icon = tone === 'success' ? CheckCircle2 : CircleAlert
return <div className={'notice ' + tone} role="status"><Icon size={19} />{text}</div>
}
async function exportLogs(logs, notify) {
const exportedAt = new Date()
const timestamp = exportedAt.toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19)
const body = [
'Super N2N session log',
'Exported: ' + exportedAt.toLocaleString('zh-CN', { hour12: false }),
'',
...logs.slice().reverse().map((log) => '[' + log.time + '] [' + log.level.toUpperCase() + '] ' + log.text),
'',
].join('\n')
const savedPath = await exportLog(body)
if (savedPath) {
notify('运行日志已导出到 ' + savedPath, 'success')
return
}
const url = URL.createObjectURL(new Blob([body], { type: 'text/plain;charset=utf-8' }))
const link = document.createElement('a')
link.href = url
link.download = 'super-n2n-' + timestamp + '.log'
document.body.appendChild(link)
link.click()
link.remove()
window.setTimeout(() => URL.revokeObjectURL(url), 0)
notify('运行日志已导出', 'success')
}
function formatRate(bytesPerSecond) {
return formatBytes(bytesPerSecond) + '/s'
}
function formatBytes(bytes) {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
const value = bytes / (1024 ** index)
return value.toFixed(value >= 100 || index === 0 ? 0 : 1) + ' ' + units[index]
}
function addOption(args, flag, value) {
if (String(value ?? '').trim()) args.push(flag, String(value).trim())
}
function buildEdgeArgs(config) {
const args = []
addOption(args, '-c', config.community)
addOption(args, '-l', config.serverHost + ':' + (config.serverPort || '7777'))
addOption(args, '-a', config.dhcp ? 'dhcp:0.0.0.0' : config.ip)
addOption(args, '-d', config.tapDevice)
addOption(args, '-t', config.managementPort)
if (config.encryptionKey) addOption(args, '-k', config.encryptionKey)
args.push('-f', '-v', '-v')
return args
}
const rootElement = document.getElementById('root')
const appRoot = globalThis.__superN2nRoot ?? createRoot(rootElement)
globalThis.__superN2nRoot = appRoot
appRoot.render(<App />)
+261
View File
@@ -0,0 +1,261 @@
:root {
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
color: #17242c;
background: #edf5f5;
font-synthesis: none;
text-rendering: optimizeLegibility;
--ink: #17242c;
--muted: #5e7079;
--faint: #81939b;
--canvas: #edf5f5;
--surface: #ffffff;
--line: #d6e1e2;
--line-strong: #b9cacc;
--teal: #087f72;
--teal-dark: #056457;
--teal-soft: #dff3ef;
--blue: #2b6cb0;
--red: #bd3d46;
}
* { box-sizing: border-box; }
html, body, #root { min-width: 320px; min-height: 100%; margin: 0; }
body { min-height: 100vh; background: var(--canvas); }
button, input { font: inherit; }
button { border: 0; cursor: pointer; }
button:focus-visible, input:focus-visible { outline: 3px solid rgba(43, 108, 176, .35); outline-offset: 2px; }
button:disabled, input:disabled { cursor: not-allowed; }
.app-shell { min-height: 100vh; display: grid; grid-template-rows: 56px 1fr 36px; background: var(--canvas); }
.app-header, .app-footer { width: min(100% - 40px, 888px); margin: 0 auto; display: flex; align-items: center; justify-content: space-between; }
.app-header { border-bottom: 1px solid var(--line); }
.brand { display: inline-flex; align-items: center; gap: 8px; color: var(--ink); font-size: 16px; font-weight: 700; }
.brand-icon { width: 28px; height: 28px; display: grid; place-items: center; color: #fff; background: var(--teal); border-radius: 6px; }
.runtime { display: flex; align-items: center; gap: 7px; color: var(--muted); font-size: 13px; }
.status-light { width: 7px; height: 7px; border-radius: 50%; background: #a6b5b9; }
.status-light.live { background: #0c9a78; box-shadow: 0 0 0 4px rgba(12, 154, 120, .14); }
.workspace { width: min(100% - 40px, 640px); align-self: center; justify-self: center; padding: 24px 0 30px; }
.mode-switch { width: max-content; display: flex; gap: 3px; padding: 3px; margin: 0 auto 22px; background: #e0ebeb; border: 1px solid #d1dede; border-radius: 8px; }
.mode-switch button { min-height: 38px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; padding: 0 14px; color: var(--muted); background: transparent; border-radius: 6px; font-size: 13px; font-weight: 600; transition: color 180ms ease, background 180ms ease, box-shadow 180ms ease; }
.mode-switch button:hover { color: var(--ink); }
.mode-switch button.active { color: var(--ink); background: var(--surface); box-shadow: 0 1px 3px rgba(26, 54, 58, .13); }
.connection-flow { animation: reveal 220ms ease-out; }
.intro { display: flex; align-items: center; justify-content: center; gap: 12px; margin-bottom: 18px; text-align: left; }
.intro-icon, .coming-icon { width: 44px; height: 44px; display: grid; place-items: center; flex: 0 0 auto; color: var(--teal); background: var(--teal-soft); border: 1px solid #b7ddd4; border-radius: 10px; }
.eyebrow { margin: 0 0 3px; color: var(--teal); font-size: 10px; font-weight: 700; letter-spacing: .08em; }
h1 { margin: 0; color: var(--ink); font-size: 24px; line-height: 1.2; letter-spacing: 0; }
.intro-copy { margin: 4px 0 0; color: var(--muted); font-size: 13px; line-height: 1.45; }
.connection-card { overflow: hidden; background: var(--surface); border: 1px solid var(--line); border-radius: 7px; box-shadow: 0 7px 20px rgba(21, 53, 57, .07); }
.field-stack { display: grid; padding: 18px 22px 6px; }
.input-group { display: grid; gap: 7px; padding: 14px 0; border-bottom: 1px solid #e7eeee; }
.input-group:last-child { border-bottom: 0; }
.field-title { display: inline-flex; align-items: center; gap: 7px; color: var(--ink); font-size: 14px; font-weight: 700; }
.field-title svg { color: var(--teal); }
.input-group input, .advanced-fields input { width: 100%; height: 40px; padding: 0 11px; color: var(--ink); background: #fbfdfd; border: 1px solid var(--line-strong); border-radius: 5px; font-size: 14px; transition: border-color 180ms ease, background 180ms ease, box-shadow 180ms ease; }
.input-group input:hover, .advanced-fields input:hover { border-color: #8ca5a8; }
.input-group input:focus, .advanced-fields input:focus { background: #fff; border-color: var(--blue); box-shadow: 0 0 0 3px rgba(43, 108, 176, .13); outline: 0; }
.input-group input:disabled { color: #83969b; background: #f0f5f5; border-color: #dce6e7; }
.input-group input::placeholder, .advanced-fields input::placeholder { color: #8ca0a5; }
.server-inputs { display: grid; grid-template-columns: minmax(0, 1fr) auto 88px; align-items: center; gap: 7px; }
.port-separator { color: var(--faint); font: 700 19px/1 ui-monospace, Consolas, monospace; }
.ip-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.input-group small { color: var(--muted); font-size: 12px; line-height: 1.4; }
.dhcp-toggle { display: inline-flex; align-items: center; gap: 7px; min-height: 28px; color: var(--muted); font-size: 13px; font-weight: 600; cursor: pointer; }
.dhcp-toggle input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.toggle-track { position: relative; width: 36px; height: 22px; flex: 0 0 auto; background: #a8b7ba; border-radius: 14px; transition: background 180ms ease; }
.toggle-track i { position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; background: #fff; border-radius: 50%; box-shadow: 0 1px 2px rgba(18, 42, 45, .2); transition: left 180ms ease; }
.dhcp-toggle input:checked + .toggle-track { background: var(--teal); }
.dhcp-toggle input:checked + .toggle-track i { left: 17px; }
.dhcp-toggle input:focus-visible + .toggle-track { outline: 3px solid rgba(43, 108, 176, .35); outline-offset: 2px; }
.dhcp-toggle input:disabled + .toggle-track { opacity: .55; }
.dhcp-toggle:has(input:disabled) { cursor: not-allowed; }
.connect-button { width: calc(100% - 44px); min-height: 46px; display: flex; align-items: center; justify-content: center; gap: 8px; margin: 13px 22px 18px; color: #fff; background: var(--teal); border: 1px solid var(--teal); border-radius: 5px; font-size: 15px; font-weight: 700; transition: background 180ms ease, border-color 180ms ease, transform 180ms ease; }
.connect-button:hover { background: var(--teal-dark); border-color: var(--teal-dark); }
.connect-button:active { transform: translateY(1px); }
.connect-button.stop { color: #fff; background: var(--red); border-color: var(--red); }
.connect-button.stop:hover { background: #a7313a; border-color: #a7313a; }
.connection-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); border-top: 1px solid var(--line); background: #f8fbfb; }
.connection-summary div { min-width: 0; display: grid; gap: 4px; padding: 12px 14px; border-right: 1px solid var(--line); }
.connection-summary div:last-child { border-right: 0; }
.connection-summary span { color: var(--faint); font-size: 11px; }
.connection-summary strong { overflow: hidden; color: var(--ink); font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.traffic-strip { min-height: 66px; display: grid; grid-template-columns: minmax(135px, 1.1fr) 1fr 1fr; align-items: stretch; border-top: 1px solid var(--line); background: #f7fbfa; }
.traffic-device { min-width: 0; display: flex; align-items: center; gap: 7px; padding: 12px 14px; color: var(--teal); border-right: 1px solid var(--line); font-size: 12px; }
.traffic-device span { color: var(--faint); font-weight: 700; }
.traffic-device strong { overflow: hidden; color: var(--ink); font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.traffic-metric { min-width: 0; display: grid; grid-template-columns: auto 1fr; align-content: center; gap: 3px 8px; padding: 9px 13px; border-right: 1px solid var(--line); font-variant-numeric: tabular-nums; }
.traffic-metric:last-child { border-right: 0; }
.traffic-metric span { display: inline-flex; align-items: center; gap: 4px; color: var(--faint); font-size: 11px; }
.traffic-metric.down span svg { color: var(--blue); }
.traffic-metric.up span svg { color: #b36b18; }
.traffic-metric strong { justify-self: end; color: var(--ink); font: 700 14px/1.2 ui-monospace, "Cascadia Code", Consolas, monospace; white-space: nowrap; }
.traffic-metric small { grid-column: 1 / -1; color: var(--muted); font-size: 11px; white-space: nowrap; }
.traffic-strip.pending { min-height: 52px; display: flex; align-items: center; justify-content: space-between; padding-right: 14px; color: var(--muted); }
.traffic-strip.pending .traffic-device { padding-top: 8px; padding-bottom: 8px; border-right: 0; }
.traffic-strip.pending > strong { font-size: 12px; font-weight: 600; }
.advanced-section, .log-section { margin-top: 8px; background: rgba(255, 255, 255, .58); border: 1px solid var(--line); border-radius: 7px; }
.advanced-toggle { width: 100%; min-height: 42px; display: flex; align-items: center; justify-content: space-between; padding: 0 14px; color: var(--muted); background: transparent; border-radius: 7px; font-size: 13px; font-weight: 600; }
.advanced-toggle:hover, .log-toggle:hover { color: var(--ink); background: rgba(255, 255, 255, .68); }
.advanced-toggle span, .log-toggle span { display: inline-flex; align-items: center; gap: 8px; }
.advanced-toggle svg, .log-toggle > svg { transition: transform 180ms ease; }
.advanced-toggle svg.rotated, .log-toggle > svg.rotated { transform: rotate(180deg); }
.advanced-fields { display: grid; grid-template-columns: 1.4fr .8fr 1fr; gap: 10px; padding: 0 14px 14px; }
.advanced-fields label { display: grid; gap: 6px; color: var(--muted); font-size: 12px; font-weight: 600; }
.advanced-fields input { height: 36px; padding: 0 9px; font-size: 13px; }
.log-toggle { width: 100%; min-height: 42px; display: flex; align-items: center; justify-content: space-between; padding: 0 14px; color: var(--muted); background: transparent; border-radius: 7px; font-size: 13px; font-weight: 600; }
.log-toggle small { min-width: 22px; height: 20px; display: inline-grid; place-items: center; padding: 0 6px; color: #41616a; background: #dfeaea; border-radius: 10px; font-size: 11px; }
.log-content { padding: 0 14px 14px; }
.log-toolbar { min-height: 40px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border-top: 1px solid var(--line); }
.log-toolbar > span { color: var(--faint); font-size: 12px; }
.export-log { min-height: 32px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 0 9px; color: var(--teal-dark); background: #edf8f5; border: 1px solid #badbd3; border-radius: 5px; font-size: 12px; font-weight: 700; transition: background 180ms ease, border-color 180ms ease; }
.export-log:hover { background: var(--teal-soft); border-color: #8ec7b9; }
.export-log:disabled { color: #93a2a5; background: #f2f5f5; border-color: var(--line); opacity: .75; }
.empty-log { margin: 0; padding: 24px 16px; color: var(--muted); text-align: center; background: #f8fbfb; border: 1px solid #e1e9ea; border-radius: 6px; font-size: 13px; line-height: 1.5; }
.log-list { max-height: 190px; margin: 0; padding: 0; overflow-y: auto; list-style: none; background: #172328; border: 1px solid #26383f; border-radius: 5px; }
.log-list li { min-height: 34px; display: grid; grid-template-columns: 68px minmax(0, 1fr); align-items: center; gap: 10px; padding: 6px 10px; color: #d4dfe1; border-bottom: 1px solid #29383e; font: 11px/1.4 ui-monospace, "Cascadia Code", Consolas, monospace; }
.log-list li:last-child { border-bottom: 0; }
.log-list time { color: #80979e; }
.log-list li.success span { color: #79d8bd; }
.log-list li.warning span { color: #edc67f; }
.log-list li.error span { color: #f0a0a6; }
.coming-soon { min-height: 280px; display: grid; align-content: center; justify-items: center; padding: 32px; text-align: center; background: rgba(255, 255, 255, .68); border: 1px dashed #b7cbcb; border-radius: 7px; animation: reveal 220ms ease-out; }
.coming-soon .coming-icon { margin-bottom: 14px; color: var(--blue); background: #e5f0fc; border-color: #c6dbf4; }
.coming-soon h1 { margin-bottom: 10px; }
.coming-soon p:last-child { margin: 0; color: var(--muted); font-size: 14px; }
.app-footer { color: var(--faint); border-top: 1px solid var(--line); font-size: 12px; }
.notice { position: fixed; right: 24px; bottom: 24px; max-width: min(420px, calc(100vw - 48px)); display: flex; align-items: center; gap: 10px; padding: 14px 16px; color: #803035; background: #fff7f7; border: 1px solid #e6b9bb; border-radius: 7px; box-shadow: 0 8px 22px rgba(44, 44, 44, .13); font-size: 14px; animation: reveal 200ms ease-out; }
.notice.success { color: #0d6a5e; background: #f0fbf8; border-color: #a8d9cd; }
.notice.neutral { color: #586a70; background: #f7fafa; border-color: #d2e0e1; }
@keyframes reveal { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
@media (max-width: 680px) {
.app-shell { grid-template-rows: 54px 1fr 34px; }
.app-header, .app-footer, .workspace { width: min(100% - 24px, 640px); }
.workspace { padding: 18px 0 24px; align-self: start; }
.mode-switch { width: 100%; margin-bottom: 18px; }
.mode-switch button { flex: 1; padding: 0 10px; font-size: 13px; }
.intro { align-items: center; justify-content: flex-start; gap: 10px; }
.intro-icon { width: 40px; height: 40px; border-radius: 9px; }
.intro-icon svg { width: 21px; height: 21px; }
h1 { font-size: 22px; }
.intro-copy { font-size: 12px; }
.field-stack { padding: 10px 16px 2px; }
.input-group { padding: 12px 0; }
.server-inputs { grid-template-columns: minmax(0, 1fr) auto 74px; gap: 5px; }
.input-group input { height: 38px; font-size: 14px; }
.connect-button { width: calc(100% - 32px); margin: 10px 16px 14px; }
.connection-summary { grid-template-columns: 1fr; }
.connection-summary div { grid-template-columns: 80px 1fr; align-items: baseline; padding: 9px 14px; border-right: 0; border-bottom: 1px solid var(--line); }
.connection-summary div:last-child { border-bottom: 0; }
.traffic-strip { grid-template-columns: 1fr 1fr; }
.traffic-device { grid-column: 1 / -1; padding: 9px 12px; border-right: 0; border-bottom: 1px solid var(--line); }
.traffic-metric { padding: 8px 11px; }
.traffic-strip.pending { display: flex; }
.traffic-strip.pending .traffic-device { border-bottom: 0; }
.advanced-fields { grid-template-columns: 1fr; }
.log-list li { grid-template-columns: 66px minmax(0, 1fr); gap: 8px; padding: 8px 10px; font-size: 11px; }
.app-footer { font-size: 11px; }
.notice { right: 16px; bottom: 16px; max-width: calc(100vw - 32px); }
}
@media (max-width: 400px), (max-height: 450px) {
html, body, #root { width: 100%; height: 100%; min-width: 0; min-height: 0; overflow: hidden; }
body { min-height: 0; }
.app-shell { width: 100%; height: 100%; min-height: 0; grid-template-rows: 42px minmax(0, 1fr); overflow: hidden; }
.app-header { width: 100%; min-width: 0; height: 42px; padding: 0 10px; }
.app-footer { display: none; }
.brand { gap: 6px; font-size: 14px; }
.brand-icon { width: 24px; height: 24px; border-radius: 5px; }
.brand-icon svg { width: 16px; height: 16px; }
.runtime { gap: 5px; font-size: 11px; }
.status-light { width: 6px; height: 6px; }
.workspace { width: 100%; height: 100%; min-height: 0; padding: 7px 10px 8px; overflow: hidden; align-self: stretch; }
.mode-switch { width: 100%; height: 34px; min-height: 34px; margin: 0 0 7px; padding: 2px; border-radius: 6px; }
.mode-switch button { min-height: 28px; gap: 5px; padding: 0 5px; border-radius: 4px; font-size: 12px; }
.mode-switch button svg { width: 15px; height: 15px; }
.connection-flow { height: calc(100% - 41px); display: grid; grid-template-rows: minmax(0, 1fr) 34px; gap: 6px; }
.intro { display: none; }
.connection-card { min-height: 0; display: flex; flex-direction: column; border-radius: 6px; box-shadow: none; }
.field-stack { min-height: 0; flex: 1 1 auto; padding: 3px 8px; }
.input-group { grid-template-columns: 70px minmax(0, 1fr); align-items: center; gap: 6px; padding: 4px 0; }
.input-group:not(:has(.ip-heading)) > .field-title { grid-column: 1; grid-row: 1; }
.input-group:not(:has(.ip-heading)) > input, .input-group:not(:has(.ip-heading)) > .server-inputs { grid-column: 2; grid-row: 1; }
.field-title { gap: 0; font-size: 12px; }
.field-title svg { display: none; }
.input-group input { height: 30px; padding: 0 7px; border-radius: 4px; font-size: 12px; }
.server-inputs { grid-template-columns: minmax(0, 1fr) auto 58px; gap: 4px; }
.port-separator { font-size: 15px; }
.input-group small { display: none; }
.ip-heading { display: contents; }
.input-group:has(.ip-heading) { position: relative; }
.input-group:has(.ip-heading) .field-title { grid-column: 1; grid-row: 1; }
.input-group:has(.ip-heading) > input { grid-column: 2; grid-row: 1; padding-right: 65px; }
.input-group:has(.ip-heading) .dhcp-toggle { position: absolute; top: 7px; right: 5px; gap: 4px; min-height: 18px; font-size: 10px; }
.toggle-track { width: 28px; height: 17px; }
.toggle-track i { top: 2px; left: 2px; width: 13px; height: 13px; }
.dhcp-toggle input:checked + .toggle-track i { left: 13px; }
.connect-button { width: calc(100% - 16px); min-height: 32px; flex: 0 0 32px; margin: 3px 8px 5px; gap: 6px; border-radius: 4px; font-size: 12px; }
.connect-button svg { width: 15px; height: 15px; }
.connection-summary { display: none; }
.traffic-strip { min-height: 42px; grid-template-columns: 72px 1fr 1fr; }
.traffic-device { gap: 4px; padding: 5px 6px; font-size: 10px; }
.traffic-device svg { width: 13px; height: 13px; }
.traffic-device span { display: none; }
.traffic-device strong { font-size: 11px; }
.traffic-metric { grid-template-columns: auto 1fr; gap: 1px 4px; padding: 4px 5px; }
.traffic-metric span { gap: 2px; font-size: 9px; }
.traffic-metric span svg { width: 11px; height: 11px; }
.traffic-metric strong { font-size: 11px; }
.traffic-metric small { display: none; }
.traffic-strip.pending { min-height: 42px; padding-right: 7px; }
.traffic-strip.pending .traffic-device { padding: 5px 6px; }
.traffic-strip.pending > strong { font-size: 10px; }
.secondary-actions { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 6px; min-height: 34px; }
.advanced-section, .log-section { min-width: 0; height: 34px; margin: 0; border-radius: 6px; }
.advanced-toggle, .log-toggle { min-height: 32px; height: 32px; padding: 0 8px; border-radius: 5px; font-size: 11px; }
.advanced-toggle span, .log-toggle span { min-width: 0; gap: 5px; white-space: nowrap; }
.advanced-toggle span svg, .log-toggle span svg { width: 14px; height: 14px; flex: 0 0 auto; }
.advanced-toggle > svg, .log-toggle > svg { width: 14px; height: 14px; flex: 0 0 auto; }
.log-toggle small { min-width: 17px; height: 16px; padding: 0 4px; font-size: 9px; }
.advanced-section:has(.advanced-fields), .log-section:has(.log-content) { position: fixed; z-index: 20; inset: 42px 0 0; width: 100%; height: auto; padding: 8px 10px; overflow: hidden; background: var(--canvas); border: 0; border-radius: 0; }
.advanced-section:has(.advanced-fields) .advanced-toggle, .log-section:has(.log-content) .log-toggle { min-height: 34px; height: 34px; padding: 0 9px; background: rgba(255, 255, 255, .72); border: 1px solid var(--line); border-radius: 6px; }
.advanced-fields { grid-template-columns: 1fr; gap: 6px; padding: 8px 4px 0; }
.advanced-fields label { gap: 4px; font-size: 11px; }
.advanced-fields input { height: 30px; padding: 0 7px; border-radius: 4px; font-size: 12px; }
.log-content { padding: 8px 0 0; }
.log-toolbar { min-height: 32px; padding: 0 4px; gap: 8px; }
.log-toolbar > span { font-size: 11px; }
.export-log { min-height: 28px; gap: 4px; padding: 0 7px; font-size: 11px; }
.export-log svg { width: 13px; height: 13px; }
.empty-log { padding: 16px 10px; border-radius: 5px; font-size: 11px; line-height: 1.4; }
.log-list { max-height: none; overflow: hidden; border-radius: 4px; }
.log-list li { min-height: 26px; grid-template-columns: 56px minmax(0, 1fr); gap: 5px; padding: 4px 6px; font-size: 10px; }
.log-list li:nth-child(n + 5) { display: none; }
.log-list li span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.coming-soon { min-height: 0; height: calc(100% - 41px); padding: 16px; border-radius: 6px; }
.coming-soon .coming-icon { width: 34px; height: 34px; margin-bottom: 8px; border-radius: 7px; }
.coming-soon .coming-icon svg { width: 20px; height: 20px; }
.coming-soon h1 { margin-bottom: 6px; font-size: 18px; }
.coming-soon p:last-child { font-size: 12px; }
.notice { right: 8px; bottom: 8px; max-width: calc(100vw - 16px); padding: 9px 10px; border-radius: 5px; font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; transition-duration: .01ms !important; }
}