Files
webui4frpc/web/src/views/ClusterView.vue
JianFeeeee 790eaa5ed1 feat(web): 集群页负载均衡改为「本机份额」视图
跟着 c69fb2c 的模型改。旧 UI 是中心调度视角(超载阈值 = Score × 均值、
leader 会迁 X→Y),新模型里每台节点只管自己的转发,所以页面必须**以本机为主语**。

## 变化

- 标题徽标从「观察模式 / 已启用」+「本节点非 leader」改为
  **「观察模式(不释放)/ 已启用(会释放)」+「本机超出份额 / 本机在份额内 / 非集群成员」**
  —— 没有 leader 这回事了,每台都在判断。
- 阈值行换成真正的规则:
  `本机转发 N 条 > 平摊份额 M 且至少 2 条时,本机自行释放一条`,
  并给出 `集群共 X 条 / Y 台存活` 与冷却间隔。
- 节点条改成**按转发数排序**(最忙的在前),条宽按最大转发数归一化;
  「超载」徽标改为「超出份额」(判据是转发数 vs 份额,不再看 Score)。
  Score 从卡片上去掉——新规则不用它,留着会让人以为它参与了判断。
- 计划区按 blockedBy 分四种:释放进行中 / 将释放 / 冷却中(含「已过 N 分钟」)/
  暂不释放(把机器原因翻成人话)/ 单节点 / 已均衡(给出具体条数与份额)。
- 加一句说明:每台各自判断、自己撤销一条丢回环、由最闲者认领、无中心调度。

## 一处刻意的设计

`blockedBy` 的文案单独做映射,因为「超出份额但不动」正是最容易被误读成故障的
状态——渲染成人话(冷却期未过 / 上一次释放尚未确认 / 可释放的转发都已停用)
才是这个字段存在的意义。

vue-tsc --noEmit 干净;npm run build 通过(中途踩到 Vue 模板重复 class 属性
导致解析失败,已修);canary 实跑端点 200、字段齐全、默认 dry-run;
go build / go test ./... 全绿。
2026-10-06 14:06:23 +08:00

