初版功能完成
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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 }
|
||||
@@ -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: 'WireGuard(WG)', 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
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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 },
|
||||
})
|
||||
Reference in New Issue
Block a user