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
+8
View File
@@ -0,0 +1,8 @@
* text=auto
*.sh text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.ps1 text eol=crlf
*.cmd text eol=crlf
*.bat text eol=crlf
+30
View File
@@ -0,0 +1,30 @@
name: Build Windows Portable Package
on:
push:
workflow_dispatch:
jobs:
portable:
# Configure the Gitea runner with the `windows:host` label.
runs-on: windows
defaults:
run:
shell: powershell
steps:
- name: Checkout source and pinned n2n dependency
uses: actions/checkout@v4
with:
submodules: true
- name: Build and test
run: .\scripts\build.ps1 -Clean
- name: Upload portable package
uses: actions/upload-artifact@v4
with:
name: super-n2n-${{ github.sha }}
path: |
release/super-n2n-portable.zip
release/super-n2n-portable.zip.sha256
if-no-files-found: error
+37
View File
@@ -0,0 +1,37 @@
# JavaScript dependencies and build artifacts
/node_modules/
/dist/
*.tsbuildinfo
# Rust and Tauri generated output
/src-tauri/target/
/src-tauri/gen/
/src-tauri/.tauri/
# Locally assembled distributable packages
/release/
# Local environment files (keep examples in source control)
.env
.env.*
!.env.example
# Logs and package-manager diagnostics
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor and operating-system metadata
.DS_Store
Thumbs.db
Desktop.ini
$RECYCLE.BIN/
.idea/
.vs/
.vscode/
*.suo
*.user
*.userossc
*.sln.docstates
+4
View File
@@ -0,0 +1,4 @@
[submodule "n2n"]
path = third_party/n2n
url = https://github.com/ntop/n2n.git
ignore = untracked
+67
View File
@@ -0,0 +1,67 @@
# Super N2N
Super N2N is a Tauri 2 desktop control plane. Its pinned n2n dependency is
stored as the `third_party/n2n` Git submodule.
## Reproducible Windows build
Clone the project together with its fixed n2n source revision:
git clone --recurse-submodules https://git.code.cq.cn/chun_qiu/supern2n.git
cd supern2n
The only supported portable-package build entry point is:
npm run build:portable
Or call the same script directly when a clean output is required:
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/build.ps1 -Clean
The script initializes submodules, compiles n2n, installs frontend dependencies
with `npm ci`, runs Rust tests, creates the Tauri portable package, and writes a
SHA-256 checksum beside it. Outputs are placed in `release/`.
The Windows build host needs Node.js, Rust with the MSVC toolchain, Git for
Windows (Bash), and MinGW-w64 tools providing `gcc`, `windres`, and
`mingw32-make`.
## Frontend development
Install dependencies:
npm install
Start the Vite preview:
npm run dev
The current preview URL is http://127.0.0.1:1420/.
## Native desktop development
Install dependencies, then start the Tauri development window:
npm install
npm run tauri dev
The portable build creates `release/super-n2n-portable.zip`. It contains
`n2n/edge.exe` and `n2n/supernode.exe`; on Windows Super N2N starts them with
the relative paths `./n2n/edge.exe` and `./n2n/supernode.exe`.
## Gitea Actions
`.gitea/workflows/build-windows.yml` runs on every push and uploads the portable
zip plus its checksum as workflow artifacts. Configure a dedicated Gitea runner
with the `windows:host` label and the Windows build prerequisites above.
The native command layer can start and stop edge/supernode processes, query the n2n UDP management API, run ping and TCPing checks, and inspect the local firewall state. Browser preview does not fabricate native process, management, or network-check results.
## Included UI
- Overview of managed services, management API client records, P2P/relay counts, and management request latency
- Visual Edge and Supernode option editors mapped to n2n command flags
- Auto-IP/DHCP client table populated from the n2n management API
- Topology view populated from the currently configured services and returned client records
- Ping, TCPing, and local firewall checks
- Process, management API, and tool event log
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0d1117" />
<title>Super N2N Control</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+2036
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "super-n2n",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1 --port 1420",
"build": "vite build",
"preview": "vite preview --host 127.0.0.1",
"tauri": "tauri",
"tauri:portable:raw": "tauri build --no-bundle",
"tauri:portable": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/build.ps1",
"build:portable": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/build.ps1",
"package:portable": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/build.ps1"
},
"dependencies": {
"@tauri-apps/api": "^2.8.0",
"lucide-react": "^0.468.0",
"react": "^19.1.1",
"react-dom": "^19.1.1"
},
"devDependencies": {
"@tauri-apps/cli": "^2.8.0",
"@vitejs/plugin-react": "^4.3.4",
"vite": "^6.0.5"
}
}
+11
View File
@@ -0,0 +1,11 @@
Super N2N Portable
Run super-n2n.exe to open the desktop control plane.
The directory includes n2n\edge.exe and n2n\supernode.exe. The application starts
them as .\n2n\edge.exe and .\n2n\supernode.exe, so no PATH configuration is required.
Windows WebView2 is required. It is included by default on current Windows 10 and
Windows 11 installations.
The bundled n2n programs are distributed under n2n\LICENSE-n2n.txt.
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
n2n_root="${1:?n2n source path is required}"
make_bin="${2:?MinGW make path is required}"
clean="${3:-0}"
cd "$n2n_root"
if [[ ! -f config.mak || ! -f include/config.h ]]; then
./scripts/hack_fakeautoconf.sh
fi
if [[ "$clean" == "1" ]]; then
"$make_bin" clean
fi
"$make_bin" edge supernode
+150
View File
@@ -0,0 +1,150 @@
[CmdletBinding()]
param(
[switch]$Clean,
[switch]$SkipTests
)
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
$projectRoot = Split-Path -Parent $PSScriptRoot
$n2nRoot = Join-Path $projectRoot 'third_party\n2n'
$cargoManifest = Join-Path $projectRoot 'src-tauri\Cargo.toml'
$packageScript = Join-Path $PSScriptRoot 'package-portable.ps1'
$n2nBuildScript = Join-Path $PSScriptRoot 'build-n2n.sh'
$archivePath = Join-Path $projectRoot 'release\super-n2n-portable.zip'
$checksumPath = $archivePath + '.sha256'
function Get-RequiredCommand {
param(
[string[]]$Names,
[string]$Purpose
)
foreach ($name in $Names) {
$command = Get-Command $name -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
if ($command) {
return $command.Source
}
}
throw "Missing required tool for $Purpose. Expected one of: $($Names -join ', ')."
}
function Get-GitBash {
param([string]$GitExecutable)
$gitRoot = Split-Path -Parent (Split-Path -Parent $GitExecutable)
$candidatePaths = @(
(Join-Path $gitRoot 'bin\bash.exe')
)
$candidatePaths += Get-Command bash.exe -CommandType Application -All -ErrorAction SilentlyContinue | ForEach-Object Source
foreach ($bashPath in $candidatePaths | Select-Object -Unique) {
if (-not (Test-Path -LiteralPath $bashPath)) {
continue
}
$bashDirectory = Split-Path -Parent $bashPath
$gitUnixBinCandidates = @(
$bashDirectory,
(Join-Path (Split-Path -Parent $bashDirectory) 'usr\bin')
)
foreach ($gitUnixBin in $gitUnixBinCandidates) {
if ((Test-Path -LiteralPath (Join-Path $gitUnixBin 'sh.exe')) -and
(Test-Path -LiteralPath (Join-Path $gitUnixBin 'cygpath.exe'))) {
return [pscustomobject]@{
Bash = $bashPath
UnixBin = $gitUnixBin
}
}
}
}
throw 'Git Bash was not found. Install Git for Windows.'
}
function Invoke-Checked {
param(
[string]$Executable,
[string[]]$Arguments
)
& $Executable @Arguments
if ($LASTEXITCODE -ne 0) {
throw "Command failed with exit code ${LASTEXITCODE}: $Executable $($Arguments -join ' ')"
}
}
if ($env:OS -ne 'Windows_NT') {
throw 'This script builds the Windows portable package and must run on Windows.'
}
$git = Get-RequiredCommand -Names @('git.exe', 'git') -Purpose 'source checkout'
$npm = Get-RequiredCommand -Names @('npm.cmd', 'npm') -Purpose 'frontend dependency installation'
$cargo = Get-RequiredCommand -Names @('cargo.exe', 'cargo') -Purpose 'Tauri compilation'
$gitBash = Get-GitBash -GitExecutable $git
$make = Get-RequiredCommand -Names @('mingw32-make.exe', 'mingw32-make') -Purpose 'n2n compilation'
$null = Get-RequiredCommand -Names @('gcc.exe', 'gcc') -Purpose 'n2n compilation'
$null = Get-RequiredCommand -Names @('windres.exe', 'windres') -Purpose 'n2n Windows resource compilation'
# Make GNU utilities (rm, cp, gzip) available to n2n's Makefile on Windows.
$env:Path = "$($gitBash.UnixBin);$env:Path"
$env:SHELL = Join-Path $gitBash.UnixBin 'sh.exe'
Push-Location $projectRoot
try {
Invoke-Checked $git @('submodule', 'sync')
Invoke-Checked $git @('submodule', 'update', '--init')
if (-not (Test-Path -LiteralPath $n2nRoot)) {
throw "The n2n submodule was not initialized: $n2nRoot"
}
$cygpath = Join-Path $gitBash.UnixBin 'cygpath.exe'
$n2nRootUnix = (& $cygpath -u $n2nRoot).Trim()
$makeUnix = (& $cygpath -u $make).Trim()
$n2nBuildScriptUnix = (& $cygpath -u $n2nBuildScript).Trim()
Invoke-Checked $gitBash.Bash @($n2nBuildScriptUnix, $n2nRootUnix, $makeUnix, $(if ($Clean) { '1' } else { '0' }))
foreach ($binary in @('edge.exe', 'supernode.exe')) {
if (-not (Test-Path -LiteralPath (Join-Path $n2nRoot $binary))) {
throw "n2n build did not produce $binary."
}
}
Invoke-Checked $npm @('ci')
if (-not $SkipTests) {
Invoke-Checked $cargo @('test', '--manifest-path', $cargoManifest)
}
& $packageScript -Clean:$Clean
if ($LASTEXITCODE -ne 0) {
throw "Portable package script failed with exit code $LASTEXITCODE."
}
if (-not (Test-Path -LiteralPath $archivePath)) {
throw "Portable archive was not created: $archivePath"
}
$stream = [System.IO.File]::OpenRead($archivePath)
try {
$sha256 = [System.Security.Cryptography.SHA256]::Create()
try {
$hashBytes = $sha256.ComputeHash($stream)
$hash = -join ($hashBytes | ForEach-Object { $_.ToString('x2') })
}
finally {
$sha256.Dispose()
}
}
finally {
$stream.Dispose()
}
"$hash *$(Split-Path -Leaf $archivePath)" | Set-Content -LiteralPath $checksumPath -Encoding ascii
Write-Output "Build completed: $archivePath"
Write-Output "SHA-256 checksum: $checksumPath"
}
finally {
Pop-Location
}
+102
View File
@@ -0,0 +1,102 @@
param(
[switch]$Clean
)
$ErrorActionPreference = 'Stop'
$projectRoot = Split-Path -Parent $PSScriptRoot
$n2nRoot = Join-Path $projectRoot 'third_party\n2n'
$releaseRoot = Join-Path $projectRoot 'release'
$portableRoot = Join-Path $projectRoot 'release\super-n2n-portable'
$stagingRoot = Join-Path $releaseRoot 'super-n2n-portable-staging'
$archivePath = Join-Path $releaseRoot 'super-n2n-portable.zip'
$releaseRootFull = [System.IO.Path]::GetFullPath($releaseRoot)
$portableRootFull = [System.IO.Path]::GetFullPath($portableRoot)
$stagingRootFull = [System.IO.Path]::GetFullPath($stagingRoot)
$archivePathFull = [System.IO.Path]::GetFullPath($archivePath)
if ((Split-Path -Parent $portableRootFull) -ne $releaseRootFull -or (Split-Path -Leaf $portableRootFull) -ne 'super-n2n-portable') {
throw "Refusing to replace an unexpected portable output directory: $portableRootFull"
}
if ((Split-Path -Parent $stagingRootFull) -ne $releaseRootFull -or (Split-Path -Leaf $stagingRootFull) -ne 'super-n2n-portable-staging') {
throw "Refusing to replace an unexpected staging directory: $stagingRootFull"
}
if ((Split-Path -Parent $archivePathFull) -ne $releaseRootFull -or (Split-Path -Leaf $archivePathFull) -ne 'super-n2n-portable.zip') {
throw "Refusing to replace an unexpected portable archive: $archivePathFull"
}
if ($Clean) {
if (Test-Path -LiteralPath $portableRoot) {
Remove-Item -LiteralPath $portableRoot -Recurse -Force
}
if (Test-Path -LiteralPath $archivePath) {
Remove-Item -LiteralPath $archivePath -Force
}
}
Push-Location $projectRoot
try {
& npm.cmd run tauri:portable:raw
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
$files = @{
'super-n2n.exe' = Join-Path $projectRoot 'src-tauri\target\release\super-n2n.exe'
'n2n\edge.exe' = Join-Path $n2nRoot 'edge.exe'
'n2n\supernode.exe' = Join-Path $n2nRoot 'supernode.exe'
'n2n\LICENSE-n2n.txt' = Join-Path $n2nRoot 'LICENSE'
'README.txt' = Join-Path $projectRoot 'scripts\PORTABLE-README.txt'
}
foreach ($entry in $files.GetEnumerator()) {
if (-not (Test-Path -LiteralPath $entry.Value)) {
throw "Required portable artifact is missing: $($entry.Value)"
}
}
if (Test-Path -LiteralPath $stagingRoot) {
Remove-Item -LiteralPath $stagingRoot -Recurse -Force
}
New-Item -ItemType Directory -Force -Path $stagingRoot | Out-Null
foreach ($entry in $files.GetEnumerator()) {
$destination = Join-Path $stagingRoot $entry.Key
$destinationDirectory = Split-Path -Parent $destination
New-Item -ItemType Directory -Force -Path $destinationDirectory | Out-Null
Copy-Item -LiteralPath $entry.Value -Destination $destination -Force
}
Compress-Archive -Path (Join-Path $stagingRoot '*') -DestinationPath $archivePath -CompressionLevel Optimal -Force
New-Item -ItemType Directory -Force -Path $portableRoot | Out-Null
foreach ($legacyName in @('edge.exe', 'supernode.exe', 'LICENSE-n2n.txt')) {
$legacyPath = Join-Path $portableRoot $legacyName
if (Test-Path -LiteralPath $legacyPath) {
try {
Remove-Item -LiteralPath $legacyPath -Force
}
catch {
Write-Warning "Could not remove legacy artifact $legacyPath because it is in use."
}
}
}
foreach ($entry in $files.GetEnumerator()) {
$destination = Join-Path $portableRoot $entry.Key
$destinationDirectory = Split-Path -Parent $destination
New-Item -ItemType Directory -Force -Path $destinationDirectory | Out-Null
try {
Copy-Item -LiteralPath $entry.Value -Destination $destination -Force
}
catch {
Write-Warning "Could not update $destination because it is in use. The zip archive is current."
}
}
Write-Output "Portable package created: $archivePath"
}
finally {
if (Test-Path -LiteralPath $stagingRoot) {
Remove-Item -LiteralPath $stagingRoot -Recurse -Force
}
Pop-Location
}
+7
View File
@@ -0,0 +1,7 @@
# Ignore generated files that would otherwise restart `tauri dev`.
/target/
/gen/
/.tauri/
# Local native build diagnostics.
*.log
+4513
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "super-n2n"
version = "0.1.0"
description = "N2N desktop control plane"
authors = ["Super N2N"]
edition = "2021"
[lib]
name = "super_n2n_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_NetworkManagement_IpHelper", "Win32_NetworkManagement_Ndis"] }
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="88" fill="#101716"/>
<path d="M128 178h118l48 78h90" fill="none" stroke="#56e390" stroke-linecap="round" stroke-linejoin="round" stroke-width="34"/>
<path d="M128 334h118l48-78h90" fill="none" stroke="#56e390" stroke-linecap="round" stroke-linejoin="round" stroke-width="34"/>
<circle cx="128" cy="256" r="45" fill="#f2fbf5"/>
<circle cx="384" cy="256" r="45" fill="#f2fbf5"/>
<circle cx="256" cy="256" r="31" fill="#f7c75d"/>
</svg>

