feat: cluster reliability (leader failover, crash rejoin, key exchange) + auth/users + canvas/forwards enhancements + comprehensive README + API docs

- Cluster: forwardToNext offline detection (leader+non-leader), WatchLeader 1s heartbeat fallback, 409 for standalone nodes, Node.NodeKey key exchange via token ring, ClusterPeers persistence + auto-rejoin, Forward delegates to forwardToNext (bugfix)
- Auth: Basic Auth (flag-creds fast path) + bcrypt users (admin/viewer) + Bearer API keys (read/write/admin scope)
- Frontend: UsersView (accounts+API keys), ClusterView (ring/nodeKey/tasks/topology/log), StatusView (group management, per-proxy status), CanvasView (edge toggle/group), PortEdge (disabled/group labels)
- API: handlers split (canvas/forwards/users/logs), canvas export/import, forwards group start/stop/assign/delete, cluster endpoints
- Docs: comprehensive README rewrite (all flags/APIs/auth/cluster), docs/cluster-api.md (cluster management API reference)
- Deploy: run-cluster.sh now 4-node ring + 1 isolated standalone, test-forward.sh updated for 4 nodes
- Removed plan.md (design notes consolidated into README + API docs)
This commit is contained in:
2026-08-19 21:09:24 +08:00
parent b518a13446
commit eda9bb9597
55 changed files with 5462 additions and 793 deletions

View File

