mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-19 16:38:31 +00:00
feat(web): cluster page shows token ring — ring topology/leader/load, pending tasks, active forward topology with owner, incremental log sync view (e2e verified)
This commit is contained in:
@ -78,12 +78,12 @@ type State struct {
|
||||
|
||||
// Token is the circulating message: one physical token, two phases per cycle.
|
||||
type Token struct {
|
||||
Cycle int64 `json:"cycle"`
|
||||
Phase int `json:"phase"`
|
||||
State State `json:"state"`
|
||||
Cycle int64 `json:"cycle"`
|
||||
Phase int `json:"phase"`
|
||||
State State `json:"state"`
|
||||
Log []LogEntry `json:"logDelta,omitempty"`
|
||||
Passed []string `json:"passed,omitempty"`
|
||||
SentAt int64 `json:"sentAt,omitempty"`
|
||||
Passed []string `json:"passed,omitempty"`
|
||||
SentAt int64 `json:"sentAt,omitempty"`
|
||||
}
|
||||
|
||||
// ---- 环图操作 ---- //
|
||||
|
||||
@ -41,8 +41,8 @@ type Engine struct {
|
||||
send func(ctx context.Context, next string, tk *Token) error
|
||||
|
||||
// inflight tracks token-in-flight state (leader only).
|
||||
inflight inFlight
|
||||
Log *ClusterLog
|
||||
inflight inFlight
|
||||
Log *ClusterLog
|
||||
lastLogSent int64
|
||||
// lastRingStart time of the previous cycle launch (leader throttle).
|
||||
lastRingStart time.Time
|
||||
|
||||
@ -29,19 +29,19 @@ const (
|
||||
|
||||
// LogEntry is one immutable, append-only cluster operation.
|
||||
type LogEntry struct {
|
||||
Seq int64 `json:"seq"`
|
||||
Node string `json:"node"`
|
||||
Kind string `json:"kind"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
At int64 `json:"at"`
|
||||
Seq int64 `json:"seq"`
|
||||
Node string `json:"node"`
|
||||
Kind string `json:"kind"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
At int64 `json:"at"`
|
||||
}
|
||||
|
||||
// ClusterLog is a node's local operation log with a sync watermark.
|
||||
type ClusterLog struct {
|
||||
mu sync.Mutex
|
||||
Seq int64 // highest local seq issued
|
||||
Log []LogEntry // append-only
|
||||
Synced int64 // watermark: entries <= this are known to remote nodes
|
||||
mu sync.Mutex
|
||||
Seq int64 // highest local seq issued
|
||||
Log []LogEntry // append-only
|
||||
Synced int64 // watermark: entries <= this are known to remote nodes
|
||||
}
|
||||
|
||||
// NewClusterLog creates an empty log with initial sequence.
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
internal/httpapi/dist/index.html
vendored
4
internal/httpapi/dist/index.html
vendored
@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>webui-frpc</title>
|
||||
<script type="module" crossorigin src="/assets/index-C9C9_j3w.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C5T3LFGh.css">
|
||||
<script type="module" crossorigin src="/assets/index-traMph4r.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CkgfmeCs.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@ -6,6 +6,7 @@ import type {
|
||||
ClusterNodesResp,
|
||||
InstallResult,
|
||||
Remote,
|
||||
RingSnapshot,
|
||||
Settings,
|
||||
StatusResp,
|
||||
} from "./types";
|
||||
@ -91,4 +92,7 @@ export const api = {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ keep }),
|
||||
}),
|
||||
|
||||
// M6 token ring snapshot.
|
||||
ring: () => request<RingSnapshot>("/api/manager/cluster/ring"),
|
||||
};
|
||||
|
||||
@ -173,3 +173,48 @@ export interface CacheResp {
|
||||
cache: CacheEntry[];
|
||||
removed?: string[];
|
||||
}
|
||||
|
||||
// M6 token-ring snapshot (GET /cluster/ring).
|
||||
export interface RingNode {
|
||||
id: string;
|
||||
addr: string;
|
||||
isLeader?: boolean;
|
||||
alive: boolean;
|
||||
load: { memPct: number; netPct: number };
|
||||
version?: string;
|
||||
cache?: string[];
|
||||
lastSeen?: number;
|
||||
}
|
||||
|
||||
export interface RingTaskInfo {
|
||||
id: string;
|
||||
local: { name: string };
|
||||
remote: { name: string };
|
||||
link: { remotePort?: number };
|
||||
}
|
||||
|
||||
export interface RingTopoEntry {
|
||||
taskId: string;
|
||||
ownerId: string;
|
||||
active: boolean;
|
||||
local: { name: string };
|
||||
remote: { name: string };
|
||||
link: { remotePort?: number };
|
||||
}
|
||||
|
||||
export interface RingLogEntry {
|
||||
seq: number;
|
||||
node: string;
|
||||
kind: string;
|
||||
at?: number;
|
||||
}
|
||||
|
||||
export interface RingSnapshot {
|
||||
leaderId: string;
|
||||
cycle: number;
|
||||
roundDelayMs?: number;
|
||||
nodes: RingNode[];
|
||||
pending: RingTaskInfo[];
|
||||
topology: RingTopoEntry[];
|
||||
log: RingLogEntry[];
|
||||
}
|
||||
|
||||
@ -1,107 +1,130 @@
|
||||
<template>
|
||||
<div class="cluster-page">
|
||||
<div class="cluster-top">
|
||||
<h2 class="page-title">集群</h2>
|
||||
<span class="page-sub">webui4frpc 节点网络 · frpc 二进制分发</span>
|
||||
<button class="refresh-btn" @click="loadAll">刷新</button>
|
||||
<h2 class="page-title">集群令牌环</h2>
|
||||
<span class="page-sub">令牌环网拓扑 · 两轮一周期 · 增量日志同步</span>
|
||||
<button class="refresh-btn" @click="load">刷新</button>
|
||||
</div>
|
||||
|
||||
<!-- 集群节点(本机 + 邻居) -->
|
||||
<section class="section">
|
||||
<h3 class="section-title">集群节点
|
||||
<span class="hint">经 -peer 心跳发现(30s);新节点自动从邻居拉取 frpc</span>
|
||||
</h3>
|
||||
<div class="node-grid">
|
||||
<div v-for="(n, i) in nodes" :key="n.addr" class="node-card" :class="{ self: i === 0 }">
|
||||
<div class="nc-head">
|
||||
<span class="nc-name">{{ n.addr }}</span>
|
||||
<span v-if="i === 0" class="nc-self">本机</span>
|
||||
<span class="nc-state" :class="nodeStateClass(i)">{{ nodeStateLabel(i) }}</span>
|
||||
</div>
|
||||
<div class="nc-meta">
|
||||
<span>frpc: <b>{{ n.version || '-' }}</b></span>
|
||||
</div>
|
||||
<div class="nc-cache" v-if="n.cache?.length">
|
||||
<span class="tag">缓存</span>
|
||||
<span v-for="v in n.cache" :key="v" class="ver">{{ v }}</span>
|
||||
</div>
|
||||
<div class="nc-cache empty" v-else>无 frpc 缓存</div>
|
||||
<div v-if="ring">
|
||||
<!-- 环状态:leader + 轮次 -->
|
||||
<section class="section">
|
||||
<h3 class="section-title">环状态
|
||||
<span class="hint">leader 持有首令牌并推进周期;各节点同步完整集群信息</span>
|
||||
</h3>
|
||||
<div class="ring-state">
|
||||
<span class="rs-item">leader: <b>{{ ring.leaderId }}</b></span>
|
||||
<span class="rs-item">cycle: <b>{{ ring.cycle }}</b></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>
|
||||
</div>
|
||||
<div v-if="!nodes.length" class="empty-tip">尚未加入集群(本机始终在内;用 -peer 连接邻居)</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<!-- 本机二进制缓存 + 管理 -->
|
||||
<section class="section" v-if="cache.length">
|
||||
<h3 class="section-title">本机 frpc 缓存
|
||||
<span class="hint">来源:本地 > 集群节点间交换 > GitHub URL</span>
|
||||
</h3>
|
||||
<div class="cache-list">
|
||||
<div v-for="c in cache" :key="c.version" class="cache-row">
|
||||
<span class="cv">{{ c.version }}</span>
|
||||
<span class="cs">{{ (c.size / 1048576).toFixed(1) }} MB</span>
|
||||
<span class="cp">{{ c.path }}</span>
|
||||
<!-- 环节点拓扑(顺序 = 令牌传递链) -->
|
||||
<section class="section">
|
||||
<h3 class="section-title">环拓扑
|
||||
<span class="hint">按环顺序排列,令牌沿此链传递;leader 被前驱监控</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 }">
|
||||
<div class="rn-top">
|
||||
<span class="rn-id">{{ shortId(n.id) }}</span>
|
||||
<span v-if="n.id === ring.leaderId" class="rn-leader">LEADER</span>
|
||||
<span v-if="!n.alive" class="rn-dead">离线</span>
|
||||
</div>
|
||||
<div class="rn-load">负载 {{ Math.round(n.load.memPct + n.load.netPct) }}</div>
|
||||
<div class="rn-addr">{{ n.addr }}</div>
|
||||
<div class="rn-cache" v-if="n.cache?.length">缓存: {{ n.cache.join(", ") }}</div>
|
||||
</div>
|
||||
<span v-if="i < ring.nodes.length - 1" class="ring-arrow">→</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prune-row">
|
||||
<span>只保留最近</span>
|
||||
<el-input-number v-model="keep" :min="1" :max="10" size="small" />
|
||||
<span>个版本</span>
|
||||
<button class="prune-btn" @click="prune">清理缓存</button>
|
||||
<span v-if="prunedMsg" class="pruned">{{ prunedMsg }}</span>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<el-empty v-if="!nodes.length && !cache.length" description="集群无数据(未配置 -peer 且无 frpc 缓存)" />
|
||||
<!-- 待办任务(挂令牌,等负载最低者摘取) -->
|
||||
<section class="section" v-if="ring.pending.length">
|
||||
<h3 class="section-title">待办任务(令牌携带)</h3>
|
||||
<div class="task-list">
|
||||
<div v-for="t in ring.pending" :key="t.id" class="task-row pending">
|
||||
<span class="tk-id">{{ t.id }}</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="tk-state">待摘取</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 活跃转发拓扑(谁负责哪个转发) -->
|
||||
<section class="section">
|
||||
<h3 class="section-title">活跃转发拓扑
|
||||
<span class="hint">每个已建立的转发由所属节点持有,全网一致</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">owner: {{ shortId(t.ownerId) }}</span>
|
||||
<span class="tp-state" :class="{ ok: t.active }">{{ 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>
|
||||
<div class="log-list">
|
||||
<div v-for="(e, i) in logView" :key="i" class="log-row">
|
||||
<span class="lg-seq">#{{ e.seq }}</span>
|
||||
<span class="lg-node">{{ shortId(e.node) }}</span>
|
||||
<span class="lg-kind">{{ kindLabel(e.kind) }}</span>
|
||||
</div>
|
||||
<div v-if="!logView.length" class="empty">日志为空</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<el-empty v-else description="集群未启动(无环节点)" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { api } from '../api'
|
||||
import type { ClusterNode, CacheEntry } from '../types'
|
||||
import type { RingSnapshot } from '../types'
|
||||
|
||||
const nodes = ref<ClusterNode[]>([])
|
||||
const cache = ref<CacheEntry[]>([])
|
||||
const keep = ref(2)
|
||||
const prunedMsg = ref('')
|
||||
const ring = ref<RingSnapshot | null>(null)
|
||||
let timer: number | null = null
|
||||
|
||||
const loadAll = async () => {
|
||||
try {
|
||||
const r = await api.clusterNodes()
|
||||
nodes.value = r.nodes
|
||||
} catch { /* keep last */ }
|
||||
try {
|
||||
const c = await api.clusterCache()
|
||||
cache.value = c.cache ?? []
|
||||
} catch { /* keep last */ }
|
||||
const load = async () => {
|
||||
try { ring.value = await api.ring() } catch { /* keep last */ }
|
||||
}
|
||||
|
||||
// 首个节点恒为本机;其后为心跳发现的邻居。状态按 seenAt/可达性粗分:
|
||||
// 本机=ok;邻居暂显示已发现(后续可加 seenAt 超时判定)。
|
||||
const nodeStateLabel = (i: number) => (i === 0 ? '在线(本机)' : '已发现')
|
||||
const nodeStateClass = (i: number) => (i === 0 ? 'ok' : 'pending')
|
||||
const shortId = (id: string) => (id.length > 22 ? '…' + id.slice(-20) : id)
|
||||
|
||||
const prune = async () => {
|
||||
try {
|
||||
const r = await api.pruneCache(keep.value)
|
||||
cache.value = r.cache ?? []
|
||||
prunedMsg.value = r.removed?.length ? `已移除: ${r.removed.join(', ')}` : '无变化'
|
||||
ElMessage.success(prunedMsg.value)
|
||||
} catch (e: any) {
|
||||
ElMessage.error('清理失败: ' + (e.message || e))
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAll()
|
||||
timer = window.setInterval(loadAll, 8000)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
if (timer !== null) { clearInterval(timer); timer = null }
|
||||
})
|
||||
const logView = computed(() => [...(ring.value?.log ?? [])].reverse())
|
||||
|
||||
onMounted(() => { load(); timer = window.setInterval(load, 3000) })
|
||||
onBeforeUnmount(() => { if (timer !== null) { clearInterval(timer); timer = null } })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@ -112,24 +135,31 @@ onBeforeUnmount(() => {
|
||||
.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; } }
|
||||
.node-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; }
|
||||
.node-card { border: 1px solid $color-border-light; border-radius: 10px; padding: 12px 14px; background: #fff; &.self { border-color: #409eff; } }
|
||||
.nc-head { display: flex; align-items: center; gap: 8px; }
|
||||
.nc-name { font-weight: 600; }
|
||||
.nc-self { font-size: 11px; background: rgba(64,158,255,.12); color: #409eff; border-radius: 10px; padding: 1px 8px; }
|
||||
.nc-state { margin-left: auto; font-size: 12px; padding: 1px 8px; border-radius: 8px; &.ok { background: #f0f9eb; color: $color-success; } &.pending { background: #ecf5ff; color: #409eff; } }
|
||||
.nc-meta { margin: 6px 0; font-size: 12px; color: $color-text-secondary; }
|
||||
.nc-cache { display: flex; gap: 4px; flex-wrap: wrap; align-items: center; }
|
||||
.tag { font-size: 11px; background: #f2f3f5; color: $color-text-muted; border-radius: 6px; padding: 1px 6px; }
|
||||
.ver { font-size: 11px; background: #ecf5ff; color: #409eff; border-radius: 6px; padding: 1px 6px; }
|
||||
.nc-cache.empty { color: $color-text-muted; font-size: 12px; }
|
||||
.empty-tip { color: $color-text-muted; font-size: 13px; padding: 8px; }
|
||||
.cache-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.cache-row { display: flex; align-items: center; gap: 10px; border: 1px solid $color-border-lighter; border-radius: 8px; padding: 6px 10px; font-size: 13px; }
|
||||
.cv { font-weight: 600; }
|
||||
.cs { color: $color-text-muted; font-size: 12px; }
|
||||
.cp { margin-left: auto; color: $color-text-muted; font-size: 11px; word-break: break-all; }
|
||||
.prune-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; font-size: 13px; color: $color-text-secondary; }
|
||||
.prune-btn { border: 1px solid $color-border; border-radius: 6px; padding: 3px 12px; background: #fff; cursor: pointer; font-size: 13px; &:hover { background: $color-bg-hover; } }
|
||||
.pruned { color: $color-success; font-size: 12px; }
|
||||
.ring-state { display: flex; gap: 18px; 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; } }
|
||||
.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; } &.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-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-cache { font-size: 11px; color: #409eff; }
|
||||
.ring-arrow { align-self: center; color: $color-text-muted; font-size: 16px; }
|
||||
.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: #fffbe6; }
|
||||
.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; }
|
||||
.tk-state { margin-left: auto; font-size: 11px; color: #b8860b; }
|
||||
.tp-owner { margin-left: auto; font-size: 11px; color: #409eff; }
|
||||
.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; }
|
||||
.lg-seq { color: $color-text-muted; }
|
||||
.lg-node { color: #409eff; }
|
||||
.lg-kind { color: $color-text-secondary; }
|
||||
.empty { color: $color-text-muted; font-size: 13px; padding: 8px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user