Files
webui4frpc/web/src/views/ClusterView.vue
JianFeeeee b40b53b40b feat(web): 集群页显示负载均衡视图
接 8176478 的只读端点。此前 balancer 的判断**只能从日志看**,而且只在"确实失衡
且迟滞窗口已满"时才输出 —— 运维在健康状态下得到的是沉默,与"负载均衡没开"完全
无法区分。

## 界面

- **各节点 Score 条**:按分数降序(最热的在顶部),条宽按最大分数归一化 ——
  空闲时(score≈5)和繁忙时(score≈500)都能看出形状。
- **阈值说明**:直接写出"超载 = Score > 均值 × N"与"需持续 N 分钟",运维不必
  去猜这两个数字怎么定的。
- **四种状态**(仅 leader 显示):
  - 已均衡 —— 无超载节点
  - 等待确认 —— 已超载但迟滞窗口未满,并显示"已持续 N 分钟 / 需 N 分钟"
    ★ 这是最有价值的一态:否则"为什么还不迁"无从回答
  - 将迁移 / 迁移进行中 —— 给出具体转发与两端节点
- **follower 不显示计划**,与端点的 isLeader 门禁一致:随便查一台节点不该
  暗示集群不会做出的决定。
- **端点 404 时整块不渲染** —— 缺席比空面板诚实(空面板会让人以为支持)。

## 一处刻意的非对称

`load()` 里 ring 失败保留上次值,balance 失败则**清空**。理由:ring 是拓扑快照,
陈旧可接受;balance 是**决策面**,端点开始失败后仍显示"已均衡"比什么都不显示
更危险 —— 会让人以为系统确认过集群是好的。

vue-tsc --noEmit 干净;go build / go test ./... (go clean -testcache) 全绿。
2026-10-06 12:58:17 +08:00

