初版功能完成
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
// Package engineerui embeds the production Wails frontend.
|
||||
package engineerui
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed all:dist
|
||||
var Assets embed.FS
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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.5:ICMP 回复,时延 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()
|
||||
}
|
||||
Vendored
+22
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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 }>
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,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 },
|
||||
})
|
||||
Reference in New Issue
Block a user