@ -10,6 +10,11 @@
</div>
<div class="toolbar-right">
<span class="save-hint">{{ dirty ? '● 未保存' : '已保存' }}</span>
<button class="btn ghost" @click="exportCanvas">导出</button>
<button class="btn ghost" :disabled="!canWrite" @click="pickImport" title="导入转发表(覆盖当前)">
导入
</button>
<input ref="importInput" type="file" accept=".json,application/json" class="hidden-input" @change="onImportFile" />
<button class="btn save" :disabled="saving" @click="save">
保存配置
</button>
@ -32,7 +37,7 @@
@pane-click="deselectAll"
@edge-click="onEdgeClick"
>
<Background pattern-color="#cfd8dc" :gap="20" />
<Background :pattern-color="patternColor" :gap="20" />
<template #node-local="slotProps">
<LocalNode
@ -58,6 +63,8 @@
:conflicted="isEdgeConflicted((edgeProps as any).id)"
@label-click="onEdgeLabelClick"
@label-drag="onLabelDrag"
@toggle-disabled="onToggleDisabled"
@edit-group="onEditGroup"
/>
</template>
</VueFlow>
@ -87,6 +94,14 @@
@keyup.enter="confirmPort"
/>
</div>
<div class="dlg-row">
<label>分组</label>
<el-input
v-model="groupInput"
placeholder="可选,用于转发页一键启停整组"
@keyup.enter="confirmPort"
/>
</div>
<template #footer>
<button class="btn" @click="portDlg.visible = false">取消</button>
<button class="btn save" @click="confirmPort">确定</button>
@ -98,21 +113,35 @@
<script setup lang="ts">
import '@vue-flow/core/dist/style.css'
import '@vue-flow/core/dist/theme-default.css'
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { VueFlow } from '@vue-flow/core'
import { Background } from '@vue-flow/background'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { Edge, Connection } from '@vue-flow/core'
import type { CanvasLocal, CanvasRemote, CanvasLink } from '../types'
import type { CanvasLocal, CanvasRemote, CanvasLink, CanvasData, CanvasExportEnvelope } from '../types'
import LocalNode from '../components/LocalNode.vue'
import RemoteNode from '../components/RemoteNode.vue'
import PortEdge from '../components/PortEdge.vue'
import { api } from '../api'
import { canWrite } from '../auth'
const nodes = ref<any[]>([])
const edges = ref<Edge[]>([])
const loading = ref(false)
// Background dot-pattern color, resolved from the live theme var so the grid
// recolors on theme switch (vue-flow Background renders pattern-color as an
// SVG fill attribute, which can't resolve var() directly — read the computed
// value and refresh it when <html data-theme> changes).
const patternColor = ref('rgba(120,130,150,0.22)')
let themeObserver: MutationObserver | null = null
const resolvePatternColor = () => {
const v = getComputedStyle(document.documentElement)
.getPropertyValue('--w4f-line-strong')
.trim()
patternColor.value = v || 'rgba(120,130,150,0.22)'
}
// ---- canvas conflict validation ----
interface CanvasConflict {
type: 'port' | 'domain'
@ -140,11 +169,14 @@ const portDlg = ref({
local: '',
remote: '',
remotePort: 0,
group: '',
pendingEdge: null as Edge | null,
})
// portInput is the plain text control for the remote port in the dialog.
const portInput = ref('')
// groupInput is the management-group text control (forwards page grouping).
const groupInput = ref('')
// openPortDlg opens the port editor dialog, seeding the input.
const openPortDlg = (
@ -153,6 +185,7 @@ const openPortDlg = (
port: number,
isNew: boolean,
pending?: Edge | null,
group = '',
) => {
portDlg.value = {
visible: true,
@ -160,9 +193,43 @@ const openPortDlg = (
local,
remote,
remotePort: port,
group,
pendingEdge: pending ?? null,
}
portInput.value = String(port)
groupInput.value = group
}
// ---- auto-layout ----
// Collapsed cards render ~200px tall (the localOnly hint wraps to several
// lines), so the old 140/160 vertical step made neighbours overlap. EST_* is
// the fallback used before the DOM has measured a node (initial load / add);
// autoLayout reads the real offsetHeight so expanded advanced sections stack
// correctly too. offsetHeight ignores the viewport zoom transform, so the
// measurement is zoom-independent.
const LAYOUT = { localX: 60, remoteX: 760, startY: 90, gap: 24, estLocal: 210, estRemote: 200 }
const measuredHeightOf = (id: string): number => {
const el = document.querySelector<HTMLElement>(`.vue-flow__node[data-id="${id}"]`)
return el ? el.offsetHeight : 0
}
// layoutColumns stacks local nodes in the left column and remote nodes in the
// right column, each offset by its height + gap so cards never overlap.
const layoutColumns = (useMeasured: boolean) => {
let ly = LAYOUT.startY
let ry = LAYOUT.startY
for (const n of nodes.value) {
if (n.type === 'local') {
const h = (useMeasured && measuredHeightOf(n.id)) || LAYOUT.estLocal
n.position = { x: LAYOUT.localX, y: ly }
ly += h + LAYOUT.gap
} else {
const h = (useMeasured && measuredHeightOf(n.id)) || LAYOUT.estRemote
n.position = { x: LAYOUT.remoteX, y: ry }
ry += h + LAYOUT.gap
}
}
}
const localOf = (name: string) =>
@ -259,8 +326,8 @@ const addLocal = () => {
id: nodeId('local', name),
type: 'local',
position: {
x: 60,
y: 90 + nodes.value.filter((n) => n.type === 'local').length * 140,
x: LAYOUT.localX,
y: LAYOUT.startY + nodes.value.filter((n) => n.type === 'local').length * (LAYOUT.estLocal + LAYOUT.gap),
},
data,
})
@ -281,8 +348,8 @@ const addRemote = () => {
id: nodeId('remote', name),
type: 'remote',
position: {
x: 760,
y: 90 + nodes.value.filter((n) => n.type === 'remote').length * 160,
x: LAYOUT.remoteX,
y: LAYOUT.startY + nodes.value.filter((n) => n.type === 'remote').length * (LAYOUT.estRemote + LAYOUT.gap),
},
data,
})
@ -338,6 +405,7 @@ const confirmPort = () => {
return
}
d.remotePort = parsed
d.group = groupInput.value.trim()
if (d.isNew && d.pendingEdge) {
const newId = edgeId(
d.pendingEdge.source,
@ -347,6 +415,7 @@ const confirmPort = () => {
const existing = edges.value.find((e) => e.id === newId)
if (existing) {
existing.label = String(d.remotePort)
existing.data = { ...(existing.data as object), group: d.group }
} else {
edges.value.push({
id: newId,
@ -355,7 +424,7 @@ const confirmPort = () => {
label: String(d.remotePort),
type: 'portedge',
animated: false,
data: {},
data: { group: d.group },
})
assignLayers()
}
@ -373,6 +442,7 @@ const confirmPort = () => {
old.id = newId
}
old.label = String(d.remotePort)
old.data = { ...(old.data as object), group: d.group }
}
portDlg.value.visible = false
dirty.value = true
@ -392,6 +462,7 @@ const editEdge = (edge: Edge) => {
Number(edge.label) || (loc ? loc.port : 0),
false,
edge,
(edge.data as any)?.group ?? '',
)
}
@ -417,6 +488,49 @@ const onLabelDrag = ({
}
}
// onToggleDisabled flips the edge's per-forward stop flag from the canvas
// edge badge. A disabled forward is omitted from the frpc config (local) /
// left out of the ring topology (cluster). Marks the canvas dirty so save
// persists the toggle. The "禁/通" badge on the edge is the quick entry;
// the full port editor also carries a disabled checkbox.
const onToggleDisabled = ({ edgeId }: { edgeId: string }) => {
const e = edges.value.find((x) => x.id === edgeId)
if (!e) return
e.data = { ...(e.data as object), disabled: !((e.data as any)?.disabled) }
dirty.value = true
}
// onEditGroup opens a prompt to set the edge's group label (a management
// label for one-click group start/stop on the status page; unrelated to
// frps load-balancing lbGroup). Lists existing group names for quick
// selection; empty input clears the group (moves the forward to 未分组).
const onEditGroup = async ({ edgeId }: { edgeId: string }) => {
const e = edges.value.find((x) => x.id === edgeId)
if (!e) return
const existing = new Set<string>()
for (const ed of edges.value) {
const g = (ed.data as any)?.group ?? ''
if (g) existing.add(g)
}
const hint = existing.size
? `现有分组:${[...existing].join('、')}(留空=移出分组)`
: '输入分组名称(留空=移出分组)'
const cur = (e.data as any)?.group ?? ''
try {
const { value } = await ElMessageBox.prompt(hint, '编辑分组', {
inputValue: cur,
inputPlaceholder: '分组名称',
confirmButtonText: '确定',
cancelButtonText: '取消',
})
const g = (value || '').trim()
e.data = { ...(e.data as object), group: g }
dirty.value = true
} catch {
// user cancelled — no change
}
}
// assignLayers re-computes the layer (per-pair index) of every edge so custom
// port edges bend away from their siblings. Returns the same array mutated in
// place; also used to keep ordering consistent.
@ -491,19 +605,7 @@ const remoteNodes = computed(() =>
nodes.value.filter((n) => n.type === 'remote'),
)
const autoLayout = () => {
let li = 0
let ri = 0
for (const n of nodes.value) {
if (n.type === 'local') {
n.position = { x: 60, y: 90 + li * 140 }
li++
} else {
n.position = { x: 760, y: 90 + ri * 160 }
ri++
}
}
}
const autoLayout = () => layoutColumns(true)
const load = async () => {
loading.value = true
@ -511,26 +613,23 @@ const load = async () => {
const data = await api.canvas()
nodes.value = []
edges.value = []
let li = 0
let ri = 0
for (const l of data.locals || []) {
nodes.value.push({
id: nodeId('local', l.name),
type: 'local',
position: { x: 60, y: 90 + li * 140 },
position: { x: LAYOUT.localX, y: LAYOUT.startY },
data: { ...l },
})
li++
}
for (const r of data.remotes || []) {
nodes.value.push({
id: nodeId('remote', r.name),
type: 'remote',
position: { x: 760, y: 90 + ri * 160 },
position: { x: LAYOUT.remoteX, y: LAYOUT.startY },
data: { ...r },
})
ri++
}
layoutColumns(false)
for (const link of data.links || []) {
const s = nodeId('local', link.local)
const t = nodeId('remote', link.remote)
@ -547,7 +646,12 @@ const load = async () => {
label: String(rp),
type: 'portedge',
animated: false,
data: { offsetX: link.offsetX ?? 0, offsetY: link.offsetY ?? 0 },
data: {
offsetX: link.offsetX ?? 0,
offsetY: link.offsetY ?? 0,
group: link.group ?? '',
disabled: link.disabled ?? false,
},
})
}
assignLayers()
@ -575,6 +679,8 @@ const buildPayload = () => {
remotePort: isNaN(rp) || rp <= 0 ? 0 : rp,
offsetX: (e.data as any)?.offsetX ?? 0,
offsetY: (e.data as any)?.offsetY ?? 0,
group: (e.data as any)?.group ?? '',
disabled: (e.data as any)?.disabled ?? false,
})
}
return { locals, remotes, links }
@ -597,7 +703,7 @@ const save = async () => {
nodes.value.push({
id,
type: 'local',
position: posOf.get(id) || { x: 60, y: 90 + li * 140 },
position: posOf.get(id) || { x: LAYOUT.localX, y: LAYOUT.startY + li * (LAYOUT.estLocal + LAYOUT.gap) },
data: { ...l },
})
li++
@ -607,7 +713,7 @@ const save = async () => {
nodes.value.push({
id,
type: 'remote',
position: posOf.get(id) || { x: 760, y: 90 + ri * 160 },
position: posOf.get(id) || { x: LAYOUT.remoteX, y: LAYOUT.startY + ri * (LAYOUT.estRemote + LAYOUT.gap) },
data: { ...r },
})
ri++
@ -623,7 +729,12 @@ const save = async () => {
target: t,
label: String(rp),
type: 'portedge',
data: { offsetX: link.offsetX ?? 0, offsetY: link.offsetY ?? 0 },
data: {
offsetX: link.offsetX ?? 0,
offsetY: link.offsetY ?? 0,
group: link.group ?? '',
disabled: link.disabled ?? false,
},
})
}
assignLayers()
@ -636,7 +747,79 @@ const save = async () => {
}
}
onMounted(load)
// ---- canvas export / import (转发表 备份/还原) ----
const importInput = ref<HTMLInputElement | null>(null)
// exportCanvas downloads the full canvas as a metadata-wrapped JSON bundle.
// Read-level: auditors can export the current table at any time.
const exportCanvas = async () => {
try {
const env = await api.exportCanvas()
const blob = new Blob([JSON.stringify(env, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `webui4frpc-canvas-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '')}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
ElMessage.success('已导出转发表')
} catch (e: any) {
ElMessage.error('导出失败: ' + (e.message || e))
}
}
const pickImport = () => {
importInput.value?.click()
}
// onImportFile reads a chosen JSON file (envelope or bare canvas), confirms the
// overwrite, then POSTs it to /canvas/import (full-replace) and reloads.
const onImportFile = async (ev: Event) => {
const input = ev.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
try {
const text = await file.text()
const parsed = JSON.parse(text)
// Accept either the { _type, canvas } envelope or a bare { locals, remotes, links }.
const canvas: CanvasData | undefined =
parsed?._type === 'webui4frpc-canvas' ? parsed.canvas : parsed?.locals ? parsed : undefined
if (!canvas) {
ElMessage.warning('文件格式不符:需为导出的信封或裸画布 JSON')
return
}
await ElMessageBox.confirm(
`导入将覆盖当前连接配置(${canvas.locals?.length ?? 0} 本地 / ${canvas.remotes?.length ?? 0} 远程 / ${canvas.links?.length ?? 0} 连线)。继续?`,
'导入转发表',
{ type: 'warning' },
)
await api.importCanvas(parsed as CanvasData | CanvasExportEnvelope)
await load()
ElMessage.success('已导入并应用')
} catch (e: any) {
if (e === 'cancel' || e?.action === 'cancel') return
ElMessage.error('导入失败: ' + (e.message || e))
} finally {
// reset so picking the same file again re-fires change
if (input) input.value = ''
}
}
onMounted(() => {
load()
resolvePatternColor()
themeObserver = new MutationObserver(resolvePatternColor)
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme'],
})
})
onBeforeUnmount(() => {
themeObserver?.disconnect()
themeObserver = null
})
</script>
<style scoped lang="scss">
@ -692,24 +875,24 @@ onMounted(load)
}
.btn.local-add {
background: #4caf50;
background: var(--w4f-ok);
color: #fff;
&:hover {
background: #43a047;
background: color-mix(in srgb, var(--w4f-ok) 82%, var(--w4f-fg));
}
}
.btn.remote-add {
background: #ff9800;
background: var(--w4f-warning);
color: #fff;
&:hover {
background: #f57c00;
background: color-mix(in srgb, var(--w4f-warning) 82%, var(--w4f-fg));
}
}
.btn.warn {
background: #78909c;
background: var(--w4f-secondary);
color: #fff;
&:hover {
background: #607d8b;
background: var(--w4f-secondary-h);
}
}
.btn.save {
@ -720,6 +903,29 @@ onMounted(load)
}
}
.btn.ghost {
background: transparent;
color: $color-text-muted;
border: 1px solid $color-border;
&:hover:not(:disabled) {
background: $color-glass-bg-2;
color: $color-text-primary;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.hidden-input {
position: absolute;
width: 0;
height: 0;
opacity: 0;
overflow: hidden;
pointer-events: none;
}
.save-hint {
font-size: $font-size-sm;
color: $color-text-muted;
@ -734,7 +940,7 @@ onMounted(load)
.flow-canvas {
width: 100%;
height: 100%;
background: #f5f7fa;
background: var(--w4f-bg-muted);
}
@ -761,8 +967,8 @@ onMounted(load)
}
:deep(.vue-flow__edge-label) {
background: rgba(0, 0, 0, 0.72);
color: #fff;
background: color-mix(in srgb, var(--w4f-fg) 75%, transparent);
color: var(--w4f-bg-2);
border-radius: 10px;
padding: 2px 8px;
font-size: 12px;
@ -773,7 +979,7 @@ onMounted(load)
:deep(.vue-flow__handle) {
width: 12px;
height: 12px;
border: 2px solid #fff;
border: 2px solid var(--w4f-card-solid);
}
:deep(.vue-flow__node) {

View File

@ -20,7 +20,7 @@
<div class="hero w4f-card">
<div class="hero-left">
<h2 class="page-title">集群令牌环</h2>
<span class="page-sub">令牌环网 · 单轮一周期 · 画布差异命令进令牌 · 增量日志同步</span>
<span class="page-sub">令牌环网 · 转发编排</span>
</div>
<div class="hero-stats" v-if="ring">
<span class="w4f-tag" :class="stateTagClass"><span class="w4f-dot" :class="stateDotClass" />{{ stateLabel }}</span>
@ -28,6 +28,14 @@
<span class="w4f-tag w4f-tag-primary" :title="'cycle ' + ring.cycle">上次同步 <b>{{ lastSyncAgo }}</b></span>
<span v-if="isLeader" class="w4f-tag w4f-tag-primary"><span class="crown">♔</span> 本机 leader</span>
</div>
<!-- nodeKey: this node's cluster admission key. Other nodes must present it
to join via this node. Shown here for the operator to copy. -->
<div class="nodekey-bar" v-if="ring?.nodeKey">
<span class="nk-label">本节点加入密钥</span>
<code class="nk-value">{{ ring.nodeKey }}</code>
<button class="w4f-btn ghost small" @click="copyNodeKey">复制</button>
<span class="nk-hint">其他节点通过本节点加入集群时需提供此密钥</span>
</div>
</div>
<!-- KPI row -->
@ -40,17 +48,14 @@
<div class="w4f-kpi">
<div class="k-hdr"><span class="k-lab"><span class="w4f-dot" />活跃转发</span><span class="k-ic"></span></div>
<div class="k-val">{{ ring.topology.length }}</div>
<div class="k-sub">已 claim 的集群转发</div>
</div>
<div class="w4f-kpi">
<div class="k-hdr"><span class="k-lab"><span class="w4f-dot" :class="{ pending: ring.pending.length > 0 }" />待办命令</span><span class="k-ic"></span></div>
<div class="k-val">{{ ring.pending.length }}</div>
<div class="k-sub">随令牌环行待执行</div>
</div>
<div class="w4f-kpi">
<div class="k-hdr"><span class="k-lab"><span class="w4f-dot" :class="{ err: lastSyncStale }" />上次同步</span><span class="k-ic"></span></div>
<div class="k-val">{{ lastSyncAgo }}</div>
<div class="k-sub">令牌环心跳 · 停滞即异常</div>
</div>
</div>
@ -72,7 +77,7 @@
<div v-if="ring" class="cluster-body">
<!-- Ring topology -->
<section class="section w4f-card">
<h3 class="section-title"><span class="k-ic">○</span>环拓扑<span class="hint">令牌传递链 · leader 青色 · 本机 sakura 高亮 · 离线标红 · 非本机节点可移除</span></h3>
<h3 class="section-title"><span class="k-ic"></span>环拓扑</h3>
<div class="ring-line">
<template v-for="(n, i) in ring.nodes" :key="n.id">
<div class="ring-node" :class="{ leader: n.id === ring.leaderId, dead: !n.alive, self: n.id === ring.selfId }">
@ -97,7 +102,7 @@
<!-- Pending token commands -->
<section class="section w4f-card" v-if="ring.pending.length">
<h3 class="section-title"><span class="k-ic">⚙</span>令牌命令(待执行)<span class="hint">画布差异 / 节点移除产生,随令牌环行由对应节点执行</span></h3>
<h3 class="section-title"><span class="k-ic"></span>令牌命令待执行</h3>
<div class="task-list">
<div v-for="t in ring.pending" :key="t.id" class="task-row" :class="taskClass(t)">
<span class="tk-id">{{ t.id }}</span>
@ -118,7 +123,7 @@
<!-- Active forward topology -->
<section class="section w4f-card">
<h3 class="section-title"><span class="k-ic">→</span>活跃转发拓扑<span class="hint">每个转发由 owner 节点持有 · localOnly 转发只在本节点可见</span></h3>
<h3 class="section-title"><span class="k-ic"></span>活跃转发拓扑</h3>
<div class="topo-list">
<div v-for="t in ring.topology" :key="t.taskId" class="topo-row">
<span class="tp-id">{{ t.taskId }}</span>
@ -135,12 +140,31 @@
<!-- Cluster log -->
<section class="section w4f-card">
<h3 class="section-title"><span class="k-ic">≣</span>集群日志(增量同步)<span class="hint">追加式,随令牌 delta 重放到全网一致</span></h3>
<h3 class="section-title">
<span class="k-ic"></span>集群日志
<span class="log-count" v-if="ring.log.length">{{ ring.log.length }} </span>
<div class="title-actions">
<el-dropdown trigger="click" @command="exportLog" :disabled="!ring.log.length">
<button class="w4f-btn ghost small" :disabled="!ring.log.length"> 导出</button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="json">JSON完整结构</el-dropdown-item>
<el-dropdown-item command="txt">文本可读 TSV</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<button class="w4f-btn ghost small" :disabled="workerLogBusy" @click="exportWorkerLogs">
导出 worker 日志
</button>
</div>
</h3>
<div class="log-list">
<div v-for="(e, i) in logView" :key="i" class="log-row" :class="logKindClass(e.kind)">
<div v-for="e in logView" :key="e.seq" class="log-row" :class="logKindClass(e.kind)">
<span class="lg-seq">#{{ e.seq }}</span>
<span class="lg-time">{{ formatTs(e.at) }}</span>
<span class="lg-node" :class="{ self: e.node === ring.selfId }">{{ shortId(e.node) }}</span>
<span class="lg-kind">{{ kindLabel(e.kind) }}</span>
<span class="lg-detail">{{ detailOf(e) }}</span>
</div>
<div v-if="!logView.length" class="empty">日志为空</div>
</div>
@ -152,12 +176,16 @@
<el-dialog v-model="joinDialog.visible" title="加入集群" width="420px">
<div class="form-row">
<label>对端地址</label>
<el-input v-model="joinDialog.addr" placeholder="host:portnode-a:7500" @keyup.enter="onJoin" />
<el-input v-model="joinDialog.addr" placeholder="对端 IP:port192.168.1.10:7500" @keyup.enter="onJoin" />
</div>
<div class="form-hint">本节点将向对端请求加入并采纳其环状态,令牌到达即并入环。</div>
<div class="form-row">
<label>加入密钥</label>
<el-input v-model="joinDialog.key" placeholder="对端节点的加入密钥nodeKey" @keyup.enter="onJoin" />
</div>
<div class="form-hint">填入对端节点可被本机路由到达的真实 IP:port或域名:port本节点将向对端请求加入并采纳其环状态令牌到达即并入环需提供对端节点的加入密钥以通过验证</div>
<template #footer>
<button class="w4f-btn ghost small" @click="joinDialog.visible = false">取消</button>
<button class="w4f-btn small" :disabled="busy || !joinDialog.addr.trim()" @click="onJoin">加入</button>
<button class="w4f-btn small" :disabled="busy || !joinDialog.addr.trim() || !joinDialog.key.trim()" @click="onJoin">加入</button>
</template>
</el-dialog>
</div>
@ -167,13 +195,14 @@
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { ElMessage } from 'element-plus'
import { api } from '../api'
import type { RingSnapshot, RingTaskInfo } from '../types'
import type { RingSnapshot, RingTaskInfo, RingLogEntry } from '../types'
const ring = ref<RingSnapshot | null>(null)
const busy = ref(false)
let timer: number | null = null
let visHandler: (() => void) | null = null
const joinDialog = reactive({ visible: false, addr: '' })
const joinDialog = reactive({ visible: false, addr: '', key: '' })
const load = async () => {
try { ring.value = await api.ring() } catch { /* keep last */ }
@ -181,6 +210,20 @@ const load = async () => {
const shortId = (id: string) => (id.length > 22 ? '…' + id.slice(-20) : id)
// copyNodeKey copies this node's admission key to the clipboard so the operator
// can paste it into another node's "加入集群" dialog. Falls back to a select+
// execCommand on browsers without async clipboard (non-secure contexts).
const copyNodeKey = async () => {
const key = ring.value?.nodeKey
if (!key) return
try {
await navigator.clipboard.writeText(key)
ElMessage.success('已复制加入密钥')
} catch {
ElMessage.error('复制失败,请手动选中复制')
}
}
// ---- state derivation (safety gates) ----
const selfInRing = computed(() => !!ring.value?.nodes.some((n) => n.id === ring.value?.selfId))
const aliveCount = computed(() => ring.value?.nodes.filter((n) => n.alive).length ?? 0)
@ -249,6 +292,90 @@ const logKindClass = (k: string) => {
}
const logView = computed(() => [...(ring.value?.log ?? [])].reverse())
// ---- log formatting + export ----
// formatTs renders the entry timestamp (unix sec) as HH:MM:SS (local tz) for
// a compact log feed; the raw epoch is preserved in the exported JSON.
const formatTs = (at?: number): string => {
if (!at) return '--:--:--'
const dt = new Date(at * 1000)
const p = (n: number) => String(n).padStart(2, '0')
return `${p(dt.getHours())}:${p(dt.getMinutes())}:${p(dt.getSeconds())}`
}
// detailOf derives a one-line human summary from each kind's payload (Data).
// forward.add/remove → "local → remote · taskId"; node.join → "node @ addr";
// node.leave → "node"; leader.change → "→ leader".
const detailOf = (e: RingLogEntry): string => {
const d = (e.data || {}) as Record<string, any>
switch (e.kind) {
case 'forward.add':
case 'forward.remove':
return `${d.local || '?'}${d.remote || '?'}${d.taskId ? ' · ' + String(d.taskId).slice(-8) : ''}`
case 'node.join':
return `${d.node || '?'} @ ${d.addr || '?'}`
case 'node.leave':
return `${d.node || '?'}`
case 'leader.change':
return `${d.leader || '?'}`
default:
return d && Object.keys(d).length ? JSON.stringify(d) : ''
}
}
// exportLog downloads the FULL cluster log (snapshot carries every entry) as
// either pretty JSON (lossless) or a readable TSV text file, sorted by seq.
const exportLog = (fmt: string) => {
const entries = [...(ring.value?.log ?? [])].sort((a, b) => a.seq - b.seq)
if (!entries.length) return
let content = ''
let mime = 'text/plain'
let ext = 'log'
if (fmt === 'json') {
content = JSON.stringify(entries, null, 2)
mime = 'application/json'
ext = 'json'
} else {
const header = '#seq\ttime\tnode\tkind\tdetail'
const lines = entries.map((e) =>
`#${e.seq}\t${formatTs(e.at)}\t${shortId(e.node)}\t${e.kind}\t${detailOf(e)}`,
)
content = [header, ...lines].join('\n')
}
const blob = new Blob([content], { type: mime })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `cluster-log-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '')}.${ext}`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
// exportWorkerLogs fans out across the ring to gather every node's frpc worker
// logs (the actual forwards running on each node) into one downloadable JSON.
// Read-level: auditors use this to inspect worker output cluster-wide.
const workerLogBusy = ref(false)
const exportWorkerLogs = async () => {
workerLogBusy.value = true
try {
const bundle = await api.exportWorkerLogs()
const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `worker-logs-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '')}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
const reachable = (bundle.nodes || []).filter((n) => n.ok).length
ElMessage.success(`已导出 worker 日志(${reachable}/${bundle.nodes?.length ?? 0} 节点)`)
} catch (e: any) {
ElMessage.error('导出失败: ' + (e.message || e))
} finally {
workerLogBusy.value = false
}
}
const taskClass = (t: RingTaskInfo) => (t.removeNode ? 'remove' : t.revoke ? 'revoke' : 'pending')
const taskCmdLabel = (t: RingTaskInfo) => (t.removeNode ? '移除节点' : t.revoke ? '撤销' : '新增')
@ -267,11 +394,14 @@ const onCreate = async () => {
}
const onJoin = async () => {
const addr = joinDialog.addr.trim()
if (!addr) return
const key = joinDialog.key.trim()
if (!addr || !key) return
busy.value = true
try {
ring.value = await api.clusterJoinRing(addr)
ring.value = await api.clusterJoinRing(addr, key)
joinDialog.visible = false
joinDialog.addr = ''
joinDialog.key = ''
ElMessage.success(`已加入 ${addr}`)
} catch (e: any) {
ElMessage.error('加入失败: ' + (e.message || e))
@ -310,12 +440,19 @@ const onRemoveNode = async (id: string) => {
onMounted(() => {
load()
timer = window.setInterval(load, 3000)
// 2s cadence so the cluster ring (pending→topology transitions, worker
// spawns) reflects near-real-time; visibilitychange reloads on tab refocus.
timer = window.setInterval(load, 2000)
tick = window.setInterval(() => { now.value = Date.now() }, 1000)
visHandler = () => {
if (!document.hidden) load()
}
document.addEventListener('visibilitychange', visHandler)
})
onBeforeUnmount(() => {
if (timer !== null) { clearInterval(timer); timer = null }
if (tick !== null) { clearInterval(tick); tick = null }
if (visHandler) { document.removeEventListener('visibilitychange', visHandler); visHandler = null }
})
</script>
@ -331,6 +468,12 @@ onBeforeUnmount(() => {
.hero-stats .w4f-tag b { color: $color-text-primary; font-weight: 800; }
.crown { color: var(--w4f-primary-h); }
/* nodeKey admission-key bar */
.nodekey-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 12px; padding-top: 12px; border-top: 1px dashed var(--w4f-line); width: 100%; }
.nk-label { font-size: 12px; color: $color-text-muted; white-space: nowrap; }
.nk-value { font-family: var(--w4f-mono); font-size: 12px; background: var(--w4f-card-2); border: 1px solid var(--w4f-line); border-radius: $radius-xs; padding: 3px 8px; user-select: all; letter-spacing: 0.02em; }
.nk-hint { font-size: 11px; color: $color-text-light; }
/* KPI */
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(168px, 1fr)); gap: 14px; }
@ -344,7 +487,6 @@ onBeforeUnmount(() => {
.section-title { margin: 0 0 12px; font-size: 13.5px; font-weight: 700; color: $color-text-muted; letter-spacing: 0.02em; display: flex; align-items: center; gap: 10px;
.k-ic { width: 26px; height: 26px; border-radius: 9px; display: grid; place-items: center; font-size: 13px;
background: linear-gradient(135deg, var(--w4f-primary-50), var(--w4f-secondary-50)); color: var(--w4f-primary-h); }
.hint { font-size: 11px; font-weight: 400; color: $color-text-muted; margin-left: auto; }
}
/* ring topology */
@ -366,6 +508,7 @@ onBeforeUnmount(() => {
/* tasks / topology / log lists */
.task-list, .topo-list, .log-list { display: flex; flex-direction: column; gap: 6px; }
.log-list { max-height: 340px; overflow-y: auto; padding-right: 2px; }
.task-row, .topo-row { display: flex; align-items: center; gap: 8px; border: 1px solid var(--w4f-line); border-radius: $radius-xs; padding: 8px 11px; font-size: 13px; background: var(--w4f-card-2); box-shadow: var(--w4f-sh-sm); }
.task-row.pending { background: var(--w4f-primary-50); border-color: rgba(255, 127, 172, 0.25); }
.task-row.revoke, .task-row.remove { background: var(--w4f-danger-50); border-color: rgba(219, 54, 148, 0.25); }
@ -374,15 +517,19 @@ onBeforeUnmount(() => {
.tk-arrow { color: var(--w4f-primary); font-weight: 700; }
.tk-port, .tp-port { color: $color-text-muted; font-family: var(--w4f-mono); }
.tp-owner { margin-left: auto; font-size: 11px; color: $color-secondary-h; &.self { color: var(--w4f-primary-h); font-weight: 700; } }
.log-row { display: flex; gap: 10px; font-size: 12px; font-family: var(--w4f-mono); border-bottom: 1px dashed var(--w4f-line); padding: 3px 2px; }
.log-row { display: flex; align-items: center; gap: 10px; font-size: 12px; font-family: var(--w4f-mono); border-bottom: 1px dashed var(--w4f-line); padding: 4px 2px; flex-wrap: wrap; }
.log-row.add .lg-kind { color: $color-success; font-weight: 700; }
.log-row.remove .lg-kind { color: $color-danger; font-weight: 700; }
.log-row.join .lg-kind { color: $color-info; font-weight: 700; }
.log-row.leave .lg-kind { color: $color-warning; font-weight: 700; }
.log-row.leader .lg-kind { color: $color-secondary-h; font-weight: 700; }
.lg-seq { color: $color-text-muted; }
.lg-node { color: $color-text-light; &.self { color: var(--w4f-primary-h); font-weight: 700; } }
.lg-kind { color: $color-text-secondary; }
.lg-seq { color: $color-text-muted; flex: 0 0 auto; }
.lg-time { color: $color-text-light; font-size: 11.5px; flex: 0 0 auto; }
.lg-node { color: $color-text-light; flex: 0 0 auto; &.self { color: var(--w4f-primary-h); font-weight: 700; } }
.lg-kind { color: $color-text-secondary; flex: 0 0 auto; font-weight: 600; }
.lg-detail { margin-left: auto; color: $color-text-secondary; font-size: 11.5px; text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 60%; }
.log-count { font-size: 11px; font-weight: 600; color: $color-text-muted; }
.title-actions { margin-left: auto; display: flex; align-items: center; gap: 6px; }
.empty { color: $color-text-muted; font-size: 13px; padding: 10px; }
.empty-page { color: $color-text-muted; font-size: 14px; padding: 40px; text-align: center; }

View File

@ -281,8 +281,8 @@ onMounted(load)
}
.info-version {
background: rgba(64, 158, 255, 0.12);
color: $color-primary;
background: $color-primary-50;
color: $color-primary-h;
border-radius: 10px;
padding: 2px 8px;
font-size: $font-size-sm;

View File

@ -4,7 +4,7 @@
<div class="topbar w4f-card">
<div class="tb-left">
<h2 class="page-title">状态总览</h2>
<span class="page-sub"> 5 秒自动刷新 · frpc worker 进程态 + frps 连接态</span>
<span class="page-sub"> 2.5 秒自动刷新</span>
</div>
<div class="tb-right">
<button class="w4f-btn ghost small" @click="loadStatus"> 刷新</button>
@ -37,9 +37,57 @@
</div>
<div class="status-body" v-if="status">
<!-- 远程节点状态 -->
<!-- 转发 (forward-centric): 本地/远程 by localOnly, per-card 启停, 分组一键 -->
<section v-for="g in forwardsByGroup" :key="g.key || '__none__'" class="section">
<h3 class="section-title">
<span class="k-ic"></span>{{ g.label }}
<span class="grp-count">{{ g.items.length }} </span>
<span class="grp-actions">
<button class="w4f-btn small" @click="groupStart(g.key)">一键启动整组</button>
<button class="w4f-btn ghost small" @click="groupStop(g.key)">一键停止整组</button>
<button v-if="g.key" class="w4f-btn ghost small danger" @click="deleteGroup(g.key)">删除分组</button>
</span>
</h3>
<div class="card-grid">
<div
v-for="f in g.items"
:key="f.local + ':' + f.remote + ':' + f.remotePort"
class="fwd-card w4f-card"
:class="{ active: f.active, stopped: f.disabled }"
>
<div class="fwd-head">
<span class="fwd-kind" :class="f.kind">{{ f.kind === 'local' ? '本地' : '远程' }}</span>
<span
class="fwd-dot"
:class="{ on: f.active }"
:title="f.active ? '运行中' : f.disabled ? '已停止' : '未运行'"
/>
<span class="fwd-route">
<b>{{ f.local }}</b><span class="fwd-ip" v-if="f.localIp"> {{ f.localIp }}:{{ f.localPort }}</span>
<span class="fwd-arrow"></span>
<b>{{ f.remote }}</b>:<span class="fwd-port">{{ f.remotePort }}</span>
</span>
<span v-if="f.localProto" class="w4f-tag w4f-tag-primary">{{ f.localProto }}</span>
<span
class="w4f-tag w4f-tag-info group-chip"
@click="editGroup(f)"
:title="'点击修改分组(当前:' + (f.group || '未分组') + ''"
>{{ f.group || '未分组' }}</span>
<span v-if="f.kind === 'remote'" class="fwd-owner" :title="f.ownerId || ''">{{ ownerLabel(f) }}</span>
<span class="fwd-actions">
<button v-if="!f.active" class="w4f-btn small" @click="startForward(f)">启动转发</button>
<button v-else class="w4f-btn ghost small" @click="stopForward(f)">停止转发</button>
</span>
</div>
</div>
<div v-if="!g.items.length" class="empty">该组暂无转发</div>
</div>
</section>
<div v-if="!status.forwards.length" class="empty">尚未配置转发 前往连接配置画布编排 localremote 连线</div>
<!-- 远程 frps 节点 (只读连接态; 启停已移至转发卡) -->
<section class="section">
<h3 class="section-title"><span class="k-ic"></span>远程节点状态<span class="hint">管理本地 frpc worker 进程不管理远程 frps</span></h3>
<h3 class="section-title"><span class="k-ic"></span>远程节点状态</h3>
<div class="card-grid">
<div
v-for="p in status.profiles"
@ -53,8 +101,6 @@
<span class="w4f-dot" :class="connDotClass(p.connState)" />{{ connStateLabel(p.connState) }}
</span>
<span class="nc-actions">
<button v-if="p.process.state === 'running'" class="w4f-btn ghost small" @click="stopRemote(p.name)"> worker</button>
<button v-else class="w4f-btn small" @click="startRemote(p.name)"> worker</button>
<button class="w4f-btn ghost small" @click="openEdit(p)">编辑</button>
<button class="w4f-btn danger small" @click="removeRemote(p.name)">删除</button>
</span>
@ -95,15 +141,16 @@
</div>
</section>
<!-- 本地服务转发状态 -->
<!-- 本地服务 (overview) -->
<section class="section">
<h3 class="section-title"><span class="k-ic"></span>本地服务转发<span class="hint">本地服务经 frpc worker 转发至远程 frps</span></h3>
<h3 class="section-title"><span class="k-ic"></span>本地服务</h3>
<div class="card-grid">
<div v-for="ls in status.localStatus" :key="ls.local.name" class="local-card w4f-card">
<div class="lc-head">
<span class="lc-name">{{ ls.local.name }}</span>
<span class="lc-addr">{{ ls.local.ip }}:{{ ls.local.port }}</span>
<span class="w4f-tag w4f-tag-primary">{{ ls.local.protocol }}</span>
<span v-if="ls.local.localOnly" class="w4f-tag w4f-tag-ok">本地</span>
</div>
<div class="lc-targets">
<div v-for="t in ls.targets" :key="t.remote + ':' + t.remotePort" class="lc-target">
@ -157,12 +204,13 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { api } from '../api'
import type { Remote, StatusResp } from '../types'
import type { ForwardStatus, Remote, StatusResp } from '../types'
const status = ref<StatusResp | null>(null)
let timer: number | null = null
let visHandler: (() => void) | null = null
interface RemoteDialog {
visible: boolean
@ -216,24 +264,93 @@ const saveRemote = async () => {
}
}
const startRemote = async (name: string) => {
// ---- Per-forward start/stop (forwards page) ----
// local-only → toggle local frpc worker (render skips disabled proxies);
// cluster → ring submit/revoke. Idempotent on the backend.
const startForward = async (f: ForwardStatus) => {
try {
await api.profileStart(name)
await api.forwardStart(f.local, f.remote, f.remotePort)
await loadStatus()
} catch (e: any) {
ElMessage.error('启动失败: ' + (e.message || e))
ElMessage.error('启动转发失败: ' + (e.message || e))
}
}
const stopForward = async (f: ForwardStatus) => {
try {
await api.forwardStop(f.local, f.remote, f.remotePort)
await loadStatus()
} catch (e: any) {
ElMessage.error('停止转发失败: ' + (e.message || e))
}
}
const groupStart = async (group: string) => {
try {
await api.groupStart(group)
await loadStatus()
} catch (e: any) {
ElMessage.error('启动分组失败: ' + (e.message || e))
}
}
const groupStop = async (group: string) => {
try {
await api.groupStop(group)
await loadStatus()
} catch (e: any) {
ElMessage.error('停止分组失败: ' + (e.message || e))
}
}
const stopRemote = async (name: string) => {
// editGroup opens a prompt to change a single forward's group label from the
// card's group chip. Lists existing group names for quick selection; empty
// input moves the forward to 未分组. Pure DB update — no worker/ring action.
const editGroup = async (f: ForwardStatus) => {
const existing = new Set<string>()
for (const fw of status.value?.forwards ?? []) {
if (fw.group) existing.add(fw.group)
}
const hint = existing.size
? `现有分组:${[...existing].join('、')}(留空=移出分组)`
: '输入分组名称(留空=移出分组)'
try {
await api.profileStop(name)
const { value } = await ElMessageBox.prompt(hint, '修改分组', {
inputValue: f.group || '',
inputPlaceholder: '分组名称',
confirmButtonText: '确定',
cancelButtonText: '取消',
})
const g = (value || '').trim()
await api.assignGroup(f.local, f.remote, f.remotePort, g)
await loadStatus()
} catch (e: any) {
ElMessage.error('停止失败: ' + (e.message || e))
// user cancelled (reject) → no action; real errors show a toast
if (e !== 'cancel' && e?.message) {
ElMessage.error('修改分组失败: ' + (e.message || e))
}
}
}
// deleteGroup dissolves a named group: all members are moved to 未分组. The
// group itself is just a label on links, so clearing all members is the
// complete delete. Confirms first since it affects every forward in the group.
const deleteGroup = async (group: string) => {
if (!window.confirm(`删除分组「${group}」?组内所有转发将移至未分组(转发本身不受影响)。`)) return
try {
await api.deleteGroup(group)
await loadStatus()
ElMessage.success(`已删除分组「${group}`)
} catch (e: any) {
ElMessage.error('删除分组失败: ' + (e.message || e))
}
}
// ownerLabel: human-readable topology owner for a remote (cluster) forward.
const ownerLabel = (f: ForwardStatus) => {
if (f.kind !== 'remote') return ''
if (!f.ownerId) return '待分配'
if (f.ownerId === status.value?.selfId) return '本机持有'
return '持有 ' + f.ownerId
}
const removeRemote = async (name: string) => {
if (!window.confirm(`删除远程节点 ${name}`)) return
try {
@ -257,7 +374,29 @@ const workerHealthy = (s?: string) => s === 'running'
const runningCount = computed(() => status.value?.profiles.filter((p) => p.process.state === 'running').length ?? 0)
const onlineCount = computed(() => status.value?.profiles.filter((p) => p.connState === 'connected').length ?? 0)
const allConnected = computed(() => (status.value?.profiles.length ?? 0) > 0 && onlineCount.value === status.value?.profiles.length)
const activeForwards = computed(() => status.value?.profiles.reduce((s, p) => s + p.forwards.length, 0) ?? 0)
const activeForwards = computed(() => status.value?.forwards.filter((f) => f.active).length ?? 0)
// forwardsByGroup buckets the forward-centric list by Link.group; the empty
// group is labelled 未分组 and sorted last so named groups surface first.
const forwardsByGroup = computed(() => {
const map = new Map<string, ForwardStatus[]>()
for (const f of status.value?.forwards ?? []) {
const key = f.group || ''
let arr = map.get(key)
if (!arr) {
arr = []
map.set(key, arr)
}
arr.push(f)
}
return [...map.entries()]
.map(([key, items]) => ({ key, label: key || '未分组', items }))
.sort((a, b) => {
if (!a.key) return 1
if (!b.key) return -1
return a.label.localeCompare(b.label, 'zh')
})
})
// connState refers to the frps connection from the local frpc worker.
// We never control the remote frps itself.
@ -332,7 +471,14 @@ const remoteVhostPort = (name: string): number =>
onMounted(() => {
loadStatus()
timer = window.setInterval(loadStatus, 5000)
// 2.5s cadence keeps the status page live against cluster dispatch +
// worker state changes; visibilitychange reloads instantly on tab refocus
// so a stale tab never lingers on pre-action data.
timer = window.setInterval(loadStatus, 2500)
visHandler = () => {
if (!document.hidden) loadStatus()
}
document.addEventListener('visibilitychange', visHandler)
})
onBeforeUnmount(() => {
@ -340,6 +486,10 @@ onBeforeUnmount(() => {
window.clearInterval(timer)
timer = null
}
if (visHandler) {
document.removeEventListener('visibilitychange', visHandler)
visHandler = null
}
})
</script>
@ -395,7 +545,6 @@ onBeforeUnmount(() => {
gap: 10px;
.k-ic { width: 26px; height: 26px; border-radius: 9px; display: grid; place-items: center; font-size: 13px;
background: linear-gradient(135deg, var(--w4f-primary-50), var(--w4f-secondary-50)); color: var(--w4f-primary-h); }
.hint { font-size: 11px; font-weight: 400; color: $color-text-muted; margin-left: auto; }
}
.card-grid {
@ -413,6 +562,8 @@ onBeforeUnmount(() => {
.nc-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.nc-name { font-weight: 800; font-size: 15px; margin-right: auto; }
.nc-actions { display: flex; gap: 6px; flex-wrap: wrap; }
.nc-cluster-note { font-size: 11.5px; color: $color-text-muted; padding: 3px 9px; border-radius: $radius-pill;
background: var(--w4f-card-2); cursor: help; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nc-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.meta-pill { font-size: 11.5px; padding: 2px 9px; border-radius: $radius-pill; background: var(--w4f-card-2); color: $color-text-muted;
font-family: var(--w4f-mono); max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
@ -436,6 +587,37 @@ onBeforeUnmount(() => {
.np-err { margin-left: auto; font-size: 11px; color: $color-danger; max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nc-proxies-empty { margin-top: 8px; font-size: 12px; color: $color-text-muted; }
/* forward card (转发为单元) */
.fwd-card {
padding: 12px 14px;
border-left: 4px solid var(--w4f-muted);
transition: border-color 0.18s var(--w4f-ease-spring);
&.active { border-left-color: var(--w4f-ok); }
&.stopped { border-left-color: var(--w4f-danger); opacity: 0.78; }
}
.fwd-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.fwd-kind {
font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: $radius-pill;
color: var(--w4f-muted); background: var(--w4f-card-2);
&.local { color: var(--w4f-ok-h, #fff); background: var(--w4f-ok-50, var(--w4f-primary-50)); }
&.remote { color: var(--w4f-secondary-h); background: var(--w4f-secondary-50); }
}
.fwd-dot {
width: 9px; height: 9px; border-radius: 50%; flex: 0 0 auto;
background: var(--w4f-muted); box-shadow: 0 0 0 3px var(--w4f-card-2);
&.on { background: var(--w4f-ok); box-shadow: 0 0 0 3px var(--w4f-ok-50, var(--w4f-primary-50)); }
}
.fwd-route { font-size: 13.5px; display: flex; align-items: center; gap: 5px; margin-right: auto; }
.fwd-ip { font-size: 11.5px; color: $color-text-muted; font-family: var(--w4f-mono); }
.fwd-arrow { color: var(--w4f-primary); font-weight: 800; }
.fwd-port { font-family: var(--w4f-mono); color: $color-text-secondary; }
.fwd-owner { font-size: 11.5px; color: $color-text-muted; padding: 2px 8px; border-radius: $radius-pill;
background: var(--w4f-card-2); max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fwd-actions { display: flex; gap: 6px; }
.grp-count { font-size: 12px; color: $color-text-muted; font-weight: 600; }
.grp-actions { margin-left: auto; display: flex; gap: 6px; }
.group-chip { cursor: pointer; transition: background $transition-fast; &:hover { background: color-mix(in srgb, var(--w4f-info) 30%, transparent); } }
/* local service card */
.local-card { padding: 14px 16px; }
.lc-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }

347
web/src/views/UsersView.vue Normal file
View File

@ -0,0 +1,347 @@
<!--
UsersView account + API key management (admin only).
Two tables: built-in/manual users (system rows are read-only, managed by
-user/-password flags) and scoped API keys (plaintext shown once at create).
Reached only when isAdmin; App.vue hides the nav entry for non-admins.
-->
<template>
<div class="users-view">
<!-- ---- accounts ---- -->
<section class="card">
<header class="card-h">
<h3>账号</h3>
<el-button type="primary" size="small" @click="openUserCreate"> 新建账号</el-button>
</header>
<el-table :data="users" size="small" stripe empty-text="暂无账号">
<el-table-column prop="username" label="用户名" min-width="140" />
<el-table-column label="角色" width="110">
<template #default="{ row }">
<el-tag size="small" :type="row.role === 'admin' ? 'danger' : 'info'">
{{ row.role === 'admin' ? '管理员' : '审计(viewer)' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag size="small" :type="row.enabled ? 'success' : 'warning'">
{{ row.enabled ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="来源" width="110">
<template #default="{ row }">
<el-tag v-if="row.system" size="small" type="warning">内置(flag)</el-tag>
<span v-else class="muted">手动</span>
</template>
</el-table-column>
<el-table-column label="创建时间" width="170">
<template #default="{ row }">{{ fmt(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="最后登录" width="170">
<template #default="{ row }">{{ row.lastLoginAt ? fmt(row.lastLoginAt) : '—' }}</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="openUserEdit(row)" :disabled="row.system">编辑</el-button>
<el-button size="small" type="danger" plain @click="onDeleteUser(row)" :disabled="row.system">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</section>
<!-- ---- api keys ---- -->
<section class="card">
<header class="card-h">
<h3>API 密钥</h3>
<el-button type="primary" size="small" @click="openKeyCreate"> 新建密钥</el-button>
</header>
<el-table :data="apiKeys" size="small" stripe empty-text="暂无密钥">
<el-table-column prop="label" label="标签" min-width="140" />
<el-table-column label="作用域" width="100">
<template #default="{ row }">
<el-tag size="small" :type="scopeType(row.scope)">{{ row.scope }}</el-tag>
</template>
</el-table-column>
<el-table-column label="归属用户" width="140">
<template #default="{ row }">{{ userName(row.userId) }}</template>
</el-table-column>
<el-table-column prop="prefix" label="前缀" width="120" />
<el-table-column label="创建时间" width="170">
<template #default="{ row }">{{ fmt(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="最近使用" width="170">
<template #default="{ row }">{{ row.lastUsedAt ? fmt(row.lastUsedAt) : '—' }}</template>
</el-table-column>
<el-table-column label="操作" width="110" fixed="right">
<template #default="{ row }">
<el-button size="small" type="danger" plain @click="onDeleteKey(row)">吊销</el-button>
</template>
</el-table-column>
</el-table>
</section>
<!-- create / edit user dialog -->
<el-dialog v-model="userDialog" :title="editing ? '编辑账号' : '新建账号'" width="420px">
<el-form label-width="86px" @submit.prevent>
<el-form-item label="用户名" v-if="!editing">
<el-input v-model="userForm.username" autocomplete="off" />
</el-form-item>
<el-form-item label="用户名" v-else>
<span class="muted">{{ userForm.username }}</span>
</el-form-item>
<el-form-item :label="editing ? '新密码' : '密码'">
<el-input v-model="userForm.password" type="password" show-password
:placeholder="editing ? '留空则不改' : ''" autocomplete="new-password" />
</el-form-item>
<el-form-item label="角色">
<el-select v-model="userForm.role" :disabled="editing && editing?.system">
<el-option label="管理员 (admin)" value="admin" />
<el-option label="审计 (viewer)" value="viewer" />
</el-select>
</el-form-item>
<el-form-item label="启用" v-if="editing">
<el-switch v-model="userForm.enabled" :disabled="editing?.system" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="userDialog = false">取消</el-button>
<el-button type="primary" @click="submitUser" :loading="userSaving">保存</el-button>
</template>
</el-dialog>
<!-- create key dialog -->
<el-dialog v-model="keyDialog" title="新建 API 密钥" width="440px">
<el-form label-width="86px" @submit.prevent v-if="!createdKey">
<el-form-item label="归属用户">
<el-select v-model="keyForm.userId" placeholder="选择用户">
<el-option v-for="u in users" :key="u.id" :label="u.username" :value="u.id" />
</el-select>
</el-form-item>
<el-form-item label="标签">
<el-input v-model="keyForm.label" placeholder="如 ci-deploy" />
</el-form-item>
<el-form-item label="作用域">
<el-select v-model="keyForm.scope">
<el-option label="read — 只读 + 导出" value="read" />
<el-option label="write — 改转发/worker/设置" value="write" />
<el-option label="admin — 全量 (含账号管理)" value="admin" />
</el-select>
</el-form-item>
</el-form>
<div v-else class="key-result">
<p class="warn">密钥仅显示一次请立即复制保存</p>
<el-input v-model="createdKey" readonly>
<template #append>
<el-button @click="copyKey">复制</el-button>
</template>
</el-input>
<p class="hint">使用方式请求头 <code>Authorization: Bearer &lt;key&gt;</code></p>
</div>
<template #footer>
<el-button @click="keyDialog = false">{{ createdKey ? '关闭' : '取消' }}</el-button>
<el-button v-if="!createdKey" type="primary" @click="submitKey" :loading="keySaving">生成</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { api } from '../api'
import type { ApiKey, ApiKeyCreated, User } from '../types'
const users = ref<User[]>([])
const apiKeys = ref<ApiKey[]>([])
const loadUsers = async () => {
const r = await api.listUsers()
users.value = r.users
}
const loadKeys = async () => {
const r = await api.listApiKeys()
apiKeys.value = r.apiKeys
}
onMounted(async () => {
await Promise.all([loadUsers(), loadKeys()])
})
const fmt = (ts: number) => {
if (!ts) return '—'
return new Date(ts * 1000).toLocaleString('zh-CN', { hour12: false })
}
const userName = (id: number) => users.value.find((u) => u.id === id)?.username ?? `#${id}`
const scopeType = (s: string) => (s === 'admin' ? 'danger' : s === 'write' ? 'warning' : 'info')
// ---- user create/edit ----
const userDialog = ref(false)
const editing = ref<User | null>(null)
const userSaving = ref(false)
const userForm = ref({ username: '', password: '', role: 'viewer' as 'admin' | 'viewer', enabled: true })
const openUserCreate = () => {
editing.value = null
userForm.value = { username: '', password: '', role: 'viewer', enabled: true }
userDialog.value = true
}
const openUserEdit = (u: User) => {
editing.value = u
userForm.value = { username: u.username, password: '', role: u.role, enabled: u.enabled }
userDialog.value = true
}
const submitUser = async () => {
userSaving.value = true
try {
if (editing.value) {
const patch: { password?: string; role: 'admin' | 'viewer'; enabled: boolean } = {
role: userForm.value.role,
enabled: userForm.value.enabled,
}
if (userForm.value.password) patch.password = userForm.value.password
await api.updateUser(editing.value.username, patch)
ElMessage.success('已更新')
} else {
if (!userForm.value.username || !userForm.value.password) {
ElMessage.warning('用户名和密码必填')
userSaving.value = false
return
}
await api.createUser(userForm.value.username, userForm.value.password, userForm.value.role)
ElMessage.success('已创建')
}
userDialog.value = false
await loadUsers()
} catch (e: any) {
ElMessage.error('失败: ' + (e.message || e))
} finally {
userSaving.value = false
}
}
const onDeleteUser = async (u: User) => {
try {
await ElMessageBox.confirm(`确认删除账号 ${u.username}`, '删除账号', { type: 'warning' })
} catch {
return
}
try {
await api.deleteUser(u.username)
ElMessage.success('已删除')
await loadUsers()
} catch (e: any) {
ElMessage.error('删除失败: ' + (e.message || e))
}
}
// ---- api key create ----
const keyDialog = ref(false)
const keySaving = ref(false)
const createdKey = ref('')
const keyForm = ref({ userId: 0 as number, label: '', scope: 'read' as 'read' | 'write' | 'admin' })
const openKeyCreate = () => {
// default to the first non-system user (or first user)
keyForm.value = {
userId: users.value.find((u) => !u.system)?.id ?? users.value[0]?.id ?? 0,
label: '',
scope: 'read',
}
createdKey.value = ''
keyDialog.value = true
}
const submitKey = async () => {
if (!keyForm.value.userId) {
ElMessage.warning('请选择归属用户')
return
}
keySaving.value = true
try {
const r: ApiKeyCreated = await api.createApiKey(keyForm.value.userId, keyForm.value.label, keyForm.value.scope)
createdKey.value = r.key
await loadKeys()
} catch (e: any) {
ElMessage.error('创建失败: ' + (e.message || e))
} finally {
keySaving.value = false
}
}
const copyKey = async () => {
try {
await navigator.clipboard.writeText(createdKey.value)
ElMessage.success('已复制')
} catch {
ElMessage.warning('复制失败,请手动选择')
}
}
const onDeleteKey = async (k: ApiKey) => {
try {
await ElMessageBox.confirm(`确认吊销密钥 ${k.label} (${k.prefix}…)?`, '吊销密钥', { type: 'warning' })
} catch {
return
}
try {
await api.deleteApiKey(k.id)
ElMessage.success('已吊销')
await loadKeys()
} catch (e: any) {
ElMessage.error('吊销失败: ' + (e.message || e))
}
}
</script>
<style scoped>
.users-view {
display: flex;
flex-direction: column;
gap: 20px;
}
.card {
background: var(--w4f-card);
border: 1px solid var(--w4f-line);
border-radius: 16px;
padding: 16px 18px;
box-shadow: var(--w4f-sh-sm);
}
.card-h {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.card-h h3 {
margin: 0;
font-size: 15px;
font-weight: 700;
}
.muted {
color: var(--w4f-muted);
}
.key-result {
display: flex;
flex-direction: column;
gap: 10px;
}
.key-result .warn {
color: var(--w4f-primary-h, #d97706);
font-weight: 600;
margin: 0;
}
.key-result .hint {
margin: 0;
font-size: 12px;
color: var(--w4f-muted);
}
.key-result code {
background: var(--w4f-card-2);
padding: 1px 5px;
border-radius: 4px;
font-size: 12px;
}
</style>