初版功能完成
ci / Go checks (ubuntu-latest) (push) Has been cancelled
ci / Go checks (windows-latest) (push) Has been cancelled

This commit is contained in:
qsc
2026-08-29 13:12:17 +08:00
commit 142e5dc7d6
217 changed files with 21313 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
// Package engineerui embeds the production Wails frontend.
package engineerui
import "embed"
//go:embed all:dist
var Assets embed.FS
+49
View File
@@ -0,0 +1,49 @@
package engineerui
import (
"io"
"net/http"
"net/http/httptest"
"regexp"
"testing"
wailsassetserver "github.com/wailsapp/wails/v2/pkg/assetserver"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
)
func TestEmbeddedProductionAssetsAreServedByWails(t *testing.T) {
handler, err := wailsassetserver.NewAssetHandler(assetserver.Options{Assets: Assets}, nil)
if err != nil {
t.Fatalf("create Wails asset handler: %v", err)
}
index := getEmbeddedAsset(t, handler, "/", "text/html")
references := regexp.MustCompile(`(?:src|href)="(/assets/[^"]+)"`).FindAllStringSubmatch(string(index), -1)
if len(references) < 2 {
t.Fatalf("expected JavaScript and CSS references in index.html, got %q", index)
}
for _, reference := range references {
getEmbeddedAsset(t, handler, reference[1], "")
}
}
func getEmbeddedAsset(t *testing.T, handler http.Handler, path string, expectedContentType string) []byte {
t.Helper()
request := httptest.NewRequest(http.MethodGet, "http://wails.localhost"+path, nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("GET %s returned %d: %s", path, response.Code, response.Body.String())
}
if expectedContentType != "" && response.Header().Get("Content-Type") != expectedContentType+"; charset=utf-8" {
t.Fatalf("GET %s returned Content-Type %q", path, response.Header().Get("Content-Type"))
}
body, err := io.ReadAll(response.Body)
if err != nil {
t.Fatalf("read GET %s response: %v", path, err)
}
if len(body) == 0 {
t.Fatalf("GET %s returned an empty body", path)
}
return body
}
+14
View File
@@ -0,0 +1,14 @@
<!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="#f7f8fc" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%23394ff0'/%3E%3Cpath d='M9 21V11h4v7h10v3z' fill='white'/%3E%3C/svg%3E" />
<title>RemLink Engineer</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+188
View File
@@ -0,0 +1,188 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import Icon from './Icon.vue'
import { checkCIDRs, createSession, disconnectSession, getState, saveSiteCIDRs } from './api'
import type { EngineerState, SiteSummary } from './types'
import { errorLabel, levelLabel, statusLabel } from './zh-cn'
const state = ref<EngineerState | null>(null)
const page = ref<'connection' | 'session' | 'logs' | 'settings'>('connection')
const selectedSiteID = ref('')
const cidrs = ref<string[]>([])
const cidrInput = ref('')
const busy = ref(false)
const error = ref('')
const preflightState = ref<'checking' | 'pass' | 'fail'>('checking')
const preflightMessage = ref('正在检查本地路由…')
let timer = 0
let unsubscribe: (() => void) | undefined
const active = computed(() => state.value?.session.status === 'ACTIVE')
const sessionBusy = computed(() => {
const status = state.value?.session.status
return status === 'CREATING' || status === 'PREPARING_SITE' || status === 'READY' || status === 'ACTIVE' || status === 'STOPPING'
})
const selectedSite = computed<SiteSummary | undefined>(() => state.value?.sites.find(site => site.node_id === selectedSiteID.value))
const canConnect = computed(() => !busy.value && !sessionBusy.value && state.value?.serverConnected && state.value.controlConnected && preflightState.value === 'pass' && selectedSite.value?.online && selectedSite.value.remote_subnet_capability && cidrs.value.length > 0)
function formatBytes(value: number) {
if (value < 1024) return `${value} B`
if (value < 1024 ** 2) return `${(value / 1024).toFixed(1)} KB`
return `${(value / 1024 ** 2).toFixed(2)} MB`
}
function formatTime(value?: string) { return value ? new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(new Date(value)) : '—' }
async function addCIDR() {
const value = cidrInput.value.trim()
if (!value || cidrs.value.includes(value)) return
cidrs.value.push(value)
cidrInput.value = ''
if (await runPreflight()) await persistCurrentProfile()
}
async function removeCIDR(value: string) {
cidrs.value = cidrs.value.filter(item => item !== value)
const passed = await runPreflight()
if (passed || cidrs.value.length === 0) await persistCurrentProfile()
}
async function runPreflight() {
if (cidrs.value.length === 0) {
preflightState.value = 'fail'; preflightMessage.value = '请为当前现场添加至少一个网段'
return false
}
preflightState.value = 'checking'; preflightMessage.value = '正在检查本地路由…'
try { await checkCIDRs(Array.from(cidrs.value)); preflightState.value = 'pass'; preflightMessage.value = '通过,无冲突'; return true }
catch (cause) { preflightState.value = 'fail'; preflightMessage.value = errorLabel(cause); return false }
}
async function persistCurrentProfile() {
const siteID = selectedSiteID.value
if (!siteID || !state.value) return
try {
await saveSiteCIDRs(siteID, Array.from(cidrs.value))
if (cidrs.value.length === 0) delete state.value.siteCIDRs[siteID]
else state.value.siteCIDRs[siteID] = Array.from(cidrs.value)
} catch (cause) {
error.value = errorLabel(cause)
}
}
function siteCIDRCount(siteID: string) { return state.value?.siteCIDRs[siteID]?.length ?? 0 }
async function loadSelectedSiteProfile() {
if (sessionBusy.value) return
cidrInput.value = ''
cidrs.value = Array.from(state.value?.siteCIDRs[selectedSiteID.value] ?? [])
await runPreflight()
}
async function refresh() {
state.value = await getState()
if (['CREATING', 'PREPARING_SITE', 'READY', 'ACTIVE', 'STOPPING'].includes(state.value.session.status) && state.value.session.cidrs.length > 0) {
cidrs.value = Array.from(state.value.session.cidrs)
}
if (!selectedSiteID.value) selectedSiteID.value = state.value.sites.find(site => site.online)?.node_id ?? ''
}
async function connect() {
if (!canConnect.value) return
busy.value = true; error.value = ''
try { await createSession(selectedSiteID.value, cidrs.value); await refresh() } catch (cause) { error.value = errorLabel(cause) } finally { busy.value = false }
}
async function disconnect() {
busy.value = true; error.value = ''
try { await disconnectSession(); await refresh() } catch (cause) { error.value = errorLabel(cause) } finally { busy.value = false }
}
onMounted(async () => {
try {
await refresh()
await runPreflight()
const safeRefresh = () => { void refresh().catch(cause => { error.value = errorLabel(cause) }) }
timer = window.setInterval(safeRefresh, 2000)
unsubscribe = window.runtime?.EventsOn('remlink:state', safeRefresh)
} catch (cause) {
error.value = errorLabel(cause)
}
})
onBeforeUnmount(() => { clearInterval(timer); unsubscribe?.() })
watch(selectedSiteID, () => { void loadSelectedSiteProfile() })
</script>
<template>
<div class="app-shell" v-if="state">
<aside class="sidebar">
<div class="brand">RemLink<span>Engineer</span></div>
<nav aria-label="主导航">
<button class="nav-item" :class="{selected:page==='connection'}" @click="page='connection'"><Icon name="link"/>连接</button>
<button class="nav-item" :class="{selected:page==='session'}" @click="page='session'"><Icon name="session"/>会话</button>
<button class="nav-item" :class="{selected:page==='logs'}" @click="page='logs'"><Icon name="logs"/>日志</button>
<button class="nav-item" :class="{selected:page==='settings'}" @click="page='settings'"><Icon name="settings"/>设置</button>
</nav>
<div class="sidebar-foot"><span class="version">v{{ state.version }}</span><span>IPv4 · netstack</span></div>
</aside>
<main>
<header class="status-strip">
<div><i :class="state.serverConnected ? 'healthy' : 'offline'"></i>服务器 <strong>{{ state.serverConnected ? '已连接' : '未连接' }}</strong></div>
<div><i class="healthy"></i>Overlay <strong>{{ state.overlayIP || '配置中' }}</strong></div>
<div><i :class="state.controlConnected ? 'healthy' : 'offline'"></i>Control <strong>{{ state.controlConnected ? '在线' : '离线' }}</strong></div>
</header>
<div class="workspace">
<section v-show="page==='connection'" class="connection-path" aria-label="当前连接路径">
<div class="endpoint"><span class="endpoint-icon"><Icon name="monitor"/></span><div><b>Engineer</b><small>{{ state.overlayIP }}</small></div></div>
<div class="path-line"><span></span><i></i><i></i><i></i><span></span></div>
<div class="endpoint site"><span class="endpoint-icon"><Icon name="server"/></span><div><b>{{ selectedSite?.name || '选择现场' }}</b><small>{{ selectedSite?.overlay_ip || '—' }}</small></div></div>
</section>
<div v-show="page==='connection'" class="setup-grid">
<section class="panel sites-panel">
<div class="panel-heading"><h2>现场节点</h2><span>{{ state.sites.filter(site => site.online).length }} 个在线</span></div>
<div class="table-head"><span>站点名称</span><span>状态</span><span>Overlay IP</span><span>Remote Subnet</span><span>LastSeen</span></div>
<button v-for="site in state.sites" :key="site.node_id" class="site-row" :class="{ selected: selectedSiteID === site.node_id, disabled: sessionBusy && selectedSiteID !== site.node_id }" :disabled="sessionBusy && selectedSiteID !== site.node_id" @click="selectedSiteID = site.node_id">
<span><i :class="site.online ? 'healthy' : 'offline'"></i>{{ site.name }}</span>
<span :class="site.online ? 'good-text' : 'muted'">{{ site.online ? '在线' : '离线' }}</span>
<span>{{ site.overlay_ip || '—' }}</span>
<span :class="site.remote_subnet_capability ? 'good-text' : 'muted'">{{ site.remote_subnet_capability ? `可用 · 已存 ${siteCIDRCount(site.node_id)}` : '不可用' }}</span>
<span>{{ formatTime(site.last_seen) }}</span>
</button>
<button class="primary wide" :disabled="!canConnect" @click="connect">{{ busy ? '正在建立' : '连接现场' }}</button>
</section>
<section class="panel cidr-panel">
<div class="panel-heading"><div><h2>远程网段</h2><small>{{ selectedSite?.name ? `当前现场:${selectedSite.name}` : '请先选择现场' }}</small></div><button class="text-action" @click="addCIDR"><Icon name="plus"/>添加网段</button></div>
<p class="profile-hint">每个现场独立保存切换现场时自动加载建立会话时只发送当前现场的网段</p>
<label for="cidr">Remote CIDR</label>
<form @submit.prevent="addCIDR"><input id="cidr" v-model="cidrInput" placeholder="例如 192.168.13.0/24" :disabled="sessionBusy"/><button class="secondary" :disabled="sessionBusy || !cidrInput.trim()">添加</button></form>
<div class="cidr-list">
<div v-for="cidr in cidrs" :key="cidr"><span class="grip">⠿</span><code>{{ cidr }}</code><button :disabled="sessionBusy" @click="removeCIDR(cidr)" :aria-label="`删除 ${cidr}`"><Icon name="close"/></button></div>
<p v-if="!cidrs.length" class="cidr-empty">当前现场尚未配置远程网段</p>
</div>
<div class="preflight" :class="preflightState"><Icon name="check"/><span>本地冲突预检<strong>{{ preflightMessage }}</strong></span><button :disabled="sessionBusy || preflightState==='checking'" @click="runPreflight">再次检查</button></div>
</section>
</div>
<p v-if="error" class="error-banner">{{ error }}</p>
<section v-show="page==='connection'||page==='session'" class="session-panel">
<div class="session-copy"><h2>会话详情</h2><dl><dt>现场端</dt><dd>{{ state.session.siteName || '—' }}</dd><dt>远程网段</dt><dd>{{ state.session.cidrs.join(', ') || '—' }}</dd><dt>会话 ID</dt><dd class="mono">{{ state.session.id || '—' }}</dd><dt>状态</dt><dd class="good-text">{{ statusLabel(state.session.status) }}</dd><dt>结束原因</dt><dd>{{ state.session.reason ? errorLabel(state.session.reason) : '—' }}</dd><dt>开始时间</dt><dd>{{ formatTime(state.session.startedAt) }}</dd></dl></div>
<div class="metric"><span>上传</span><strong>{{ formatBytes(state.session.uploadBytes) }}</strong><small>{{ state.session.uploadPackets.toLocaleString() }} packets</small></div>
<div class="metric"><span>下载</span><strong>{{ formatBytes(state.session.downloadBytes) }}</strong><small>{{ state.session.downloadPackets.toLocaleString() }} packets</small></div>
<div class="metric"><span>时延</span><strong>{{ state.session.latencyMS || '—' }}</strong><small>ms</small></div>
<button class="danger" :disabled="!active || busy" @click="disconnect">断开会话</button>
</section>
<section v-show="page==='connection'||page==='logs'" class="logs-panel">
<div class="panel-heading"><h2>实时日志</h2><span>自动滚动</span></div>
<div class="logs"><div v-for="(log, index) in state.logs" :key="index"><time>{{ formatTime(log.time) }}</time><b :class="log.level.toLowerCase()">{{ levelLabel(log.level) }}</b><span>{{ log.message }}</span></div></div>
</section>
<section v-if="page==='settings'" class="panel settings-panel">
<div class="panel-heading"><h2>运行配置</h2><span>由本机配置与 Server Bootstrap 管理</span></div>
<dl><dt>Server 公网地址</dt><dd class="mono">{{ state.serverURL }}</dd><dt>客户端版本</dt><dd>{{ state.version }}</dd><dt>数据面</dt><dd>IPv4 · Wintun · wireguard-go</dd><dt>现场网关</dt><dd>gVisor netstack</dd></dl>
<p>网络地址密钥与 Node Token 不在界面中复制或明文保存Overlay 变更由 Server 触发重新 Bootstrap</p>
</section>
</div>
</main>
</div>
<div v-else class="loading">{{ error || '正在启动 RemLink Engineer…' }}</div>
</template>
<style>
.cidr-panel .panel-heading>div{display:grid;gap:3px}
.cidr-panel .panel-heading small{color:var(--muted);font-size:10px}
.sites-panel .table-head,.sites-panel .site-row{grid-template-columns:1.2fr .55fr .8fr 1.05fr .7fr;column-gap:8px}
.profile-hint{margin:11px 18px 0;color:#68738a;font-size:11px;line-height:1.5}
.cidr-empty{margin:3px 0;padding:14px;border:1px dashed #cbd2e0;border-radius:6px;color:var(--muted);font-size:11px;text-align:center}
</style>
+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
defineProps<{ name: 'link' | 'session' | 'logs' | 'settings' | 'server' | 'monitor' | 'plus' | 'close' | 'check' }>()
</script>
<template>
<svg viewBox="0 0 24 24" aria-hidden="true">
<g fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<template v-if="name === 'link'"><circle cx="12" cy="5" r="2.5"/><circle cx="5" cy="18" r="2.5"/><circle cx="19" cy="18" r="2.5"/><path d="M12 7.5v4M7.2 16.4l4.8-4.9 4.8 4.9"/></template>
<template v-else-if="name === 'session'"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/></template>
<template v-else-if="name === 'logs'"><path d="M6 3.5h9l3 3V20.5H6z"/><path d="M15 3.5v3h3M9 11h6M9 15h6"/></template>
<template v-else-if="name === 'settings'"><circle cx="12" cy="12" r="3"/><path d="M12 2.8v2M12 19.2v2M2.8 12h2M19.2 12h2M5.5 5.5l1.4 1.4M17.1 17.1l1.4 1.4M18.5 5.5l-1.4 1.4M6.9 17.1l-1.4 1.4"/></template>
<template v-else-if="name === 'server'"><rect x="5" y="3.5" width="14" height="7" rx="2"/><rect x="5" y="13.5" width="14" height="7" rx="2"/><path d="M9 7h.01M9 17h.01M13 7h3M13 17h3"/></template>
<template v-else-if="name === 'monitor'"><rect x="3.5" y="4" width="17" height="12" rx="2"/><path d="M9 20h6M12 16v4"/></template>
<template v-else-if="name === 'plus'"><path d="M12 5v14M5 12h14"/></template>
<template v-else-if="name === 'close'"><path d="M6 6l12 12M18 6L6 18"/></template>
<template v-else><path d="M5 12.5l4.2 4.2L19 7"/></template>
</g>
</svg>
</template>
+82
View File
@@ -0,0 +1,82 @@
import type { EngineerState } from './types'
const dev = import.meta.env.DEV
const demoState: EngineerState = {
serverConnected: true,
controlConnected: true,
serverURL: 'https://remlink.example.com',
version: '1.0.0',
overlayIP: '10.88.0.2',
sites: [
{ node_id: 'site-qingdao', name: '青岛现场 01', overlay_ip: '10.88.0.25', online: true, remote_subnet_capability: true, last_seen: new Date().toISOString() },
{ node_id: 'site-shanghai', name: '上海实验室', overlay_ip: '10.88.0.31', online: false, remote_subnet_capability: true },
],
siteCIDRs: {
'site-qingdao': ['192.168.17.0/24'],
'site-shanghai': ['192.168.107.0/24'],
},
session: {
id: '4488624737516445881', siteName: '青岛现场 01', cidrs: ['192.168.17.0/24'], status: 'ACTIVE',
uploadBytes: 1321205, downloadBytes: 2940838, uploadPackets: 12345, downloadPackets: 15402, latencyMS: 28,
startedAt: new Date(Date.now() - 18 * 60_000).toISOString(),
},
logs: [
{ time: new Date(Date.now() - 50_000).toISOString(), level: 'INFO', message: 'Overlay 隧道已建立,连接路径正常' },
{ time: new Date(Date.now() - 34_000).toISOString(), level: 'INFO', message: 'Remote CIDR 已生效:192.168.17.0/24' },
{ time: new Date(Date.now() - 12_000).toISOString(), level: 'INFO', message: '192.168.17.5ICMP 回复,时延 28ms' },
],
}
const native = () => window.go?.main?.EngineerApp
function normalizeState(value: EngineerState): EngineerState {
return {
...value,
sites: value.sites ?? [],
siteCIDRs: Object.fromEntries(Object.entries(value.siteCIDRs ?? {}).map(([siteID, cidrs]) => [siteID, Array.from(cidrs ?? [])])),
logs: value.logs ?? [],
session: { ...value.session, cidrs: value.session?.cidrs ?? [] },
}
}
export async function saveSiteCIDRs(siteNodeID: string, cidrs: string[]): Promise<void> {
const current = native()
if (current) return current.SaveSiteCIDRs(siteNodeID, cidrs)
if (!dev) return nativeUnavailable()
if (cidrs.length === 0) delete demoState.siteCIDRs[siteNodeID]
else demoState.siteCIDRs[siteNodeID] = Array.from(cidrs)
}
function nativeUnavailable(): never {
throw new Error('RemLink native runtime is unavailable; production Demo fallback is disabled')
}
export async function getState(): Promise<EngineerState> {
const current = native()
if (current) return normalizeState(await current.GetState())
if (!dev) return nativeUnavailable()
return structuredClone(demoState)
}
export async function createSession(siteNodeID: string, cidrs: string[]): Promise<string> {
const current = native()
if (current) return current.CreateSession(siteNodeID, cidrs)
if (!dev) return nativeUnavailable()
demoState.session = { ...demoState.session, id: 'pending', siteName: demoState.sites.find(site => site.node_id === siteNodeID)?.name ?? '', cidrs: Array.from(cidrs), status: 'CREATING' }
setTimeout(() => { demoState.session.status = 'ACTIVE'; demoState.session.id = '4488624737516445881' }, 450)
return 'dev-request'
}
export async function disconnectSession(): Promise<void> {
const current = native()
if (current) return current.DisconnectSession()
if (!dev) return nativeUnavailable()
demoState.session.status = 'IDLE'
demoState.session.id = ''
}
export async function checkCIDRs(cidrs: string[]): Promise<void> {
const current = native()
if (current) return current.CheckCIDRs(cidrs)
if (!dev) return nativeUnavailable()
}
+22
View File
@@ -0,0 +1,22 @@
/// <reference types="vite/client" />
import type { EngineerState } from './types'
declare global {
interface Window {
go?: {
main?: {
EngineerApp?: {
GetState(): Promise<EngineerState>
CreateSession(siteNodeID: string, cidrs: string[]): Promise<string>
SaveSiteCIDRs(siteNodeID: string, cidrs: string[]): Promise<void>
DisconnectSession(): Promise<void>
CheckCIDRs(cidrs: string[]): Promise<void>
}
}
}
runtime?: {
EventsOn(name: string, callback: (payload: unknown) => void): () => void
}
}
}
@@ -0,0 +1,12 @@
.table-head,
.site-row {
grid-template-columns: minmax(105px, 1.25fr) minmax(48px, .48fr) minmax(82px, .75fr) minmax(72px, .72fr) minmax(68px, .72fr);
column-gap: 8px;
}
.site-row > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
+6
View File
@@ -0,0 +1,6 @@
import { createApp } from 'vue'
import App from './App.vue'
import './styles.css'
import './layout-overrides.css'
createApp(App).mount('#app')
File diff suppressed because one or more lines are too long
+35
View File
@@ -0,0 +1,35 @@
export type NodeStatus = 'ONLINE' | 'UNSTABLE' | 'OFFLINE'
export type SessionStatus = 'IDLE' | 'CREATING' | 'PREPARING_SITE' | 'READY' | 'ACTIVE' | 'STOPPING' | 'CLOSED' | 'FAILED'
export interface SiteSummary {
node_id: string
name: string
overlay_ip: string
online: boolean
remote_subnet_capability: boolean
last_seen?: string
}
export interface EngineerState {
serverConnected: boolean
controlConnected: boolean
serverURL: string
version: string
overlayIP: string
sites: SiteSummary[]
siteCIDRs: Record<string, string[]>
session: {
id: string
siteName: string
cidrs: string[]
status: SessionStatus
uploadBytes: number
downloadBytes: number
uploadPackets: number
downloadPackets: number
latencyMS: number
startedAt?: string
reason?: string
}
logs: Array<{ time: string; level: 'INFO' | 'WARN' | 'ERROR'; message: string }>
}
+27
View File
@@ -0,0 +1,27 @@
const statusLabels: Record<string, string> = {
IDLE: '空闲(IDLE', CREATING: '正在创建(CREATING', PREPARING_SITE: '正在准备现场端(PREPARING_SITE',
READY: '准备就绪(READY', ACTIVE: '活动中(ACTIVE', STOPPING: '正在停止(STOPPING',
CLOSED: '已关闭(CLOSED', FAILED: '失败(FAILED',
}
const levelLabels: Record<string, string> = { INFO: '信息', WARN: '警告', ERROR: '错误' }
const errorLabels: Record<string, string> = {
SITE_NO_ROUTE: '现场端没有通往远程网段的明确路由', SESSION_TIMEOUT: '会话建立超时', SITE_OFFLINE: '现场端离线',
CIDR_INVALID: '远程网段格式无效', CIDR_LOCAL_CONFLICT: '远程网段与本地网络冲突',
CIDR_OVERLAY_CONFLICT: '远程网段与 Overlay 网段冲突', NETSTACK_UNAVAILABLE: '现场端 netstack 网关不可用',
FLOW_LIMIT_REACHED: '现场端连接流数量已达到上限', SESSION_INJECT_FAILED: '会话数据包注入失败',
ENGINEER_SESSION_EXISTS: 'Engineer 已存在未结束的会话',
}
export function statusLabel(value: string) { return statusLabels[value] ?? value }
export function levelLabel(value: string) { return levelLabels[value] ?? value }
export function errorLabel(cause: unknown) {
const value = String(cause).replace(/^Error:\s*/, '')
for (const [code, label] of Object.entries(errorLabels)) if (value.includes(code)) return `${label}${code}`
if (value.includes('administrator privileges are required')) return '需要以管理员身份运行,才能管理 RemLink Wintun 网卡'
if (value.includes('decrypt WireGuard private key')) return '无法解密 WireGuard 私钥:identity.json 不是由当前 Windows 系统生成,请重新注册节点'
if (value.includes('Join Token is required')) return '首次注册需要在 engineer.yaml 中填写 Join Token'
if (value.includes('conflicts with existing route')) return `远程网段与现有本地路由冲突;原始信息:${value}`
if (value.includes('Site has no route to Remote CIDR')) return '现场端没有通往远程网段的明确路由(SITE_NO_ROUTE'
if (value.includes('native runtime is unavailable')) return 'RemLink 原生运行时不可用,请使用正式 Engineer.exe 启动'
return value
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
root: fileURLToPath(new URL('.', import.meta.url)),
plugins: [vue()],
build: { outDir: 'dist', emptyOutDir: true, sourcemap: false },
server: { host: '127.0.0.1', port: 34115, strictPort: true },
})
+1155
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "remlink-frontends",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "npm run build:engineer && npm run build:server",
"build:engineer": "vite build --config engineer/vite.config.ts",
"build:server": "vite build --config server/vite.config.ts",
"dev:engineer": "vite --config engineer/vite.config.ts",
"dev:server": "vite --config server/vite.config.ts",
"typecheck": "vue-tsc --noEmit -p engineer/tsconfig.json && vue-tsc --noEmit -p server/tsconfig.json"
},
"dependencies": {
"vue": "3.5.41"
},
"devDependencies": {
"@vitejs/plugin-vue": "6.0.8",
"typescript": "5.9.3",
"vite": "8.2.2",
"vue-tsc": "3.3.11"
}
}
+19
View File
@@ -0,0 +1,19 @@
// Package serverui embeds the production Server Web UI.
package serverui
import (
"embed"
"io/fs"
"net/http"
)
//go:embed all:dist
var assets embed.FS
func Handler() (http.Handler, error) {
dist, err := fs.Sub(assets, "dist")
if err != nil {
return nil, err
}
return http.FileServer(http.FS(dist)), nil
}
+14
View File
@@ -0,0 +1,14 @@
<!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="#f7f8fc" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%23394ff0'/%3E%3Ccircle cx='11' cy='16' r='3' fill='white'/%3E%3Ccircle cx='21' cy='10' r='3' fill='white'/%3E%3Ccircle cx='21' cy='22' r='3' fill='white'/%3E%3Cpath d='m13 15 5-3m-5 5 5 3' stroke='white' stroke-width='2'/%3E%3C/svg%3E" />
<title>RemLink Server</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+128
View File
@@ -0,0 +1,128 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import Icon from './Icon.vue'
import LogTable from './components/LogTable.vue'
import NetworkForm from './components/NetworkForm.vue'
import NodeTable from './components/NodeTable.vue'
import SessionTable from './components/SessionTable.vue'
import { api, setAdminToken } from './api'
import type { EventRecord, NetworkConfig, NodeRecord, SessionRecord } from './types'
import { errorLabel, levelLabel, moduleLabel } from './zh-cn'
type Page = 'overview' | 'nodes' | 'sessions' | 'network' | 'logs'
const page = ref<Page>('overview')
const nodes = ref<NodeRecord[]>([]), sessions = ref<SessionRecord[]>([]), logs = ref<EventRecord[]>([])
let network = reactive<NetworkConfig>({ overlay_cidr:'', server_overlay_ip:'', wireguard_port:51820, session_udp_port:6200, mtu:1280, config_version:1 })
const busy = ref(false), error = ref(''), saved = ref(false), adminToken = ref(localStorage.getItem('remlink-admin-token') ?? '')
const logLevel = ref(''), logModule = ref(''), logNode = ref(''), logSession = ref(''), logFrom = ref(''), logTo = ref('')
let refreshTimer = 0
const logModules = ['CORE','BOOTSTRAP','WG','IPAM','CONTROL','SESSION','ROUTE','NETSTACK','TUN','SUBNET','SYSTEM']
const nav: Array<{id:Page; label:string; icon:string}> = [{id:'overview',label:'概览',icon:'overview'},{id:'nodes',label:'节点',icon:'nodes'},{id:'sessions',label:'会话',icon:'sessions'},{id:'network',label:'网络',icon:'network'},{id:'logs',label:'日志',icon:'logs'}]
const online = computed(() => nodes.value.filter(node => node.status === 'ONLINE').length)
const unstable = computed(() => nodes.value.filter(node => node.status === 'UNSTABLE').length)
const offline = computed(() => nodes.value.filter(node => node.status === 'OFFLINE').length)
const healthLabel = computed(() => offline.value ? '需关注' : unstable.value ? '不稳定' : '健康')
const activeSessions = computed(() => sessions.value.filter(session => session.status === 'ACTIVE'))
const totalUpload = computed(() => sessions.value.reduce((sum, session) => sum + session.counters.upload_bytes, 0))
const totalDownload = computed(() => sessions.value.reduce((sum, session) => sum + session.counters.download_bytes, 0))
const engineerNodes = computed(() => nodes.value.filter(node => node.type === 'engineer'))
const siteNodes = computed(() => nodes.value.filter(node => node.type === 'site'))
const nodeName = (id:string) => nodes.value.find(node => node.node_id === id)?.name ?? id
function arrayOrEmpty<T>(value:T[]|null|undefined):T[]{return Array.isArray(value)?value:[]}
function formatTime(value?:string){return value?new Intl.DateTimeFormat('zh-CN',{month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit'}).format(new Date(value)):'—'}
function formatBytes(value:number){return value<1024**2?`${(value/1024).toFixed(1)} KB`:`${(value/1024**2).toFixed(2)} MB`}
function formatUptime(value=0){const days=Math.floor(value/86400),hours=Math.floor(value%86400/3600),minutes=Math.floor(value%3600/60);return days?`${days} ${hours}小时`:`${hours}小时 ${minutes}`}
function topologyNodePosition(index:number,count:number){return ((index+0.5)/Math.max(count,1))*100}
function logFilter(){return {level:logLevel.value||undefined,module:logModule.value||undefined,node_id:logNode.value.trim()||undefined,session_id:logSession.value.trim()||undefined,from:logFrom.value?new Date(logFrom.value).toISOString():undefined,to:logTo.value?new Date(logTo.value).toISOString():undefined,limit:200}}
async function load(){busy.value=true;error.value='';try{const [nextNodes,nextSessions,nextNetwork,nextLogs]=await Promise.all([api.nodes(),api.sessions(),api.network(),api.logs(logFilter())]);nodes.value=arrayOrEmpty(nextNodes);sessions.value=arrayOrEmpty(nextSessions);logs.value=arrayOrEmpty(nextLogs);Object.assign(network,nextNetwork)}catch(cause){error.value=errorLabel(cause)}finally{busy.value=false}}
async function refreshRuntime(){if(busy.value)return;try{const [nextNodes,nextSessions,nextLogs]=await Promise.all([api.nodes(),api.sessions(),api.logs(logFilter())]);nodes.value=arrayOrEmpty(nextNodes);sessions.value=arrayOrEmpty(nextSessions);logs.value=arrayOrEmpty(nextLogs)}catch(cause){error.value=errorLabel(cause)}}
async function runAction(action:()=>Promise<unknown>){busy.value=true;error.value='';try{await action();await load()}catch(cause){error.value=errorLabel(cause)}finally{busy.value=false}}
async function editNode(node:NodeRecord){const name=window.prompt('节点名称',node.name);if(name===null)return;const overlay=window.prompt('Overlay IP',node.overlay_ip);if(overlay===null)return;await runAction(()=>api.patchNode(node.node_id,{name,overlay_ip:overlay}))}
async function revokeNode(node:NodeRecord){if(!window.confirm(`撤销节点 ${node.name}?此操作会断开相关会话。`))return;await runAction(()=>api.deleteNode(node.node_id))}
async function disconnect(session:SessionRecord){if(!window.confirm(`强制断开 Session ${session.session_id}`))return;await runAction(()=>api.disconnect(session.session_id))}
async function saveNetwork(rotate=false){busy.value=true;error.value='';saved.value=false;try{const updated=await api.updateNetwork({...network,rotate_join_token:rotate});Object.assign(network,updated);saved.value=true;setTimeout(()=>saved.value=false,2500)}catch(cause){error.value=errorLabel(cause)}finally{busy.value=false}}
function applyToken(){setAdminToken(adminToken.value);load()}
onMounted(async()=>{await load();refreshTimer=window.setInterval(()=>{void refreshRuntime()},5000)})
onBeforeUnmount(()=>clearInterval(refreshTimer))
</script>
<template>
<div class="admin-shell">
<aside class="sidebar">
<div class="brand"><span class="brand-mark"><i></i><i></i><i></i></span><span>RemLink <b>Server</b></span></div>
<nav><button v-for="item in nav" :key="item.id" :class="{selected:page===item.id}" @click="page=item.id"><Icon :name="item.icon"/>{{item.label}}</button></nav>
<div class="token-box"><label>单机访问令牌可选</label><div><input v-model="adminToken" type="password" placeholder="Bearer token"/><button @click="applyToken">应用</button></div></div>
<footer>RemLink Server v1.0.0</footer>
</aside>
<main>
<header><span>服务器版本<b>v1.0.0</b></span><i></i><span>运行时长<b>{{formatUptime(network.uptime_seconds)}}</b></span><i></i><span>整体健康状态<strong>{{healthLabel}}</strong></span></header>
<div class="content">
<div class="title-row"><div><h1>{{nav.find(item=>item.id===page)?.label==='概览'?'网络运行概览':nav.find(item=>item.id===page)?.label}}</h1><p v-if="page!=='overview'">RemLink Server 权威配置与运行状态</p></div><button v-if="page==='overview'" class="primary" @click="page='network'"><Icon name="plus"/>节点接入配置</button></div>
<p v-if="error" class="page-error">{{error}}</p>
<template v-if="page==='overview'">
<section class="topology panel">
<div class="section-title">
<div class="topology-heading"><h2>拓扑概览</h2><p>Engineer 在左Site 在右均独立连接 RemLink Server</p></div>
<div class="legend"><span><i class="online"></i>在线</span><span><i class="unstable"></i>不稳定</span><span><i class="offline"></i>离线</span></div>
</div>
<div class="topology-body">
<div class="topology-split" :style="{minHeight:`${Math.max(180,Math.max(engineerNodes.length,siteNodes.length)*48)}px`}" aria-label="Engineer 位于左侧RemLink Server 位于中间Site 位于右侧的 Overlay 拓扑">
<div class="topology-side engineer-side">
<svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
<line v-for="(node,index) in engineerNodes" :key="node.node_id" x1="28" :y1="topologyNodePosition(index,engineerNodes.length)" x2="100" y2="50"/>
</svg>
<span v-for="(node,index) in engineerNodes" :key="node.node_id" class="topology-node engineer-node" :style="{top:`${topologyNodePosition(index,engineerNodes.length)}%`}">
<i :class="node.status.toLowerCase()"></i><b>{{node.name}}</b><em>Engineer</em><small>{{node.overlay_ip}}</small>
</span>
<p v-if="!engineerNodes.length" class="topology-empty">暂无 Engineer</p>
</div>
<div class="server-node"><Icon name="nodes"/><div><b>RemLink Server</b><small>{{network.server_overlay_ip}}</small></div></div>
<div class="topology-side site-side">
<svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
<line v-for="(node,index) in siteNodes" :key="node.node_id" x1="0" y1="50" x2="72" :y2="topologyNodePosition(index,siteNodes.length)"/>
</svg>
<span v-for="(node,index) in siteNodes" :key="node.node_id" class="topology-node site-node" :style="{top:`${topologyNodePosition(index,siteNodes.length)}%`}">
<i :class="node.status.toLowerCase()"></i><b>{{node.name}}</b><em>Site</em><small>{{node.overlay_ip}}</small>
</span>
<p v-if="!siteNodes.length" class="topology-empty">暂无 Site</p>
</div>
</div>
<dl><dt>在线节点</dt><dd>{{online}}</dd><dt>不稳定节点</dt><dd class="amber">{{unstable}}</dd><dt>离线节点</dt><dd class="red">{{offline}}</dd><dt>活跃会话</dt><dd>{{activeSessions.length}}</dd><dt>累计上传</dt><dd>{{formatBytes(totalUpload)}}</dd><dt>累计下载</dt><dd>{{formatBytes(totalDownload)}}</dd></dl>
</div>
</section>
<div class="overview-list"><NodeTable :nodes="nodes" :compact="true" @edit="editNode" @revoke="revokeNode"/><SessionTable :sessions="activeSessions" :nodes="nodes" @disconnect="disconnect"/><LogTable :logs="logs.slice(0,5)"/></div>
</template>
<NodeTable v-else-if="page==='nodes'" :nodes="nodes" @edit="editNode" @revoke="revokeNode"/>
<SessionTable v-else-if="page==='sessions'" :sessions="sessions" :nodes="nodes" @disconnect="disconnect"/>
<NetworkForm v-else-if="page==='network'" v-model="network" :busy="busy" :saved="saved" :full="true" @save="saveNetwork(false)" @rotate="saveNetwork(true)"/>
<section v-else class="panel logs-page"><div class="section-title log-filter-title"><h2>事件日志</h2><div class="filters"><input v-model="logFrom" type="datetime-local" title="起始时间"/><input v-model="logTo" type="datetime-local" title="结束时间"/><select v-model="logLevel"><option value="">全部级别</option><option value="INFO">{{levelLabel('INFO')}}</option><option value="WARN">{{levelLabel('WARN')}}</option><option value="ERROR">{{levelLabel('ERROR')}}</option></select><select v-model="logModule"><option value="">全部模块</option><option v-for="module in logModules" :key="module" :value="module">{{moduleLabel(module)}}</option></select><input v-model="logNode" placeholder="节点 ID"/><input v-model="logSession" placeholder="会话 ID"/><button @click="load">查询</button></div></div><LogTable :logs="logs" :bare="true"/></section>
</div>
</main>
</div>
</template>
<style>
.filters{flex-wrap:wrap;justify-content:flex-end}
.filters input[type="datetime-local"]{width:154px}
.filters input{width:105px}
.log-filter-title{min-height:72px;align-items:flex-start;padding-top:12px;gap:12px}
.join-token-result{margin:10px 15px 0;padding:10px;background:#f0f1ff;border:1px solid #ccd2ff;border-radius:6px;display:grid;gap:7px;color:#3438cc;font-size:9px}
.join-token-result input{width:100%;height:30px;border:1px solid #afbaf0;border-radius:5px;padding:0 9px;background:#fff;font-family:"Cascadia Code",monospace;font-size:9px}
.topology-split{grid-column:1/3;display:grid;grid-template-columns:minmax(0,1fr) 220px minmax(0,1fr);align-items:center;min-width:0}
.topology-side{position:relative;align-self:stretch;min-width:0}
.topology-side svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}
.topology-side line{stroke:#6d78a9;stroke-width:2;vector-effect:non-scaling-stroke}
.topology-node{position:absolute;width:calc(28% - 10px);transform:translateY(-50%);display:grid;gap:2px;line-height:1.15}
.topology-node>i{position:absolute;top:50%;transform:translateY(-50%);width:13px;height:13px;border:2px solid #fff;border-radius:50%;box-shadow:0 0 0 1px #d9deeb}
.engineer-node{right:72%;padding-right:21px;text-align:right}
.engineer-node>i{right:-6px}
.site-node{left:72%;padding-left:21px;text-align:left}
.site-node>i{left:-6px}
.topology-node>b{font-size:9px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.topology-node>em{color:#59647a;font-size:8px;font-style:normal;font-weight:700}
.topology-node>small{color:var(--muted);font-size:9px;white-space:nowrap}
.topology-empty{position:absolute;top:50%;width:100%;margin:0;transform:translateY(-50%);color:var(--muted);font-size:9px;text-align:center}
.overview-list{display:grid;gap:10px}
@media(max-width:1120px){.topology-split{grid-template-columns:minmax(0,1fr) 160px minmax(0,1fr)}}
</style>
+12
View File
@@ -0,0 +1,12 @@
<script setup lang="ts">defineProps<{name:string}>()</script>
<template><svg viewBox="0 0 24 24" aria-hidden="true"><g fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<template v-if="name==='overview'"><path d="M3.5 11L12 4l8.5 7v9H14v-5h-4v5H3.5z"/></template>
<template v-else-if="name==='nodes'"><circle cx="12" cy="5" r="2.2"/><circle cx="5" cy="18" r="2.2"/><circle cx="19" cy="18" r="2.2"/><path d="M12 7.2v4M7 16.5l5-5 5 5"/></template>
<template v-else-if="name==='sessions'"><rect x="4" y="4" width="16" height="13" rx="2"/><path d="M8 20h8M12 17v3M8 9h8M8 12h5"/></template>
<template v-else-if="name==='network'"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c3 3.2 3 14.8 0 18M12 3c-3 3.2-3 14.8 0 18"/></template>
<template v-else-if="name==='logs'"><path d="M6 3.5h9l3 3V20.5H6zM15 3.5v3h3M9 11h6M9 15h6"/></template>
<template v-else-if="name==='edit'"><path d="M4 20l4.2-1 10.5-10.5-3.2-3.2L5 15.8zM13.8 7l3.2 3.2"/></template>
<template v-else-if="name==='trash'"><path d="M4 7h16M9 7V4h6v3M7 7l1 13h8l1-13M10 11v5M14 11v5"/></template>
<template v-else-if="name==='plus'"><circle cx="12" cy="12" r="9"/><path d="M12 8v8M8 12h8"/></template>
<template v-else><path d="M5 12.5l4.2 4.2L19 7"/></template>
</g></svg></template>
+61
View File
@@ -0,0 +1,61 @@
import type { EventRecord, LogFilter, NetworkConfig, NodeRecord, SessionRecord } from './types'
const now = Date.now()
const demoNodes: NodeRecord[] = [
{ node_id: 'eng-01', type: 'engineer', name: 'Engineer-01', overlay_ip: '10.88.0.10', wg_public_key: 'E7y0lQYJb0fYfS0uA8GpR0JtXj4Ff7r2O8lD...', wg_handshake: new Date(now - 6000).toISOString(), status: 'ONLINE', version: '1.0.0', os_version: 'Windows 11', last_seen: new Date(now - 4000).toISOString() },
{ node_id: 'eng-02', type: 'engineer', name: 'Engineer-02', overlay_ip: '10.88.0.11', wg_public_key: 'Q1r9...', status: 'ONLINE', version: '1.0.0', os_version: 'Windows 11', last_seen: new Date(now - 8000).toISOString() },
{ node_id: 'site-a', type: 'site', name: 'Site-A', overlay_ip: '10.88.0.20', wg_public_key: 'L8c2...', status: 'UNSTABLE', version: '1.0.0', os_version: 'Windows Server 2022', last_seen: new Date(now - 22_000).toISOString() },
{ node_id: 'site-b', type: 'site', name: 'Site-B', overlay_ip: '10.88.0.21', wg_public_key: 'M5x4...', status: 'OFFLINE', version: '1.0.0', os_version: 'Windows 10', last_seen: new Date(now - 2_400_000).toISOString() },
]
const demoSessions: SessionRecord[] = [
{ session_id: '8648912340291133', engineer_node_id: 'eng-01', site_node_id: 'site-a', status: 'ACTIVE', cidrs: ['192.168.10.0/24'], created_at: new Date(now - 1_220_000).toISOString(), active_at: new Date(now - 1_200_000).toISOString(), counters: { upload_bytes: 1258291, download_bytes: 3586129, upload_packets: 8912, download_packets: 14022 } },
{ session_id: '7066248371127201', engineer_node_id: 'eng-02', site_node_id: 'site-a', status: 'ACTIVE', cidrs: ['192.168.20.0/24'], created_at: new Date(now - 550_000).toISOString(), active_at: new Date(now - 530_000).toISOString(), counters: { upload_bytes: 712004, download_bytes: 1153434, upload_packets: 4220, download_packets: 6741 } },
]
let demoNetwork: NetworkConfig = { overlay_cidr: '10.88.0.0/16', server_overlay_ip: '10.88.0.1', wireguard_port: 51820, session_udp_port: 6200, mtu: 1280, config_version: 1, uptime_seconds: 48376 }
const demoEvents: EventRecord[] = [
{ id: 5, time: new Date(now - 5000).toISOString(), level: 'INFO', module: 'CONTROL', node_id: 'eng-01', message: '节点上线,Overlay 10.88.0.10', fields: {} },
// Keep two legacy English records in development so the presentation-layer
// translator is exercised against events persisted by older Server builds.
{ id: 4, time: new Date(now - 12_000).toISOString(), level: 'INFO', module: 'SESSION', session_id: '8648912340291133', message: 'Session status changed to ACTIVE', fields: {} },
{ id: 3, time: new Date(now - 31_000).toISOString(), level: 'WARN', module: 'CONTROL', node_id: 'site-a', message: 'Node heartbeat status changed to UNSTABLE', fields: {} },
{ id: 2, time: new Date(now - 80_000).toISOString(), level: 'ERROR', module: 'CONTROL', node_id: 'site-b', message: '节点离线', fields: {} },
]
const dev = import.meta.env.DEV
let token = localStorage.getItem('remlink-admin-token') ?? ''
export function setAdminToken(value: string) { token = value.trim(); localStorage.setItem('remlink-admin-token', token) }
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(path, { ...init, headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...init?.headers } })
if (!response.ok) {
const body = await response.json().catch(() => ({}))
const code = body?.error?.code
const message = body?.error?.message ?? `HTTP ${response.status}`
throw new Error(code ? `${code}: ${message}` : message)
}
return response.status === 204 ? undefined as T : response.json()
}
export const api = {
nodes: () => dev ? Promise.resolve(structuredClone(demoNodes)) : request<NodeRecord[]>('/api/v1/admin/nodes'),
sessions: () => dev ? Promise.resolve(structuredClone(demoSessions)) : request<SessionRecord[]>('/api/v1/admin/sessions'),
network: () => dev ? Promise.resolve(structuredClone(demoNetwork)) : request<NetworkConfig>('/api/v1/admin/network'),
logs: (filter: LogFilter = {}) => {
const params = new URLSearchParams({ limit: String(filter.limit ?? 200) })
for (const key of ['level','module','node_id','session_id','from','to'] as const) if (filter[key]) params.set(key, String(filter[key]))
if (!dev) return request<EventRecord[]>(`/api/v1/admin/logs?${params}`)
const from = filter.from ? Date.parse(filter.from) : Number.NEGATIVE_INFINITY
const to = filter.to ? Date.parse(filter.to) : Number.POSITIVE_INFINITY
return Promise.resolve(structuredClone(demoEvents.filter(event => (!filter.level || event.level === filter.level) && (!filter.module || event.module === filter.module) && (!filter.node_id || event.node_id === filter.node_id) && (!filter.session_id || String(event.session_id ?? '') === filter.session_id) && Date.parse(event.time) >= from && Date.parse(event.time) <= to)))
},
patchNode: async (id: string, patch: Partial<Pick<NodeRecord, 'name' | 'overlay_ip'>>) => dev ? Object.assign(demoNodes.find(node => node.node_id === id)!, patch) : request<NodeRecord>(`/api/v1/admin/nodes/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify(patch) }),
deleteNode: async (id: string) => dev ? demoNodes.splice(demoNodes.findIndex(node => node.node_id === id), 1) : request<void>(`/api/v1/admin/nodes/${encodeURIComponent(id)}`, { method: 'DELETE' }),
disconnect: async (id: string) => dev ? Object.assign(demoSessions.find(session => session.session_id === id)!, { status: 'CLOSED' }) : request(`/api/v1/admin/sessions/${id}/disconnect`, { method: 'POST' }),
updateNetwork: async (network: NetworkConfig) => {
if (dev) {
const { rotate_join_token: rotate, join_token: _, ...input } = network
demoNetwork = { ...input, config_version: network.config_version + 1, ...(rotate ? { join_token: 'demo-join-token-after-rotation' } : {}) }
return structuredClone(demoNetwork)
}
const { config_version: _, uptime_seconds: __, join_token: ___, ...input } = network
return request<NetworkConfig>('/api/v1/admin/network', { method: 'PUT', body: JSON.stringify(input) })
},
}
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { EventRecord } from '../types'
import { levelLabel, logMessageLabel, moduleLabel } from '../zh-cn'
defineProps<{ logs: EventRecord[]; bare?: boolean }>()
function formatTime(value?: string) { return value ? new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(new Date(value)) : '—' }
</script>
<template>
<section :class="bare ? '' : 'panel table-panel'">
<div v-if="!bare" class="section-title"><h2>最近事件日志</h2><span>按时间倒序</span></div>
<div class="table-wrap"><table>
<thead><tr><th>时间</th><th>级别</th><th>模块</th><th>Node</th><th>Session</th><th>事件</th></tr></thead>
<tbody><tr v-for="log in logs" :key="log.id"><td>{{ formatTime(log.time) }}</td><td><span class="log-level" :class="log.level.toLowerCase()">{{ levelLabel(log.level) }}</span></td><td>{{ moduleLabel(log.module) }}</td><td>{{ log.node_id || '—' }}</td><td class="mono">{{ log.session_id || '—' }}</td><td>{{ logMessageLabel(log.message) }}</td></tr><tr v-if="!logs.length"><td colspan="6" class="empty">没有匹配日志</td></tr></tbody>
</table></div>
</section>
</template>
@@ -0,0 +1,19 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { NetworkConfig } from '../types'
const props = defineProps<{ modelValue: NetworkConfig; busy?: boolean; saved?: boolean; full?: boolean }>()
defineEmits<{ 'update:modelValue': [value: NetworkConfig]; save: []; rotate: [] }>()
const form = computed(() => props.modelValue)
</script>
<template>
<section class="panel network-form" :class="{ full }">
<div class="section-title"><div><h2>网络运行状况 & 配置</h2><p>配置版本 {{ form.config_version }}</p></div></div>
<div class="health-list"><span>Overlay 网络 <b><i class="online"></i>健康</b></span><span>节点连通性 <b><i class="online"></i>正常</b></span><span>会话状态 <b><i class="online"></i>正常</b></span></div>
<div class="fields"><label>Overlay CIDR<input v-model="form.overlay_cidr" /></label><label>Server Overlay IP<input v-model="form.server_overlay_ip" /></label><label>WireGuard Port<input v-model.number="form.wireguard_port" type="number" /></label><label>Session UDP Port<input v-model.number="form.session_udp_port" type="number" /></label><label>MTU<input v-model.number="form.mtu" type="number" /></label></div>
<button class="primary save" :disabled="busy" @click="$emit('save')">{{ saved ? '已保存' : '保存网络配置' }}</button>
<button v-if="full" class="secondary-action" :disabled="busy" @click="$emit('rotate')">轮换 Join Token</button>
<div v-if="full && form.join_token" class="join-token-result"><strong>新 Join Token(仅本次显示)</strong><input :value="form.join_token" readonly aria-label="新 Join Token" /></div>
<p class="form-note">Overlay 变更将关闭现有会话并要求在线节点重新 Bootstrap</p>
</section>
</template>
@@ -0,0 +1,26 @@
<script setup lang="ts">
import Icon from '../Icon.vue'
import type { NodeRecord } from '../types'
import StatusDot from './StatusDot.vue'
defineProps<{ nodes: NodeRecord[]; compact?: boolean }>()
defineEmits<{ edit: [node: NodeRecord]; revoke: [node: NodeRecord] }>()
function formatTime(value?: string) {
return value ? new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(new Date(value)) : '—'
}
</script>
<template>
<section class="panel table-panel">
<div class="section-title"><h2>节点状态</h2><span> {{ nodes.length }} 个节点</span></div>
<div class="table-wrap"><table>
<thead><tr><th>名称 / WG 公钥</th><th>类型</th><th>Overlay IP</th><th>应用状态</th><th>WG 最近握手</th><th>版本</th><th>最后在线</th><th>操作</th></tr></thead>
<tbody><tr v-for="node in nodes" :key="node.node_id">
<td><b>{{ node.name }}</b><small>{{ node.wg_public_key.slice(0, 16) }}</small></td>
<td>{{ node.type === 'engineer' ? 'Engineer' : 'Site' }}</td><td class="mono">{{ node.overlay_ip }}</td>
<td><StatusDot :status="node.status" /></td><td>{{ formatTime(node.wg_handshake) }}</td><td>{{ node.version || '—' }}</td><td>{{ formatTime(node.last_seen) }}</td>
<td><button class="icon-button" aria-label="编辑节点" @click="$emit('edit', node)"><Icon name="edit" /></button><button class="icon-button destructive" aria-label="撤销节点" @click="$emit('revoke', node)"><Icon name="trash" /></button></td>
</tr><tr v-if="!nodes.length"><td colspan="8" class="empty">尚无注册节点</td></tr></tbody>
</table></div>
</section>
</template>
@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { NodeRecord, SessionRecord } from '../types'
import StatusDot from './StatusDot.vue'
import { reasonLabel } from '../zh-cn'
const props = defineProps<{ sessions: SessionRecord[]; nodes: NodeRecord[] }>()
defineEmits<{ disconnect: [session: SessionRecord] }>()
function formatBytes(value: number) { return value < 1024 ** 2 ? `${(value / 1024).toFixed(1)} KB` : `${(value / 1024 ** 2).toFixed(2)} MB` }
function nodeName(id: string) { return props.nodes.find(node => node.node_id === id)?.name ?? id }
function duration(session: SessionRecord) {
const start = Date.parse(session.active_at || session.created_at), end = session.closed_at ? Date.parse(session.closed_at) : Date.now()
const seconds = Math.max(0, Math.floor((end - start) / 1000)), hours = Math.floor(seconds / 3600), minutes = Math.floor(seconds % 3600 / 60)
return hours ? `${hours}小时 ${minutes}` : `${minutes}${seconds % 60}`
}
</script>
<template>
<section class="panel table-panel">
<div class="section-title"><h2>会话</h2><span> {{ sessions.length }} </span></div>
<div class="table-wrap"><table>
<thead><tr><th>会话 ID</th><th>Engineer</th><th>Site</th><th>远程网段</th><th>状态</th><th>上传 / 下载</th><th>持续时间</th><th>操作</th></tr></thead>
<tbody><tr v-for="session in sessions" :key="session.session_id">
<td class="mono">{{ String(session.session_id).slice(0, 14) }}</td><td>{{ nodeName(session.engineer_node_id) }}</td><td>{{ nodeName(session.site_node_id) }}</td><td class="mono">{{ session.cidrs.join(', ') }}</td>
<td><StatusDot :status="session.status" /><small v-if="session.error_code" class="red">{{ reasonLabel(session.error_code) }}</small></td><td>{{ formatBytes(session.counters.upload_bytes) }} / {{ formatBytes(session.counters.download_bytes) }}</td><td>{{ duration(session) }}</td>
<td><button class="disconnect" :disabled="session.status !== 'ACTIVE'" @click="$emit('disconnect', session)">强制断开</button></td>
</tr><tr v-if="!sessions.length"><td colspan="8" class="empty">当前没有会话</td></tr></tbody>
</table></div>
</section>
</template>
@@ -0,0 +1,8 @@
<script setup lang="ts">
import { statusLabel } from '../zh-cn'
defineProps<{ status: string }>()
</script>
<template>
<span class="status"><i :class="status.toLowerCase()"></i>{{ statusLabel(status) }}</span>
</template>
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './styles.css'
createApp(App).mount('#app')
File diff suppressed because one or more lines are too long
+6
View File
@@ -0,0 +1,6 @@
export interface NodeRecord { node_id: string; type: 'engineer' | 'site'; name: string; overlay_ip: string; wg_public_key: string; wg_handshake?: string; status: 'ONLINE' | 'UNSTABLE' | 'OFFLINE'; version: string; os_version: string; last_seen?: string }
export interface Counters { upload_bytes: number; download_bytes: number; upload_packets: number; download_packets: number }
export interface SessionRecord { session_id: string; engineer_node_id: string; site_node_id: string; status: string; cidrs: string[]; created_at: string; active_at?: string; closed_at?: string; error_code?: string; counters: Counters }
export interface NetworkConfig { overlay_cidr: string; server_overlay_ip: string; wireguard_port: number; session_udp_port: number; mtu: number; config_version: number; uptime_seconds?: number; rotate_join_token?: boolean; join_token?: string }
export interface EventRecord { id: number; time: string; level: string; module: string; node_id?: string; session_id?: string; message: string; fields: Record<string, unknown> }
export interface LogFilter { level?: string; module?: string; node_id?: string; session_id?: string; from?: string; to?: string; limit?: number }
+61
View File
@@ -0,0 +1,61 @@
const statusLabels: Record<string, string> = {
ONLINE: '在线(ONLINE', UNSTABLE: '连接不稳定(UNSTABLE', OFFLINE: '离线(OFFLINE',
CREATING: '正在创建(CREATING', PREPARING_SITE: '正在准备现场端(PREPARING_SITE',
READY: '准备就绪(READY', ACTIVE: '活动中(ACTIVE', STOPPING: '正在停止(STOPPING',
CLOSED: '已关闭(CLOSED', FAILED: '失败(FAILED', IDLE: '空闲(IDLE',
}
const levelLabels: Record<string, string> = { INFO: '信息(INFO', WARN: '警告(WARN', ERROR: '错误(ERROR', DEBUG: '调试(DEBUG' }
const moduleLabels: Record<string, string> = {
CORE: '核心(CORE', BOOTSTRAP: '节点接入(BOOTSTRAP', WG: 'WireGuardWG', IPAM: '地址分配(IPAM',
CONTROL: '控制通道(CONTROL', SESSION: '会话(SESSION', ROUTE: '路由(ROUTE', NETSTACK: '网络栈(NETSTACK',
TUN: '虚拟网卡(TUN', SUBNET: '远程网段(SUBNET', SYSTEM: '系统(SYSTEM',
}
const reasonLabels: Record<string, string> = {
SITE_NO_ROUTE: '现场端没有通往远程网段的明确路由(SITE_NO_ROUTE',
SESSION_TIMEOUT: '会话建立超时(SESSION_TIMEOUT',
SITE_OFFLINE: '现场端离线(SITE_OFFLINE',
CIDR_INVALID: '远程网段格式无效(CIDR_INVALID',
CIDR_LOCAL_CONFLICT: '远程网段与 Engineer 本地网络冲突(CIDR_LOCAL_CONFLICT',
CIDR_OVERLAY_CONFLICT: '远程网段与 Overlay 网段冲突(CIDR_OVERLAY_CONFLICT',
NETSTACK_UNAVAILABLE: '现场端 netstack 网关不可用(NETSTACK_UNAVAILABLE',
FLOW_LIMIT_REACHED: '现场端连接流数量已达到上限(FLOW_LIMIT_REACHED',
SESSION_INJECT_FAILED: '会话数据包注入失败(SESSION_INJECT_FAILED',
INVALID_REQUEST: '请求内容无效(INVALID_REQUEST', NODE_NOT_FOUND: '没有找到指定节点(NODE_NOT_FOUND',
NODE_UPDATE_FAILED: '节点更新失败(NODE_UPDATE_FAILED', SESSION_DISCONNECT_FAILED: '会话断开失败(SESSION_DISCONNECT_FAILED',
PEER_REVOKE_FAILED: 'WireGuard 对等节点撤销失败(PEER_REVOKE_FAILED', NODE_DELETE_FAILED: '节点删除失败(NODE_DELETE_FAILED',
INVALID_SESSION_ID: '会话 ID 无效(INVALID_SESSION_ID', NETWORK_UPDATE_FAILED: '网络配置更新失败(NETWORK_UPDATE_FAILED',
JOIN_TOKEN_ROTATE_FAILED: 'Join Token 轮换失败(JOIN_TOKEN_ROTATE_FAILED',
}
const oldMessages: Record<string, string> = {
'Node Control connected': '节点 Control 通道已连接',
'Node rejected Overlay network configuration': '节点拒绝了 Overlay 网络配置',
'Session preparation started': '会话准备已开始',
'Node updated': '节点配置已更新',
'Node revoked': '节点已撤销',
'Session disconnected by administrator': '管理员已强制断开会话',
'Network configuration updated': '网络配置已更新',
}
export function statusLabel(value: string) { return statusLabels[value] ?? value }
export function levelLabel(value: string) { return levelLabels[value] ?? value }
export function moduleLabel(value: string) { return moduleLabels[value] ?? value }
export function reasonLabel(value: string) { return reasonLabels[value] ?? value }
export function logMessageLabel(value: string) {
if (oldMessages[value]) return oldMessages[value]
let match = /^Session status changed to ([A-Z_]+)$/.exec(value)
if (match) return `会话状态变更为 ${statusLabel(match[1])}`
match = /^Node heartbeat status changed to ([A-Z_]+)$/.exec(value)
if (match) return `节点心跳状态变更为 ${statusLabel(match[1])}`
return value
}
export function errorLabel(cause: unknown) {
const value = String(cause).replace(/^Error:\s*/, '')
for (const [code, label] of Object.entries(reasonLabels)) if (value.includes(code)) return `${label};原始信息:${value}`
if (value.includes('valid Bearer Admin Token required')) return '需要有效的管理员 Bearer Token'
if (value.includes('Failed to fetch')) return '无法连接 Server API,请检查服务地址、端口和防火墙'
return value
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
root: fileURLToPath(new URL('.', import.meta.url)),
plugins: [vue()],
resolve: { alias: { vue: 'vue/dist/vue.esm-bundler.js' } },
build: { outDir: 'dist', emptyOutDir: true, sourcemap: false },
server: { host: '127.0.0.1', port: 34116, strictPort: true },
})