858 lines
39 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.isMember" v-text="'非集群成员'"></span>
<span class="w4f-tag" v-else-if="bal.releasing" v-text="'释放进行中'"></span>
<span class="w4f-tag w4f-tag-warn" v-else-if="bal.selfOverShare" v-text="'本机超出份额'"></span>
<span class="w4f-tag w4f-tag-ok" v-else v-text="'本机在份额内'"></span>
<span class="title-actions">
<button class="w4f-btn ghost small" :disabled="busy" @click="load">⟳ 刷新</button>
</span>
</h3>
<div class="bal-thresholds">
<span>
规则:本机转发 <b>{{ bal.selfForwards }}</b> 条
&gt; 平摊份额 <b>{{ bal.share }}</b>
且至少 {{ 2 }} 条时,本机自行释放一条
</span>
<span>集群共 {{ bal.totalForwards }} 条 / {{ bal.aliveNodes }} 台存活</span>
<span>两次释放间隔 ≥ {{ Math.round(bal.cooldownSec / 60) }} 分钟</span>
<span v-if="bal.dryRun" class="bal-hint">
设 <code>W4F_BALANCE_MODE=on</code> 才会真正释放
</span>
</div>
<p class="bal-note">
每个节点各自判断自己的转发,超了就自己撤销一条并丢回环上,
由环节点按「最闲者优先」重新认领。没有中心调度。
</p>
<!-- Per-node forward counts. Bars are scaled to the busiest node so the
shape reads whether the cluster holds 3 forwards or 300; the share
marker is what actually decides, not the bar. -->
<div class="bal-nodes">
<div
v-for="n in balNodes"
:key="n.id"
class="bal-node"
:class="{ self: n.id === bal.self, over: n.forwards > bal.share }"
>
<div class="bn-top">
<span class="bn-id">{{ shortId(n.id) }}</span>
<span v-if="n.id === bal.self" 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="n.alive && n.forwards > bal.share" class="w4f-tag w4f-tag-warn">超出份额</span>
</div>
<div class="w4f-bar" :class="{ err: n.alive && n.forwards > bal.share }">
<i :style="{ width: barWidth(n.forwards) + '%' }" />
</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">份额 <b>{{ bal.share }}</b></span>
</div>
</div>
<div v-if="!balNodes.length" class="empty">暂无节点负载数据</div>
</div>
<!-- What THIS node would do. The answer is per-node by design: every
member runs the same rule on its own forwards, so there is no
cluster-wide plan and no "the leader will move X to Y". -->
<div class="bal-plan">
<div v-if="bal.releasing" class="bp-inflight">
<span class="w4f-tag w4f-tag-warn">释放进行中</span>
<span class="bp-text">
本机已释放 {{ bal.releasing.local }}:{{ bal.releasing.remotePort }},
等待环把它认领到别的节点
</span>
</div>
<div v-else-if="bal.wouldRelease && !bal.blockedBy" class="bp-would">
<span class="w4f-tag w4f-tag-warn">将释放</span>
<span class="bp-text">
本机将撤销 {{ bal.wouldRelease.local }}:{{ bal.wouldRelease.remotePort }}
并丢回环上,由最闲节点接管
</span>
</div>
<div v-else-if="bal.wouldRelease && bal.blockedBy === 'cooldown'" class="bp-waiting">
<span class="w4f-tag w4f-tag-info">冷却中</span>
<span class="bp-text">
本机超出份额,但距上次释放不足
{{ Math.round(bal.cooldownSec / 60) }} 分钟
<template v-if="bal.lastReleaseAt">
(已过 {{ cooldownAgo }})
</template>
</span>
</div>
<div v-else-if="bal.wouldRelease" class="bp-waiting">
<span class="w4f-tag w4f-tag-info">暂不释放</span>
<span class="bp-text">本机超出份额,原因:{{ blockedLabel }}</span>
</div>
<div v-else-if="!bal.isMember" class="bp-ok">
<span class="w4f-tag w4f-tag-ok">单节点</span>
<span class="bp-text">本机未加入集群,无需均衡</span>
</div>
<div v-else class="bp-ok">
<span class="w4f-tag w4f-tag-ok">已均衡</span>
<span class="bp-text">
本机持有 {{ bal.selfForwards }} 条,未超平摊份额 {{ bal.share }},无需释放
</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 { 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 ---- //
// Each node decides about its OWN forwards, so this page has no cluster-wide
// plan to show — only what THIS node holds and what it is about to do.
// Ordered by descending count: the busiest node first is what an operator
// scanning for a hotspot wants.
const balNodes = computed(() => [...(bal.value?.nodes ?? [])].sort((a, b) => b.forwards - a.forwards))
// barWidth scales against the busiest node rather than an absolute, so the shape
// is readable whether the cluster holds 3 forwards or 300. The bar is context;
// `share` is what actually decides whether a node is over.
const barWidth = (forwards: number) => {
const max = Math.max(...(bal.value?.nodes ?? []).map((n) => n.forwards), 1)
return Math.min(100, Math.round((forwards / max) * 100))
}
// blockedLabel renders the server's machine reason as something readable. The
// reason matters: "over its share but not acting" is otherwise indistinguishable
// from "broken".
const blockedLabel = computed(() => {
switch (bal.value?.blockedBy) {
case 'cooldown': return '冷却期未过'
case 'in-flight': return '上一次释放尚未确认'
case 'nothing-active': return '本机可释放的转发都已停用'
case 'below-min': return '持有量低于下限'
case 'single-node': return '单节点环'
default: return bal.value?.blockedBy || '未说明'
}
})
// cooldownAgo says how long ago the last release was, so an operator can see
// whether the cooldown is about to expire.
const cooldownAgo = computed(() => {
const t = bal.value?.lastReleaseAt ?? 0
if (!t) return ''
const s = Math.max(0, Math.floor(nowMs.value / 1000) - t)
if (s < 60) return s + ' 秒前'
return Math.floor(s / 60) + ' 分钟前'
})
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.over { border-color: var(--w4f-warning); }
.bal-note {
margin: 0 0 10px; font-size: 11.5px; line-height: 1.55;
color: var(--w4f-muted);
}
.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>