Initial Super N2N desktop application
Build Windows Portable Package / portable (push) Has been cancelled
Build Windows Portable Package / portable (push) Has been cancelled
This commit is contained in:
@@ -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
@@ -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
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user