mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-10-15 12:14:37 +00:00
跟着 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 ./... 全绿。
858 lines
39 KiB
Vue
858 lines
39 KiB
Vue
<!--
|
||
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> 成员</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> 条
|
||
> 平摊份额 <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>
|