808 lines
37 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!--
Cluster page — token-ring snapshot + cluster lifecycle actions.
Sakua × frost glassmorphism (ModelRouter-style): hero glass card, KPI row,
glass action toolbar, horizontal ring chain of glass node-cards, glass
task/topology/log sections with cardIn entrance.
Four capabilities (gated by derived state to prevent split-brain):
创建集群 — reseed THIS node as a fresh standalone leader (CreateCluster)
加入集群 — THIS node joins an existing cluster at a peer addr (JoinRing)
退出集群 — THIS node leaves (publishes a node-remove for self)
移除节点 — remove SOME member (publishes a node-remove for the target)
加入/退出 are reflexive (this node's relation to a cluster); 移除 is
transitive. Per plan §M6 the leader only holds the first token + judges
loss — it does NOT route tasks.
-->
<template>
<div class="cluster-page">
<!-- Hero header -->
<div class="hero w4f-card">
<div class="hero-left">
<h2 class="page-title">集群令牌环</h2>
<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>
<span class="w4f-tag w4f-tag-info"><b>{{ aliveCount }}</b>&nbsp;成员</span>
<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 -->
<div class="kpis" v-if="ring">
<div class="w4f-kpi">
<div class="k-hdr"><span class="k-lab"><span class="w4f-dot" :class="{ err: aliveCount === 0 }" />环节点</span><span class="k-ic">○</span></div>
<div class="k-val">{{ ring.nodes.length }}</div>
<div class="k-sub">活跃 {{ aliveCount }} · 离线 {{ ring.nodes.length - aliveCount }}</div>
</div>
<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>
<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>
<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>
</div>
<!-- Action toolbar -->
<div class="toolbar w4f-card">
<template v-if="!isMember">
<button v-if="canWrite" class="w4f-btn" :disabled="busy" @click="onCreate">⊕ 创建集群</button>
<button v-if="canWrite" class="w4f-btn ghost" :disabled="busy" @click="joinDialog.visible = true">↪ 加入集群…</button>
<span class="act-hint" v-if="isStandalone && isLeader">本节点为初始 leader,已就绪;可直接创建,或输入对端地址加入既有集群</span>
<span class="act-hint" v-else-if="!selfInRing">本节点已脱离集群,可创建新集群或加入既有集群</span>
</template>
<template v-else>
<button v-if="canWrite" class="w4f-btn danger" :disabled="busy" @click="onLeave">⏏ 退出集群</button>
<span class="act-hint">退出后本节点仅保留 localOnly 转发;可重新创建/加入</span>
</template>
<button class="w4f-btn ghost small" style="margin-left: auto" :disabled="busy" @click="load">⟳ 刷新</button>
</div>
<div v-if="ring" class="cluster-body">
<!-- Ring topology -->
<section class="section w4f-card">
<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 }">
<div class="rn-top">
<span class="rn-id">{{ shortId(n.id) }}</span>
<span v-if="n.id === ring.selfId" class="w4f-tag w4f-tag-primary">本机</span>
<span v-if="n.id === ring.leaderId" class="w4f-tag w4f-tag-info">LEADER</span>
<span v-if="!n.alive" class="w4f-tag w4f-tag-danger">离线</span>
</div>
<div class="rn-load-row">
<span class="rn-load-label">负载 {{ Math.round(n.load.memPct + n.load.netPct) }}</span>
<div class="w4f-bar"><i :style="{ width: Math.min(100, n.load.memPct + n.load.netPct) + '%' }" /></div>
</div>
<div class="rn-addr">{{ n.addr }}</div>
<button v-if="canWrite && n.id !== ring.selfId && n.alive" class="w4f-btn danger small rn-remove" :disabled="busy" @click="onRemoveNode(n.id)">移除节点</button>
</div>
<span v-if="i < ring.nodes.length - 1" class="ring-arrow">→</span>
</template>
<div v-if="!ring.nodes.length" class="empty">环节点为空(已脱离)</div>
</div>
</section>
<!-- Pending token commands -->
<section class="section w4f-card" v-if="ring.pending.length">
<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>
<span class="w4f-tag" :class="t.removeNode || t.revoke ? 'w4f-tag-danger' : 'w4f-tag-primary'">{{ taskCmdLabel(t) }}</span>
<template v-if="t.removeNode">
<span class="tk-arrow">→</span>
<span class="tk-name">移除节点 {{ shortId(t.removeNode) }}</span>
</template>
<template v-else>
<span class="tk-name">{{ t.local.name }}</span>
<span class="tk-arrow">→</span>
<span class="tk-name">{{ t.remote.name }}</span>
<span class="tk-port">:{{ t.link?.remotePort }}</span>
</template>
</div>
</div>
</section>
<!-- Load balancer (read-only). Shown only when the endpoint responds: a
404 means this node has no balancer wired up, and an absent section is
the honest signal for "this build doesn't do balancing". -->
<section class="section w4f-card bal-section" v-if="bal">
<h3 class="section-title">
<span class="k-ic">⚖</span>负载均衡
<span class="w4f-tag" :class="bal.dryRun ? 'w4f-tag-info' : 'w4f-tag-warn'">
{{ bal.dryRun ? '观察模式(不迁移)' : '已启用(会迁移)' }}
</span>
<span class="w4f-tag" v-if="!bal.isLeader" v-text="'本节点非 leader,只报告'"></span>
<span class="title-actions">
<button class="w4f-btn ghost small" :disabled="busy" @click="load">⟳ 刷新</button>
</span>
</h3>
<div class="bal-thresholds">
<span>超载阈值:Score &gt; 均值 × {{ bal.ratio }}</span>
<span>需持续:{{ Math.round(bal.sustainSec / 60) }} 分钟</span>
<span v-if="bal.dryRun" class="bal-hint">
设 <code>W4F_BALANCE_MODE=on</code> 才会真正迁移
</span>
</div>
<!-- Per-node score bars, ordered by score so the hot node is first. -->
<div class="bal-nodes">
<div
v-for="n in balNodes"
:key="n.id"
class="bal-node"
:class="{ self: n.id === ring?.selfId, hot: isHot(n) }"
>
<div class="bn-top">
<span class="bn-id">{{ shortId(n.id) }}</span>
<span v-if="n.id === ring?.selfId" class="w4f-tag w4f-tag-primary">本机</span>
<span v-if="n.isLeader" class="w4f-tag w4f-tag-info">LEADER</span>
<span v-if="!n.alive" class="w4f-tag w4f-tag-danger">离线</span>
<span v-if="isHot(n)" class="w4f-tag w4f-tag-warn">超载</span>
</div>
<div class="w4f-bar" :class="{ err: isHot(n) }">
<i :style="{ width: barWidth(n.score) + '%' }" />
</div>
<div class="bn-stats">
<span>转发 <b>{{ n.forwards }}</b></span>
<span>内存 <b>{{ n.memPct.toFixed(1) }}%</b></span>
<span>网络 <b>{{ n.netPct.toFixed(1) }}%</b></span>
<span class="bn-score">Score <b>{{ n.score.toFixed(1) }}</b></span>
</div>
</div>
<div v-if="!balNodes.length" class="empty">暂无节点负载数据</div>
</div>
<!-- What the balancer would do. A follower deliberately shows no plan:
asking a random node must not imply a decision the cluster won't make. -->
<div class="bal-plan" v-if="bal.isLeader">
<div v-if="bal.inFlight" class="bp-inflight">
<span class="w4f-tag w4f-tag-warn">迁移进行中</span>
<span class="bp-text">
{{ bal.inFlight.local }}:{{ bal.inFlight.remotePort }}
从 {{ shortId(bal.inFlight.from) }} 迁往 {{ shortId(bal.inFlight.to) }}
</span>
</div>
<div v-else-if="bal.wouldMigrate" class="bp-would">
<span class="w4f-tag w4f-tag-warn">将迁移</span>
<span class="bp-text">
{{ bal.wouldMigrate.local }}:{{ bal.wouldMigrate.remotePort }}
从 {{ shortId(bal.wouldMigrate.from) }} 迁往 {{ shortId(bal.wouldMigrate.to) }}
</span>
</div>
<div v-else-if="bal.overloadedSince" class="bp-waiting">
<span class="w4f-tag w4f-tag-info">等待确认</span>
<span class="bp-text">
失衡已持续 {{ Math.floor((nowMs / 1000 - bal.overloadedSince) / 60) }} 分钟,
需连续 {{ Math.round(bal.sustainSec / 60) }} 分钟后才会迁移
</span>
</div>
<div v-else class="bp-ok">
<span class="w4f-tag w4f-tag-ok">已均衡</span>
<span class="bp-text">各节点负载在阈值内,无需迁移</span>
</div>
</div>
</section>
<!-- Active forward topology -->
<section class="section w4f-card">
<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>
<span class="tp-name">{{ t.local.name }}</span>
<span class="tk-arrow">→</span>
<span class="tp-name">{{ t.remote.name }}</span>
<span class="tp-port">:{{ t.link?.remotePort }}</span>
<span class="tp-owner" :class="{ self: t.ownerId === ring.selfId }">owner: {{ shortId(t.ownerId) }}</span>
<span class="w4f-tag" :class="t.active ? 'w4f-tag-ok' : 'w4f-tag-danger'">{{ t.active ? '运行中' : '已停' }}</span>
</div>
<div v-if="!ring.topology.length" class="empty">暂无活跃转发(画布编辑后将经令牌分发)</div>
</div>
</section>
<!-- Cluster log -->
<section class="section w4f-card">
<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-item command="csv">CSV(审计用,Excel 可开)</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-dropdown trigger="click" :disabled="workerLogBusy" @command="exportWorkerLogs">
<button class="w4f-btn ghost small" :disabled="workerLogBusy">⤓ 导出 worker 日志</button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="json">JSON(完整结构)</el-dropdown-item>
<el-dropdown-item command="csv">CSV(审计用,逐行展开)</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</h3>
<div class="log-list">
<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>
</section>
</div>
<div v-else class="empty-page w4f-card">集群未启动(无环节点)</div>
<!-- Join dialog -->
<el-dialog v-model="joinDialog.visible" title="加入集群" width="420px">
<div class="form-row">
<label>对端地址</label>
<el-input v-model="joinDialog.addr" :disabled="!canWrite" placeholder="对端 IP:port(如 192.168.1.10:7500)" @keyup.enter="onJoin" />
</div>
<div class="form-row">
<label>加入密钥</label>
<el-input v-model="joinDialog.key" :disabled="!canWrite" 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 || !canWrite || !joinDialog.addr.trim() || !joinDialog.key.trim()" @click="onJoin">加入</button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { ElMessage } from 'element-plus'
import { api, downloadAuditCsv } from '../api'
import { canWrite } from '../auth'
import type { BalanceNodeReport, BalanceReport, RingSnapshot, RingTaskInfo, RingLogEntry } from '../types'
const ring = ref<RingSnapshot | null>(null)
const busy = ref(false)
// bal is null when the node has no balancer wired up (endpoint 404s). The
// section is simply not rendered then, which is the honest signal for "this
// build does not do balancing" — better than an empty panel implying it does.
const bal = ref<BalanceReport | null>(null)
let timer: number | null = null
let visHandler: (() => void) | null = null
const joinDialog = reactive({ visible: false, addr: '', key: '' })
const load = async () => {
try { ring.value = await api.ring() } catch { /* keep last */ }
// Loaded alongside the ring, but unlike it a failure clears the view: the
// balancer report is a decision surface, and showing a stale "已均衡" after the
// endpoint starts failing would be worse than showing nothing.
try { bal.value = await api.balance() } catch { bal.value = null }
}
// ---- load balancer view ---- //
// Ordered by descending score so the hottest node is always on top.
const balNodes = computed(() => [...(bal.value?.nodes ?? [])].sort((a, b) => b.score - a.score))
const balAvgScore = computed(() => {
const n = bal.value?.nodes ?? []
return n.length ? n.reduce((s, x) => s + x.score, 0) / n.length : 0
})
// isHot mirrors the server's rule: above ratio x mean. The server additionally
// requires a forward-count floor and the sustained window, so a node can be
// "hot" here while the plan is still empty — that is the waiting state.
const isHot = (n: BalanceNodeReport) =>
balAvgScore.value > 0 && n.score > balAvgScore.value * (bal.value?.ratio ?? 2)
// barWidth scales against the max score rather than an absolute, so the shape is
// readable whether the cluster is idle (scores ~5) or loaded (scores ~500).
const barWidth = (score: number) => {
const max = Math.max(...(bal.value?.nodes ?? []).map((n) => n.score), 1)
return Math.min(100, Math.round((score / max) * 100))
}
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)
const isStandalone = computed(() => aliveCount.value <= 1 || !selfInRing.value)
const isMember = computed(() => !!ring.value && ring.value.nodes.length > 1 && selfInRing.value)
const isLeader = computed(() => !!ring.value && ring.value.selfId === ring.value.leaderId)
const stateLabel = computed(() => {
if (!ring.value) return '未启动'
if (!selfInRing.value) return '已脱离'
if (isMember.value) return '运行中'
return '待加入'
})
const stateTagClass = computed(() => {
if (!ring.value) return 'w4f-tag-warn'
if (!selfInRing.value) return 'w4f-tag-danger'
if (isMember.value) return 'w4f-tag-ok'
return 'w4f-tag-info'
})
const stateDotClass = computed(() => {
if (!ring.value) return ''
if (!selfInRing.value) return 'err'
if (isMember.value) return ''
return 'pending'
})
// ---- last-sync freshness (ops cares "环还活着吗", not the cycle counter) ----
const now = ref(Date.now())
// nowMs mirrors `now` as a plain number for the balance section's minute math.
const nowMs = computed(() => now.value)
let tick: number | null = null
const lastSyncAgo = computed(() => {
const ls = ring.value?.lastSync ?? 0
if (!ls) return '—'
const s = Math.max(0, Math.floor(now.value / 1000) - ls)
if (s < 2) return '刚刚'
if (s < 60) return s + ' 秒前'
if (s < 3600) return Math.floor(s / 60) + ' 分钟前'
return Math.floor(s / 3600) + ' 小时前'
})
// 停滞告警:正常每 ~2-3 秒一轮,超过 15 秒未同步视为环异常
const lastSyncStale = computed(() => {
const ls = ring.value?.lastSync ?? 0
if (!ls) return false
return Math.floor(now.value / 1000) - ls > 15
})
// ---- log/task helpers ----
const kindLabel = (k: string) => {
switch (k) {
case 'forward.add': return '新增转发'
case 'forward.remove': return '撤销转发'
case 'node.join': return '节点加入'
case 'node.leave': return '节点离开'
case 'leader.change': return 'leader 变更'
default: return k
}
}
const logKindClass = (k: string) => {
switch (k) {
case 'forward.add': return 'add'
case 'forward.remove': return 'remove'
case 'node.join': return 'join'
case 'node.leave': return 'leave'
case 'leader.change': return 'leader'
default: return ''
}
}
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
// pretty JSON (lossless), a readable TSV text file, or server-generated RFC4180
// CSV (audit deliverable, Excel-friendly) — sorted by seq.
const exportLog = async (fmt: string) => {
if (fmt === 'csv') {
// Server-side CSV: RFC4180 escaping + formula-injection defence + ISO8601 UTC.
try {
await downloadAuditCsv('cluster-log')
} catch (e: any) {
ElMessage.error('导出失败: ' + (e?.message || e))
}
return
}
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 (fmt = 'json') => {
workerLogBusy.value = true
try {
if (fmt === 'csv') {
// Server-side CSV: ring-wide fan-out, one row per log line.
await downloadAuditCsv('worker-logs')
ElMessage.success('已导出 worker 日志 CSV')
return
}
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 ? '撤销' : '新增')
// ---- lifecycle actions ----
const onCreate = async () => {
busy.value = true
try {
ring.value = await api.clusterCreate()
ElMessage.success('已创建集群(本节点为初始 leader)')
} catch (e: any) {
ElMessage.error('创建失败: ' + (e.message || e))
} finally {
busy.value = false
await load()
}
}
const onJoin = async () => {
const addr = joinDialog.addr.trim()
const key = joinDialog.key.trim()
if (!addr || !key) return
busy.value = true
try {
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))
} finally {
busy.value = false
await load()
}
}
const onLeave = async () => {
if (!ring.value) return
if (!window.confirm('退出集群?本节点将取消一切集群远程转发并脱离环。')) return
busy.value = true
try {
await api.clusterRemoveNode(ring.value.selfId)
ElMessage.success('退出指令已发布')
} catch (e: any) {
ElMessage.error('退出失败: ' + (e.message || e))
} finally {
busy.value = false
await load()
}
}
const onRemoveNode = async (id: string) => {
if (!window.confirm(`移除节点 ${shortId(id)} 出集群?`)) return
busy.value = true
try {
await api.clusterRemoveNode(id)
ElMessage.success('移除指令已发布')
} catch (e: any) {
ElMessage.error('移除失败: ' + (e.message || e))
} finally {
busy.value = false
await load()
}
}
onMounted(() => {
load()
// 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>
<style scoped lang="scss">
/* Let .content (App.vue) remain the single scroll container — see the note in
* StatusView: a page-level scroller sized by leftover flex space is what froze
* the status page. `.log-list` keeps its own bounded scroll (that one is
* deliberate — a log pane should not push the whole page). */
.cluster-page { display: flex; flex-direction: column; padding: 4px 0 40px; gap: 14px; }
/* hero */
.hero { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; padding: 16px 20px; }
.hero-left { display: flex; flex-direction: column; gap: 4px; }
.page-title { margin: 0; font-size: 18px; font-weight: 800; }
.page-sub { color: $color-text-muted; font-size: 12px; }
.hero-stats { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.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; }
/* toolbar */
.toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 12px 16px; }
.act-hint { font-size: 12px; color: $color-text-muted; }
/* sections */
.cluster-body { display: flex; flex-direction: column; gap: 14px; }
.section { padding: 18px 20px; }
.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); }
}
/* ring topology */
.ring-line { display: flex; align-items: stretch; gap: 8px; flex-wrap: wrap; }
.ring-node { position: relative; border: 1px solid var(--w4f-line); border-radius: $radius-md; padding: 12px 14px; min-width: 168px;
background: var(--w4f-card-2); box-shadow: var(--w4f-sh-sm);
&.leader { border-color: var(--w4f-secondary); background: var(--w4f-secondary-50); }
&.self { outline: 2px solid var(--w4f-primary); box-shadow: 0 0 0 4px var(--w4f-glow-soft); border-color: var(--w4f-primary); }
&.dead { border-color: var(--w4f-danger); background: var(--w4f-danger-50); opacity: 0.72; }
}
.rn-top { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.rn-id { font-weight: 800; font-size: 13px; font-family: var(--w4f-mono); }
.rn-load-row { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
.rn-load-label { font-size: 11px; color: $color-text-muted; white-space: nowrap; }
.rn-load-row .w4f-bar { flex: 1; min-width: 50px; }
.rn-addr { font-size: 11px; color: $color-text-muted; font-family: var(--w4f-mono); margin-top: 4px; }
.rn-remove { margin-top: 8px; }
.ring-arrow { align-self: center; color: var(--w4f-primary); font-size: 18px; font-weight: 700; }
/* 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: var(--w4f-primary-200); }
.task-row.revoke, .task-row.remove { background: var(--w4f-danger-50); border-color: var(--w4f-danger); }
.tk-id, .tp-id { font-weight: 700; color: $color-text-secondary; font-size: 12px; font-family: var(--w4f-mono); }
.tk-name, .tp-name { font-weight: 600; }
.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; 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; 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; }
/* dialog form */
.form-row { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; label { width: 72px; font-size: 13px; color: $color-text-secondary; flex-shrink: 0; } }
.form-hint { font-size: 12px; color: $color-text-muted; }
/* ---------- narrow screens ----------
窄屏下的主要问题:
- ring-node 的 min-width:168px 与环形箭头在 375px 下会横向溢出,且节点横排无意义
- nodeKey 是 32 位 hex,单行放不下会撑宽容器
- log-row 的 lg-detail max-width:60% + 不换行,窄屏几乎全被省略号吃掉
- toolbar / 对话框表单同 StatusView 的问题 */
@media (max-width: 720px) {
.cluster-page { gap: 10px; }
.section { padding: 14px 14px; }
.hero { padding: 14px 15px; gap: 12px; }
.page-title { font-size: 16.5px; }
/* nodeKey 允许折行,不再撑宽容器 */
.nk-value {
font-size: 11px;
word-break: break-all;
white-space: normal;
max-width: 100%;
}
.nk-label { white-space: normal; }
.kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
/* 工具栏按钮平分整行 */
.toolbar { padding: 11px 13px; gap: 8px; }
.toolbar > .w4f-btn { flex: 1 1 auto; min-height: 40px; }
.act-hint { width: 100%; }
/* 环拓扑改竖向:节点卡占整宽,箭头旋转 90° 指下 */
.ring-line { flex-direction: column; align-items: stretch; gap: 6px; }
.ring-node { min-width: 0; width: 100%; padding: 11px 12px; }
.rn-id { font-size: 12.5px; word-break: break-all; }
.ring-arrow {
align-self: center;
transform: rotate(90deg);
font-size: 15px;
line-height: 1;
}
/* 日志:详情另起一行完整显示,不再被省略号截掉 */
.log-row { row-gap: 3px; font-size: 11.5px; }
.lg-detail {
margin-left: 0;
width: 100%;
max-width: 100%;
text-align: left;
white-space: normal;
word-break: break-all;
}
.log-list { max-height: 260px; }
/* 任务/拓扑行:允许折行,归属另起一行 */
.task-row, .topo-row { flex-wrap: wrap; row-gap: 4px; font-size: 12.5px; }
.tp-owner { margin-left: 0; width: 100%; }
.title-actions { margin-left: 0; width: 100%; }
.title-actions > .w4f-btn { flex: 1 1 0; min-height: 36px; }
.section-title { flex-wrap: wrap; row-gap: 8px; }
/* 对话框表单:标签在上、输入在下 */
.form-row {
flex-direction: column;
align-items: stretch;
gap: 5px;
label { width: auto; }
}
}
@media (max-width: 380px) {
.kpis { grid-template-columns: minmax(0, 1fr); }
.page-title { font-size: 15.5px; }
}
/* ---- Load balancer section ---- */
.bal-section .section-title { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.bal-section .section-title .k-ic { margin-right: 0; }
.bal-thresholds {
display: flex; flex-wrap: wrap; gap: 6px 14px;
font-size: 11.5px; color: var(--w4f-muted);
margin-bottom: 10px;
}
.bal-hint { color: var(--w4f-warning); }
.bal-hint code {
font-family: var(--w4f-mono, ui-monospace, monospace);
background: var(--w4f-card-2); padding: 1px 4px; border-radius: 3px;
}
.bal-nodes {
display: grid; gap: 8px;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
.bal-node {
border: 1px solid var(--w4f-line);
border-radius: 8px;
padding: 9px 11px;
background: var(--w4f-card-2);
min-width: 0;
}
.bal-node.self { border-color: var(--w4f-primary); }
.bal-node.hot { border-color: var(--w4f-warning); }
.bn-top { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 7px; }
.bn-id {
font-family: var(--w4f-mono, ui-monospace, monospace);
font-size: 12px; font-weight: 700;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.bn-stats {
display: flex; flex-wrap: wrap; gap: 3px 12px;
font-size: 11.5px; color: var(--w4f-muted); margin-top: 7px;
}
.bn-stats b { color: var(--w4f-text); font-variant-numeric: tabular-nums; }
.bn-score { margin-left: auto; }
.bal-plan { margin-top: 11px; }
.bp-inflight, .bp-would, .bp-waiting, .bp-ok {
display: flex; align-items: center; gap: 9px; flex-wrap: wrap;
padding: 8px 11px; border-radius: 8px;
font-size: 12.5px;
background: var(--w4f-card-2);
border: 1px solid var(--w4f-line);
}
.bp-inflight { border-color: var(--w4f-warning); }
.bp-text { min-width: 0; overflow-wrap: anywhere; }
@media (max-width: 380px) {
.bn-score { margin-left: 0; }
}
</style>