mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-10-15 12:14:37 +00:00
feat: Phase C 完成 — ModelRouter 风格 UI 重设计 + 模拟 frps 测试 + lastSync 修复 + 集群作坊搭建
- 主题: sakura×frost 玻璃拟态 (theme.css) + SCSS 变量重映射 - 侧栏: 玻璃侧栏 246px + 渐变品牌区 + 面包屑导航 - 状态页: 玻璃 KPI 卡 + 远程节点/本地服务卡片网格 - 集群页: 英雄玻璃卡 + 横向环拓扑链 + 待办命令/活跃拓扑/日志区 - 令牌环: 新增 lastSync 上次同步时间替代周期计数 - 模拟 frps: frps2/frps3 容器 + test-forward.sh 全链路验证脚本 - 修复: BinaryPath 空导致 worker 不启动, 撤销仅撤第一个 link, 任务复活风暴 (published 追踪)
This commit is contained in:
@ -1,73 +1,124 @@
|
||||
<!--
|
||||
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">
|
||||
<div class="cluster-top">
|
||||
<h2 class="page-title">集群令牌环</h2>
|
||||
<span class="page-sub">令牌环网 · 两轮一周期 · 画布差异命令进令牌 · 增量日志同步</span>
|
||||
<button class="refresh-btn" @click="load">刷新</button>
|
||||
<!-- 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>
|
||||
</div>
|
||||
|
||||
<div v-if="ring">
|
||||
<!-- 环状态 -->
|
||||
<section class="section">
|
||||
<h3 class="section-title">环状态
|
||||
<span class="hint">本机 = 编辑入口节点;leader 持首令牌并推进周期</span>
|
||||
</h3>
|
||||
<div class="ring-state">
|
||||
<span class="rs-item">本机: <b :class="{ leader: ring.selfId === ring.leaderId }">{{ shortId(ring.selfId) }}</b></span>
|
||||
<span class="rs-item">leader: <b>{{ shortId(ring.leaderId) }}</b></span>
|
||||
<span class="rs-item">周期: <b>{{ ring.cycle }}</b></span>
|
||||
<span class="rs-item">令牌相: <span class="phase-badge" :class="ring.phase === 2 ? 'sync' : 'collect'">{{ ring.phase === 2 ? '同步轮' : '收集轮' }}</span></span>
|
||||
<span class="rs-item">节点数: <b>{{ ring.nodes.length }}</b></span>
|
||||
<span class="rs-item">待办: <b>{{ ring.pending.length }}</b></span>
|
||||
<span class="rs-item">活跃转发: <b>{{ ring.topology.length }}</b></span>
|
||||
</div>
|
||||
</section>
|
||||
<!-- 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 class="k-sub">已 claim 的集群转发</div>
|
||||
</div>
|
||||
<div class="w4f-kpi">
|
||||
<div class="k-hdr"><span class="k-lab"><span class="w4f-dot" :class="{ pending: ring.pending.length > 0 }" />待办命令</span><span class="k-ic">⚙</span></div>
|
||||
<div class="k-val">{{ ring.pending.length }}</div>
|
||||
<div class="k-sub">随令牌环行待执行</div>
|
||||
</div>
|
||||
<div class="w4f-kpi">
|
||||
<div class="k-hdr"><span class="k-lab"><span class="w4f-dot" :class="{ err: lastSyncStale }" />上次同步</span><span class="k-ic">⟳</span></div>
|
||||
<div class="k-val">{{ lastSyncAgo }}</div>
|
||||
<div class="k-sub">令牌环心跳 · 停滞即异常</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 环拓扑 -->
|
||||
<section class="section">
|
||||
<h3 class="section-title">环拓扑
|
||||
<span class="hint">顺序=令牌传递链;本机高亮,leader 蓝色,离线标红;前驱监控 leader</span>
|
||||
</h3>
|
||||
<!-- Action toolbar -->
|
||||
<div class="toolbar w4f-card">
|
||||
<template v-if="!isMember">
|
||||
<button class="w4f-btn" :disabled="busy" @click="onCreate">⊕ 创建集群</button>
|
||||
<button 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 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>环拓扑<span class="hint">令牌传递链 · leader 青色 · 本机 sakura 高亮 · 离线标红 · 非本机节点可移除</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="rn-self">本机</span>
|
||||
<span v-if="n.id === ring.leaderId" class="rn-leader">LEADER</span>
|
||||
<span v-if="!n.alive" class="rn-dead">离线</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-load">负载 {{ Math.round(n.load.memPct + n.load.netPct) }}</div>
|
||||
<div class="rn-addr">{{ n.addr }}</div>
|
||||
<div v-if="n.id === ring.selfId" class="rn-role">编辑入口</div>
|
||||
<button v-if="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>
|
||||
|
||||
<!-- 待办任务 / 命令 -->
|
||||
<section class="section" v-if="ring.pending.length">
|
||||
<h3 class="section-title">令牌命令(待执行)
|
||||
<span class="hint">画布差异产生:新增转发 / 撤销转发,随令牌环行由对应节点执行</span>
|
||||
</h3>
|
||||
<!-- Pending token commands -->
|
||||
<section class="section w4f-card" v-if="ring.pending.length">
|
||||
<h3 class="section-title"><span class="k-ic">⚙</span>令牌命令(待执行)<span class="hint">画布差异 / 节点移除产生,随令牌环行由对应节点执行</span></h3>
|
||||
<div class="task-list">
|
||||
<div v-for="t in ring.pending" :key="t.id" class="task-row" :class="t.revoke ? 'revoke' : 'pending'">
|
||||
<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="tk-cmd">{{ t.revoke ? '撤销' : '新增' }}</span>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<!-- 活跃转发拓扑 -->
|
||||
<section class="section">
|
||||
<h3 class="section-title">活跃转发拓扑
|
||||
<span class="hint">每个转发由 owner 节点持有;localOnly 转发只在本节点 webui 可见</span>
|
||||
</h3>
|
||||
<!-- Active forward topology -->
|
||||
<section class="section w4f-card">
|
||||
<h3 class="section-title"><span class="k-ic">→</span>活跃转发拓扑<span class="hint">每个转发由 owner 节点持有 · localOnly 转发只在本节点可见</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>
|
||||
@ -76,17 +127,15 @@
|
||||
<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="tp-state" :class="{ ok: t.active }">{{ t.active ? "运行中" : "已停" }}</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>
|
||||
|
||||
<!-- 增量日志 -->
|
||||
<section class="section">
|
||||
<h3 class="section-title">集群日志(增量同步)
|
||||
<span class="hint">追加式,随令牌 delta 重放到全网一致</span>
|
||||
</h3>
|
||||
<!-- Cluster log -->
|
||||
<section class="section w4f-card">
|
||||
<h3 class="section-title"><span class="k-ic">≣</span>集群日志(增量同步)<span class="hint">追加式,随令牌 delta 重放到全网一致</span></h3>
|
||||
<div class="log-list">
|
||||
<div v-for="(e, i) in logView" :key="i" class="log-row" :class="logKindClass(e.kind)">
|
||||
<span class="lg-seq">#{{ e.seq }}</span>
|
||||
@ -97,24 +146,87 @@
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<el-empty v-else description="集群未启动(无环节点)" />
|
||||
<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" placeholder="host:port(如 node-a:7500)" @keyup.enter="onJoin" />
|
||||
</div>
|
||||
<div class="form-hint">本节点将向对端请求加入并采纳其环状态,令牌到达即并入环。</div>
|
||||
<template #footer>
|
||||
<button class="w4f-btn ghost small" @click="joinDialog.visible = false">取消</button>
|
||||
<button class="w4f-btn small" :disabled="busy || !joinDialog.addr.trim()" @click="onJoin">加入</button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { api } from '../api'
|
||||
import type { RingSnapshot } from '../types'
|
||||
import type { RingSnapshot, RingTaskInfo } from '../types'
|
||||
|
||||
const ring = ref<RingSnapshot | null>(null)
|
||||
const busy = ref(false)
|
||||
let timer: number | null = null
|
||||
|
||||
const joinDialog = reactive({ visible: false, addr: '' })
|
||||
|
||||
const load = async () => {
|
||||
try { ring.value = await api.ring() } catch { /* keep last */ }
|
||||
}
|
||||
|
||||
const shortId = (id: string) => (id.length > 22 ? '…' + id.slice(-20) : id)
|
||||
|
||||
// ---- 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())
|
||||
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 '新增转发'
|
||||
@ -125,7 +237,6 @@ const kindLabel = (k: string) => {
|
||||
default: return k
|
||||
}
|
||||
}
|
||||
|
||||
const logKindClass = (k: string) => {
|
||||
switch (k) {
|
||||
case 'forward.add': return 'add'
|
||||
@ -136,54 +247,146 @@ const logKindClass = (k: string) => {
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
const logView = computed(() => [...(ring.value?.log ?? [])].reverse())
|
||||
|
||||
onMounted(() => { load(); timer = window.setInterval(load, 3000) })
|
||||
onBeforeUnmount(() => { if (timer !== null) { clearInterval(timer); timer = null } })
|
||||
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()
|
||||
if (!addr) return
|
||||
busy.value = true
|
||||
try {
|
||||
ring.value = await api.clusterJoinRing(addr)
|
||||
joinDialog.visible = false
|
||||
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()
|
||||
timer = window.setInterval(load, 3000)
|
||||
tick = window.setInterval(() => { now.value = Date.now() }, 1000)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
if (timer !== null) { clearInterval(timer); timer = null }
|
||||
if (tick !== null) { clearInterval(tick); tick = null }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.cluster-page { height: 100%; display: flex; flex-direction: column; padding: 20px; overflow-y: auto; }
|
||||
.cluster-top { display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px; }
|
||||
.page-title { margin: 0; font-size: 18px; font-weight: 600; }
|
||||
.page-sub { color: $color-text-muted; font-size: 13px; }
|
||||
.refresh-btn { margin-left: auto; border: 1px solid $color-border; border-radius: 6px; padding: 3px 12px; background: #fff; cursor: pointer; font-size: 13px; &:hover { background: $color-bg-hover; } }
|
||||
.section { margin-bottom: 20px; }
|
||||
.section-title { margin: 0 0 10px; font-size: 15px; font-weight: 600; .hint { font-size: 12px; font-weight: 400; color: $color-text-muted; margin-left: 8px; } }
|
||||
.ring-state { display: flex; gap: 16px; flex-wrap: wrap; align-items: center; background: #fafafa; border: 1px solid $color-border-light; border-radius: 10px; padding: 10px 16px; }
|
||||
.rs-item { font-size: 13px; color: $color-text-secondary; b { color: #303133; font-weight: 600; font-size: 14px; } b.leader { color: #409eff; } }
|
||||
.phase-badge { font-size: 11px; padding: 1px 8px; border-radius: 8px; background: #ecf5ff; color: #409eff; &.sync { background: #f0f9eb; color: $color-success; } }
|
||||
.ring-line { display: flex; align-items: stretch; gap: 6px; flex-wrap: wrap; }
|
||||
.ring-node { border: 2px solid $color-border; border-radius: 10px; padding: 8px 12px; min-width: 130px; background: #fff; &.leader { border-color: #409eff; background: #ecf5ff; } &.self { outline: 2px solid #409eff33; box-shadow: 0 0 0 4px #409eff1a; } &.dead { border-color: $color-danger; background: #fef0f0; opacity: .7; } }
|
||||
.rn-top { display: flex; align-items: center; gap: 6px; }
|
||||
.rn-id { font-weight: 600; font-size: 13px; }
|
||||
.rn-self { font-size: 10px; background: #67c23a; color: #fff; border-radius: 8px; padding: 0 6px; }
|
||||
.rn-leader { font-size: 10px; background: #409eff; color: #fff; border-radius: 8px; padding: 0 6px; }
|
||||
.rn-dead { font-size: 10px; background: $color-danger; color: #fff; border-radius: 8px; padding: 0 6px; }
|
||||
.rn-load { font-size: 11px; color: $color-text-muted; margin-top: 4px; }
|
||||
.rn-addr { font-size: 11px; color: $color-text-muted; }
|
||||
.rn-role { font-size: 10px; color: $color-success; margin-top: 2px; }
|
||||
.ring-arrow { align-self: center; color: $color-text-muted; font-size: 16px; }
|
||||
.cluster-page { height: 100%; display: flex; flex-direction: column; overflow-y: auto; 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); }
|
||||
|
||||
/* 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); }
|
||||
.hint { font-size: 11px; font-weight: 400; color: $color-text-muted; margin-left: auto; }
|
||||
}
|
||||
|
||||
/* 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 rgba(255, 127, 172, 0.45); box-shadow: 0 0 0 4px rgba(255, 127, 172, 0.14); 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; }
|
||||
.task-row, .topo-row { display: flex; align-items: center; gap: 8px; border: 1px solid $color-border-lighter; border-radius: 8px; padding: 6px 10px; font-size: 13px; }
|
||||
.task-row.pending { background: #ecf5ff; }
|
||||
.task-row.revoke { background: #fef0f0; }
|
||||
.tk-cmd { font-size: 11px; padding: 1px 8px; border-radius: 8px; background: #409eff; color: #fff; }
|
||||
.task-row.revoke .tk-cmd { background: $color-danger; }
|
||||
.tk-id, .tp-id { font-weight: 600; color: $color-text-secondary; font-size: 12px; }
|
||||
.tk-name, .tp-name { font-weight: 500; }
|
||||
.tk-arrow { color: $color-text-muted; }
|
||||
.tk-port, .tp-port { color: $color-text-muted; }
|
||||
.tp-owner { margin-left: auto; font-size: 11px; color: #409eff; &.self { color: $color-success; font-weight: 600; } }
|
||||
.tp-state { font-size: 11px; padding: 1px 8px; border-radius: 8px; background: #f0f9eb; color: $color-success; &:not(.ok) { background: #fef0f0; color: $color-danger; } }
|
||||
.log-row { display: flex; gap: 10px; font-size: 12px; font-family: monospace; border-bottom: 1px dashed $color-border-lighter; padding: 3px 2px; }
|
||||
.log-row.add .lg-kind { color: $color-success; }
|
||||
.log-row.remove .lg-kind { color: $color-danger; }
|
||||
.log-row.join .lg-kind { color: #909399; }
|
||||
.log-row.leader .lg-kind { color: #409eff; }
|
||||
.task-row, .topo-row { display: flex; align-items: center; gap: 8px; border: 1px solid var(--w4f-line); border-radius: $radius-xs; padding: 8px 11px; font-size: 13px; background: var(--w4f-card-2); box-shadow: var(--w4f-sh-sm); }
|
||||
.task-row.pending { background: var(--w4f-primary-50); border-color: rgba(255, 127, 172, 0.25); }
|
||||
.task-row.revoke, .task-row.remove { background: var(--w4f-danger-50); border-color: rgba(219, 54, 148, 0.25); }
|
||||
.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; gap: 10px; font-size: 12px; font-family: var(--w4f-mono); border-bottom: 1px dashed var(--w4f-line); padding: 3px 2px; }
|
||||
.log-row.add .lg-kind { color: $color-success; font-weight: 700; }
|
||||
.log-row.remove .lg-kind { color: $color-danger; font-weight: 700; }
|
||||
.log-row.join .lg-kind { color: $color-info; font-weight: 700; }
|
||||
.log-row.leave .lg-kind { color: $color-warning; font-weight: 700; }
|
||||
.log-row.leader .lg-kind { color: $color-secondary-h; font-weight: 700; }
|
||||
.lg-seq { color: $color-text-muted; }
|
||||
.lg-node { color: #777; &.self { color: #409eff; font-weight: 600; } }
|
||||
.lg-node { color: $color-text-light; &.self { color: var(--w4f-primary-h); font-weight: 700; } }
|
||||
.lg-kind { color: $color-text-secondary; }
|
||||
.empty { color: $color-text-muted; font-size: 13px; padding: 8px; }
|
||||
</style>
|
||||
.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; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user