After

Width:  |  Height:  |  Size: 544 B

+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default permissions for the Super N2N desktop window",
"windows": ["main"],
"permissions": ["core:default"]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 633 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 529 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 817 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 957 B

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 644 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

+505
View File
@@ -0,0 +1,505 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::{
collections::HashMap,
fs,
net::{SocketAddr, TcpStream, UdpSocket},
path::PathBuf,
process::{Child, Command, Stdio},
sync::Mutex,
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use tauri::State;
struct AppState {
processes: Mutex<HashMap<String, Child>>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct StartRequest {
service: String,
binary_path: String,
args: Vec<String>,
}
#[derive(Debug, Serialize)]
struct ServiceStatus {
service: String,
running: bool,
pid: Option<u32>,
started_at: u64,
message: String,
}
#[derive(Debug, Deserialize)]
struct ManagementRequest {
host: String,
port: u16,
command: String,
password: Option<String>,
}
#[derive(Debug, Serialize)]
struct ToolResult {
ok: bool,
summary: String,
output: String,
latency_ms: Option<u128>,
}
#[derive(Debug, Deserialize)]
struct PingRequest {
host: String,
count: u8,
}
#[derive(Debug, Deserialize)]
struct TcpingRequest {
host: String,
port: u16,
timeout_ms: u64,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct TapTrafficRequest {
tap_device: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct TapTraffic {
interface_name: String,
received_bytes: u64,
sent_bytes: u64,
}
fn now_epoch() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn empty_status(service: &str, message: &str) -> ServiceStatus {
ServiceStatus {
service: service.to_string(),
running: false,
pid: None,
started_at: 0,
message: message.to_string(),
}
}
fn relative_binary_path(service: &str) -> Result<&'static str, String> {
match service {
"edge" => Ok("./n2n/edge.exe"),
"supernode" => Ok("./n2n/supernode.exe"),
_ => Err(format!("unsupported service: {service}")),
}
}
fn app_directory() -> Result<std::path::PathBuf, String> {
std::env::current_exe()
.map_err(|error| format!("unable to locate application executable: {error}"))?
.parent()
.map(|path| path.to_path_buf())
.ok_or_else(|| "application executable has no parent directory".to_string())
}
fn downloads_directory() -> PathBuf {
#[cfg(target_os = "windows")]
if let Some(profile) = std::env::var_os("USERPROFILE") {
return PathBuf::from(profile).join("Downloads");
}
#[cfg(not(target_os = "windows"))]
if let Some(home) = std::env::var_os("HOME") {
return PathBuf::from(home).join("Downloads");
}
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
#[cfg(target_os = "windows")]
fn windows_tap_traffic(tap_device: &str) -> Result<TapTraffic, String> {
use std::{ptr, slice};
use windows_sys::Win32::{
Foundation::NO_ERROR,
NetworkManagement::IpHelper::{FreeMibTable, GetIfTable2, MIB_IF_TABLE2},
};
fn wide_string(value: &[u16]) -> String {
let end = value
.iter()
.position(|unit| *unit == 0)
.unwrap_or(value.len());
String::from_utf16_lossy(&value[..end])
}
fn guid_string(guid: &windows_sys::core::GUID) -> String {
format!(
"{{{:08x}-{:04x}-{:04x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}}}",
guid.data1,
guid.data2,
guid.data3,
guid.data4[0],
guid.data4[1],
guid.data4[2],
guid.data4[3],
guid.data4[4],
guid.data4[5],
guid.data4[6],
guid.data4[7]
)
}
let mut table: *mut MIB_IF_TABLE2 = ptr::null_mut();
let result = unsafe { GetIfTable2(&mut table) };
if result != NO_ERROR || table.is_null() {
return Err(format!("unable to read network adapter table: {result}"));
}
let traffic = unsafe {
let rows = slice::from_raw_parts((*table).Table.as_ptr(), (*table).NumEntries as usize);
rows.iter()
.find_map(|row| {
let alias = wide_string(&row.Alias);
let description = wide_string(&row.Description);
let guid = guid_string(&row.InterfaceGuid);
(alias.eq_ignore_ascii_case(tap_device)
|| description.eq_ignore_ascii_case(tap_device)
|| guid.eq_ignore_ascii_case(tap_device))
.then_some(TapTraffic {
interface_name: alias,
received_bytes: row.InOctets,
sent_bytes: row.OutOctets,
})
})
.ok_or_else(|| format!("TAP adapter not found: {tap_device}"))
};
unsafe { FreeMibTable(table.cast()) };
traffic
}
#[tauri::command]
fn start_service(request: StartRequest, state: State<'_, AppState>) -> Result<ServiceStatus, String> {
let mut processes = state.processes.lock().map_err(|_| "process state is unavailable".to_string())?;
if let Some(existing) = processes.get_mut(&request.service) {
if existing.try_wait().map_err(|e| e.to_string())?.is_none() {
return Ok(ServiceStatus {
service: request.service,
running: true,
pid: Some(existing.id()),
started_at: now_epoch(),
message: "already running".to_string(),
});
}
}
let relative_binary = relative_binary_path(&request.service)?;
if request.binary_path != relative_binary {
return Err(format!("{} must use {}", request.service, relative_binary));
}
let working_directory = app_directory()?;
let binary = working_directory.join(relative_binary);
if !binary.is_file() {
return Err(format!("required n2n binary is missing: {}", binary.display()));
}
let child = Command::new(relative_binary)
.current_dir(&working_directory)
.args(&request.args)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("unable to start {}: {}", binary.display(), e))?;
let pid = child.id();
processes.insert(request.service.clone(), child);
Ok(ServiceStatus {
service: request.service,
running: true,
pid: Some(pid),
started_at: now_epoch(),
message: format!("started {relative_binary}"),
})
}
#[tauri::command]
fn stop_service(service: String, state: State<'_, AppState>) -> Result<ServiceStatus, String> {
let mut processes = state.processes.lock().map_err(|_| "process state is unavailable".to_string())?;
if let Some(mut child) = processes.remove(&service) {
child.kill().map_err(|e| format!("unable to stop {}: {}", service, e))?;
let _ = child.wait();
return Ok(empty_status(&service, "stopped"));
}
Ok(empty_status(&service, "not running"))
}
#[tauri::command]
fn service_status(service: String, state: State<'_, AppState>) -> Result<ServiceStatus, String> {
let mut processes = state.processes.lock().map_err(|_| "process state is unavailable".to_string())?;
if let Some(child) = processes.get_mut(&service) {
if child.try_wait().map_err(|e| e.to_string())?.is_none() {
return Ok(ServiceStatus {
service,
running: true,
pid: Some(child.id()),
started_at: now_epoch(),
message: "running".to_string(),
});
}
processes.remove(&service);
return Ok(empty_status(&service, "exited"));
}
Ok(empty_status(&service, "not running"))
}
#[tauri::command]
fn management_query(request: ManagementRequest) -> Result<Vec<Value>, String> {
let address: SocketAddr = format!("{}:{}", request.host, request.port)
.parse()
.map_err(|e| format!("invalid management address: {}", e))?;
let socket = UdpSocket::bind("0.0.0.0:0").map_err(|e| e.to_string())?;
socket
.set_read_timeout(Some(Duration::from_millis(650)))
.map_err(|e| e.to_string())?;
let tag = now_epoch() % 1000;
let auth = request
.password
.filter(|password| !password.is_empty())
.map(|password| format!(":1:{}", password))
.unwrap_or_default();
let payload = format!("r {}{} {}\n", tag, auth, request.command);
socket
.send_to(payload.as_bytes(), address)
.map_err(|e| e.to_string())?;
let mut packets = Vec::new();
let mut buffer = [0_u8; 4096];
loop {
match socket.recv_from(&mut buffer) {
Ok((size, _)) => {
if let Ok(text) = std::str::from_utf8(&buffer[..size]) {
for line in text.lines() {
if let Ok(value) = serde_json::from_str::<Value>(line) {
let finished = value.get("_type").and_then(Value::as_str) == Some("end");
packets.push(value);
if finished {
return Ok(packets);
}
}
}
}
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock || error.kind() == std::io::ErrorKind::TimedOut => {
break;
}
Err(error) => return Err(error.to_string()),
}
}
if packets.is_empty() {
Err(format!("management API timed out: {address}"))
} else {
Ok(packets)
}
}
#[tauri::command]
fn run_ping(request: PingRequest) -> Result<ToolResult, String> {
let count = request.count.clamp(1, 8).to_string();
let started = Instant::now();
#[cfg(target_os = "windows")]
let output = Command::new("ping")
.args(["-n", &count, &request.host])
.output()
.map_err(|e| e.to_string())?;
#[cfg(not(target_os = "windows"))]
let output = Command::new("ping")
.args(["-c", &count, &request.host])
.output()
.map_err(|e| e.to_string())?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
Ok(ToolResult {
ok: output.status.success(),
summary: if output.status.success() { "目标可达".to_string() } else { "目标不可达".to_string() },
output: if stdout.is_empty() { stderr } else { stdout },
latency_ms: Some(started.elapsed().as_millis()),
})
}
#[tauri::command]
fn run_tcping(request: TcpingRequest) -> Result<ToolResult, String> {
let address: SocketAddr = format!("{}:{}", request.host, request.port)
.parse()
.map_err(|e| format!("invalid TCP address: {}", e))?;
let started = Instant::now();
match TcpStream::connect_timeout(&address, Duration::from_millis(request.timeout_ms.max(100))) {
Ok(_) => Ok(ToolResult {
ok: true,
summary: "TCP 端口可连接".to_string(),
output: format!("connected to {} in {} ms", address, started.elapsed().as_millis()),
latency_ms: Some(started.elapsed().as_millis()),
}),
Err(error) => Ok(ToolResult {
ok: false,
summary: "TCP 端口不可连接".to_string(),
output: error.to_string(),
latency_ms: Some(started.elapsed().as_millis()),
}),
}
}
#[tauri::command]
fn tap_traffic(request: TapTrafficRequest) -> Result<TapTraffic, String> {
let tap_device = request.tap_device.trim();
if tap_device.is_empty() {
return Err("TAP device name is empty".to_string());
}
if tap_device.contains(['/', '\\']) || tap_device == "." || tap_device == ".." {
return Err("invalid TAP device name".to_string());
}
#[cfg(target_os = "windows")]
{
return windows_tap_traffic(tap_device);
}
#[cfg(target_os = "linux")]
{
let path = std::path::Path::new("/sys/class/net").join(tap_device).join("statistics");
let received_bytes = fs::read_to_string(path.join("rx_bytes"))
.map_err(|error| format!("unable to read TAP adapter {tap_device}: {error}"))?
.trim()
.parse::<u64>()
.map_err(|error| format!("invalid TAP receive counter: {error}"))?;
let sent_bytes = fs::read_to_string(path.join("tx_bytes"))
.map_err(|error| format!("unable to read TAP adapter {tap_device}: {error}"))?
.trim()
.parse::<u64>()
.map_err(|error| format!("invalid TAP transmit counter: {error}"))?;
return Ok(TapTraffic {
interface_name: tap_device.to_string(),
received_bytes,
sent_bytes,
});
}
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
Err("TAP traffic monitoring is not supported on this operating system".to_string())
}
#[tauri::command]
fn firewall_status() -> Result<ToolResult, String> {
#[cfg(target_os = "windows")]
let output = Command::new("netsh")
.args(["advfirewall", "show", "allprofiles", "state"])
.output()
.map_err(|e| e.to_string())?;
#[cfg(target_os = "linux")]
let output = Command::new("sh")
.args(["-c", "ufw status verbose 2>/dev/null || firewall-cmd --state 2>/dev/null || true"])
.output()
.map_err(|e| e.to_string())?;
#[cfg(target_os = "macos")]
let output = Command::new("/usr/libexec/ApplicationFirewall/socketfilterfw")
.args(["--getglobalstate"])
.output()
.map_err(|e| e.to_string())?;
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
let output = Command::new("true").output().map_err(|e| e.to_string())?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let normalized = stdout
.to_ascii_lowercase()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
let enabled = normalized.contains("state on")
|| normalized.contains("status: active")
|| normalized.contains("firewall is enabled")
|| normalized.contains("state = 1")
|| normalized.split_whitespace().any(|token| token == "running");
Ok(ToolResult {
ok: output.status.success(),
summary: if enabled { "本地防火墙已开启".to_string() } else { "未检测到防火墙开启".to_string() },
output: stdout,
latency_ms: None,
})
}
#[tauri::command]
fn export_log(contents: String) -> Result<String, String> {
let directory = downloads_directory();
fs::create_dir_all(&directory)
.map_err(|error| format!("unable to create downloads directory: {error}"))?;
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let file = directory.join(format!("super-n2n-{millis}.log"));
fs::write(&file, contents).map_err(|error| format!("unable to export log: {error}"))?;
Ok(file.display().to_string())
}
#[cfg(test)]
mod tests {
use super::{relative_binary_path, StartRequest};
#[cfg(target_os = "windows")]
#[test]
fn windows_tap_traffic_uses_the_adapter_table() {
assert!(super::windows_tap_traffic("__super_n2n_missing_tap__").is_err());
}
#[test]
fn start_request_accepts_an_argument_vector_from_tauri() {
let request: StartRequest = serde_json::from_str(
r#"{
"service": "edge",
"binaryPath": "./n2n/edge.exe",
"args": ["-c", "test-community", "-l", "127.0.0.1:7777"]
}"#,
)
.expect("a Tauri start request with an argument array should deserialize");
assert_eq!(request.binary_path, "./n2n/edge.exe");
assert_eq!(
request.args,
["-c", "test-community", "-l", "127.0.0.1:7777"]
);
}
#[test]
fn services_have_fixed_relative_binary_paths() {
assert_eq!(relative_binary_path("edge").unwrap(), "./n2n/edge.exe");
assert_eq!(
relative_binary_path("supernode").unwrap(),
"./n2n/supernode.exe"
);
}
}
pub fn run() {
tauri::Builder::default()
.manage(AppState {
processes: Mutex::new(HashMap::new()),
})
.invoke_handler(tauri::generate_handler![
start_service,
stop_service,
service_status,
management_query,
run_ping,
run_tcping,
tap_traffic,
firewall_status,
export_log
])
.run(tauri::generate_context!())
.expect("error while running Super N2N");
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
super_n2n_lib::run()
}
+34
View File
@@ -0,0 +1,34 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Super N2N",
"version": "0.1.0",
"identifier": "com.supern2n.control",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://127.0.0.1:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"label": "main",
"title": "Super N2N Control",
"width": 350,
"height": 380,
"minWidth": 350,
"minHeight": 380,
"maxWidth": 350,
"maxHeight": 380,
"resizable": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": false,
"targets": "all"
}
}
+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; }
}
Vendored Submodule
+1
Submodule third_party/n2n added at 4831375d6e
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
clearScreen: false,
server: {
strictPort: true,
},
})