feat(M6): token ring data plane + engine — pending adds disappear on claim, topology written back for full cluster view (per authoritative design)

This commit is contained in:
2026-08-18 08:24:37 +08:00
parent 5bd70b90c0
commit f6c4b91a96
9 changed files with 1166 additions and 202 deletions

300
internal/cluster/ring.go Normal file
View File

@ -0,0 +1,300 @@
// Package cluster implements the token-ring cooperative network.
// Authoritative design: plan.md §M6 (令牌环网拓扑).
//
// Token payload semantics (per the design):
// - PendingTasks: forward requests (中间转发意图) riding in the token,
// waiting to be claimed by the lowest-load node. Once claimed, the task
// disappears from PendingTasks.
// - Topology: the cluster's ACTIVE forward map — every established forward
// with its owning node. The claiming node appends it back into the token
// so every member converges on a complete picture (who runs what).
package cluster
import (
"fmt"
"sort"
"time"
"webui4frpc/internal/store"
)
// ---- 数据面 ---- //
// Node is one cluster member in the ring.
type Node struct {
ID string `json:"id"`
Addr string `json:"addr"`
IsLeader bool `json:"isLeader,omitempty"`
Alive bool `json:"alive"`
Load Load `json:"load"`
Version string `json:"version,omitempty"`
Cache []string `json:"cache,omitempty"`
LastSeen int64 `json:"lastSeen,omitempty"`
}
// Load is the combined load metric used to pick the task claimer.
type Load struct {
MemPct float64 `json:"memPct"`
NetPct float64 `json:"netPct"`
}
func (l Load) Score() float64 { return l.MemPct + l.NetPct }
// Task is a PENDING forward request circulated in the token. It carries the
// intermediate forwarding intent (local/remote/link) — NOT a rendered frpc
// config. The claiming node renders its local frpc config and spawns the worker.
type Task struct {
ID string `json:"id"`
Local store.Local `json:"local"`
Remote store.Remote `json:"remote"`
Link store.Link `json:"link"`
Created int64 `json:"created"`
}
// TopoEntry is an ESTABLISHED forward in the cluster topology: who (OwnerID)
// runs which forward (rendered from the same intermediate intent).
type TopoEntry struct {
TaskID string `json:"taskId"`
OwnerID string `json:"ownerId"`
Local store.Local `json:"local"`
Remote store.Remote `json:"remote"`
Link store.Link `json:"link"`
Active bool `json:"active"`
}
// State is the full cluster picture, replicated on every node:
// ring membership + pending tasks + active forward topology.
type State struct {
LeaderID string `json:"leaderId"`
Nodes []Node `json:"nodes"`
Cycle int64 `json:"cycle"`
RoundDelay time.Duration `json:"-"`
// PendingTasks: not yet claimed (disappear when claimed).
PendingTasks map[string]*Task `json:"pendingTasks,omitempty"`
// Topology: active forwards owned by members (full cluster view).
Topology map[string]*TopoEntry `json:"topology,omitempty"`
Seq int64 `json:"seq"`
}
// 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"`
Passed []string `json:"passed,omitempty"`
SentAt int64 `json:"sentAt,omitempty"`
}
// ---- 环图操作 ---- //
func (s *State) Find(id string) int {
for i, n := range s.Nodes {
if n.ID == id {
return i
}
}
return -1
}
func (s *State) AliveSuccessor(id string) (string, bool) {
i := s.Find(id)
if i < 0 {
return "", false
}
n := len(s.Nodes)
for k := 1; k <= n; k++ {
j := (i + k) % n
if s.Nodes[j].Alive {
return s.Nodes[j].ID, true
}
}
return "", false
}
func (s *State) AlivePredecessor(id string) (string, bool) {
i := s.Find(id)
if i < 0 {
return "", false
}
n := len(s.Nodes)
for k := 1; k <= n; k++ {
j := ((i-k)%n + n) % n
if s.Nodes[j].Alive {
return s.Nodes[j].ID, true
}
}
return "", false
}
func (s *State) UpsertNode(n Node) {
i := s.Find(n.ID)
if i < 0 {
if n.IsLeader {
s.LeaderID = n.ID
}
n.Alive = true
n.LastSeen = time.Now().Unix()
s.Nodes = append(s.Nodes, n)
return
}
old := s.Nodes[i]
n.IsLeader = old.IsLeader || n.IsLeader
if n.IsLeader {
s.LeaderID = n.ID
}
n.Alive = true
n.LastSeen = time.Now().Unix()
s.Nodes[i] = n
}
func (s *State) MarkOffline(id string) int {
i := s.Find(id)
if i < 0 {
return -1
}
s.Nodes[i].Alive = false
s.Nodes[i].LastSeen = time.Now().Unix()
return i
}
// InsertAfter inserts newNode right after anchor (join: newNode lands behind
// its sponsor; the former successor moves after the newcomer).
func (s *State) InsertAfter(anchor string, n Node) {
i := s.Find(n.ID)
if i >= 0 {
s.Nodes[i].Alive = true
s.Nodes[i].LastSeen = time.Now().Unix()
return
}
n.Alive = true
n.LastSeen = time.Now().Unix()
j := s.Find(anchor)
if j < 0 {
s.Nodes = append(s.Nodes, n)
return
}
out := make([]Node, 0, len(s.Nodes)+1)
out = append(out, s.Nodes[:j+1]...)
out = append(out, n)
out = append(out, s.Nodes[j+1:]...)
s.Nodes = out
}
// LowestAlive returns alive node with lowest combined load (nil if none).
func (s *State) LowestAlive() *Node {
var best *Node
for i := range s.Nodes {
if !s.Nodes[i].Alive {
continue
}
if best == nil || s.Nodes[i].Load.Score() < best.Load.Score() {
b := s.Nodes[i]
best = &b
}
}
return best
}
func (s *State) NextTaskID() string {
s.Seq++
return fmt.Sprintf("t%d", s.Seq)
}
// AddPending attaches a NEW forward request to the token (round-1 inject).
func (s *State) AddPending(local store.Local, remote store.Remote, link store.Link) *Task {
t := &Task{
ID: s.NextTaskID(),
Local: local,
Remote: remote,
Link: link,
Created: time.Now().Unix(),
}
if s.PendingTasks == nil {
s.PendingTasks = map[string]*Task{}
}
s.PendingTasks[t.ID] = t
return t
}
// PendingList returns pending tasks, stable by id.
func (s *State) PendingList() []*Task {
out := make([]*Task, 0, 4)
for _, t := range s.PendingTasks {
out = append(out, t)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
// ClaimPending removes a pending task and returns it (the claimer then renders
// its local frpc config and spawns the worker). Returns nil if absent.
func (s *State) ClaimPending(id string) *Task {
if s.PendingTasks == nil {
return nil
}
t := s.PendingTasks[id]
if t == nil {
return nil
}
delete(s.PendingTasks, id)
return t
}
// AddTopology records an ESTABLISHED forward into the cluster topology.
// Called by the claiming node right after it spawns the worker: the task
// disappears from pending and lives on as active topology.
func (s *State) AddTopology(t *Task, ownerID string) *TopoEntry {
if s.Topology == nil {
s.Topology = map[string]*TopoEntry{}
}
e := &TopoEntry{
TaskID: t.ID, OwnerID: ownerID,
Local: t.Local, Remote: t.Remote, Link: t.Link,
Active: true,
}
s.Topology[t.ID] = e
return e
}
// TopologyList returns active forwards, stable by task id.
func (s *State) TopologyList() []*TopoEntry {
out := make([]*TopoEntry, 0, len(s.Topology))
for _, e := range s.Topology {
out = append(out, e)
}
sort.Slice(out, func(i, j int) bool { return out[i].TaskID < out[j].TaskID })
return out
}
// ForwardsOwnedBy returns topology entries owned by a node (for offline
// reassignment and UI per-node views).
func (s *State) ForwardsOwnedBy(owner string) []*TopoEntry {
var out []*TopoEntry
for _, e := range s.Topology {
if e.OwnerID == owner {
out = append(out, e)
}
}
return out
}
// OfflineReassign moves all active forwards owned by an offline node back into
// PendingTasks (they become new tasks for the next lowest-load member). Returns
// the reassigned task ids.
func (s *State) OfflineReassign(offlineID string) []string {
var reattached []string
for id, e := range s.Topology {
if e.OwnerID != offlineID || !e.Active {
continue
}
delete(s.Topology, id)
t := &Task{ID: id, Local: e.Local, Remote: e.Remote, Link: e.Link,
Created: time.Now().Unix()}
if s.PendingTasks == nil {
s.PendingTasks = map[string]*Task{}
}
s.PendingTasks[id] = t
reattached = append(reattached, id)
}
return reattached
}

View File

@ -0,0 +1,252 @@
// Package cluster implements the token-ring cooperative network.
// Authoritative design: plan.md §M6 (令牌环网拓扑).
package cluster
import (
"context"
"log"
"time"
)
// Phase constants for the two-round cycle.
const (
PhaseCollect = 1 // round 1: append own info to token
PhaseSync = 2 // round 2: sync cluster state, claim tasks
)
// Handler is what the engine calls when the node must act on a task
// (create the frpc worker / forward). Injected to avoid import cycle.
type Handler interface {
Claim(ctx context.Context, tk *Task) error
RuntimeLoad() Load
}
// Engine drives one member of the token ring.
type Engine struct {
ID string
Addr string
User string
Pass string
Version string
Cache []string
Handler Handler
state State
// myAddr maps our Node ID to the address peers dial.
myAddr string
// send moves the token to the next node (injected transport).
send func(ctx context.Context, next string, tk *Token) error
// inflight tracks token-in-flight state (leader only).
inflight inFlight
}
// NewEngine builds the engine; state holds this node as initial leader unless
// a peer list says otherwise (creation node starts the ring).
func NewEngine(id, addr, user, pass, version string, cache []string, h Handler, send func(ctx context.Context, next string, tk *Token) error, selfAddr string, isLeader bool) *Engine {
e := &Engine{
ID: id, Addr: addr, User: user, Pass: pass,
Version: version, Cache: cache, Handler: h,
state: State{
LeaderID: "",
Cycle: 0,
PendingTasks: map[string]*Task{},
Topology: map[string]*TopoEntry{},
RoundDelay: 200 * time.Millisecond,
},
myAddr: selfAddr,
send: send,
}
n := Node{ID: id, Addr: selfAddr, Alive: true, IsLeader: isLeader,
Load: Load{MemPct: 10, NetPct: 10}, Version: version, Cache: cache}
e.state.UpsertNode(n)
return e
}
// State returns the engine's current cluster picture.
func (e *Engine) State() *State { return &e.state }
// nextRecipient picks the successor to hand the token to, skipping dead nodes.
func (e *Engine) nextRecipient() (string, bool) {
return e.state.AliveSuccessor(e.ID)
}
// myNode returns this node's entry from state.
func (e *Engine) myNode() Node {
i := e.state.Find(e.ID)
if i < 0 {
return Node{ID: e.ID, Addr: e.myAddr, Alive: true}
}
return e.state.Nodes[i]
}
// loadSnapshot reads our runtime load (mem+net) from the handler.
func (e *Engine) loadSnapshot() Load {
if e.Handler != nil {
return e.Handler.RuntimeLoad()
}
return Load{MemPct: 20, NetPct: 20}
}
// phase1 appends this node's info to the token (round 1).
func (e *Engine) phase1(tk *Token) {
e.state.UpsertNode(Node{
ID: e.ID, Addr: e.myAddr, Alive: true,
IsLeader: e.state.LeaderID == e.ID || tk.State.LeaderID == e.ID,
Load: e.loadSnapshot(),
Version: e.Version, Cache: e.Cache,
})
tk.Passed = append(tk.Passed, e.ID)
}
// phase2 syncs cluster info from the token and claims pending tasks if we are
// the lowest-load node. A claimed task DISAPPEARS from pending and is
// written into the active topology so every member knows who runs what.
func (e *Engine) phase2(ctx context.Context, tk *Token) error {
e.state = tk.State
e.state.LeaderID = tk.State.LeaderID
for {
pending := e.state.PendingList()
if len(pending) == 0 {
break
}
low := e.state.LowestAlive()
if low == nil || low.ID != e.ID {
break
}
tk0 := pending[0]
claimed := e.state.ClaimPending(tk0.ID)
if claimed == nil {
break
}
if e.Handler != nil {
if err := e.Handler.Claim(ctx, claimed); err != nil {
e.state.PendingTasks[claimed.ID] = claimed
log.Printf("ring[%s] claim %s failed: %v", e.ID, claimed.ID, err)
break
}
}
e.state.AddTopology(claimed, e.ID)
}
e.state = tk.State
tk.Passed = append(tk.Passed, e.ID)
return nil
}
// OnToken receives the token: process by phase, return updated token.
func (e *Engine) OnToken(ctx context.Context, tk *Token) (*Token, error) {
switch tk.Phase {
case PhaseCollect:
e.phase1(tk)
case PhaseSync:
if err := e.phase2(ctx, tk); err != nil {
return tk, err
}
default:
return tk, nil
}
return tk, nil
}
// Forward hands the token to this node's successor over the injected send.
// It is the transport hook used by the HTTP handler after OnToken.
func (e *Engine) Forward(ctx context.Context, tk *Token) error {
next, ok := e.state.AliveSuccessor(e.ID)
if !ok {
return nil // single-node ring
}
if e.send != nil {
return e.send(ctx, next, tk)
}
return nil
}
// Snapshot returns a serializable view of the ring for the frontend.
type RingSnapshot struct {
LeaderID string `json:"leaderId"`
Cycle int64 `json:"cycle"`
RoundDelay int64 `json:"roundDelayMs"`
Nodes []Node `json:"nodes"`
Pending []*Task `json:"pending"`
Topology []*TopoEntry `json:"topology"`
}
func (e *Engine) Snapshot() *RingSnapshot {
snap := &RingSnapshot{
LeaderID: e.state.LeaderID,
Cycle: e.state.Cycle,
RoundDelay: e.state.RoundDelay.Milliseconds(),
Nodes: e.state.Nodes,
Pending: e.state.PendingList(),
Topology: e.state.TopologyList(),
}
return snap
}
// advanceToken decides the next recipient: normal successor; if we are leader
// and everyone passed, flip phase for the second round.
func (e *Engine) advanceToken(ctx context.Context, tk *Token) error {
if tk.Phase == PhaseCollect {
all := true
for _, n := range e.state.Nodes {
if !n.Alive {
continue
}
if !contains(tk.Passed, n.ID) {
all = false
break
}
}
if all && e.state.LeaderID == e.ID {
tk.Phase = PhaseSync
tk.Passed = nil
e.state.RoundDelay = tkDelaySince(tk)
}
}
next, ok := e.state.AliveSuccessor(e.ID)
if !ok {
return nil
}
if e.send != nil {
return e.send(ctx, next, tk)
}
return nil
}
// tkDelaySince measures elapsed ms since token SentAt (leader round delay).
func tkDelaySince(tk *Token) time.Duration {
if tk.SentAt == 0 {
return 200 * time.Millisecond
}
return time.Duration(time.Now().UnixMilli()-tk.SentAt) * time.Millisecond
}
func contains(xs []string, v string) bool {
for _, x := range xs {
if x == v {
return true
}
}
return false
}
// StartRing kicks off the cycle from the leader by sending the first token
// (phase 1) to the next node. Called once at leader boot.
func (e *Engine) StartRing(ctx context.Context) {
if e.state.LeaderID != e.ID {
return
}
e.state.Cycle++
tk := &Token{
Cycle: e.state.Cycle,
Phase: PhaseCollect,
State: e.state,
Passed: []string{e.ID},
SentAt: time.Now().UnixMilli(),
}
e.phase1(tk)
if err := e.advanceToken(ctx, tk); err != nil {
log.Printf("ring[%s] start cycle %d: %v", e.ID, tk.Cycle, err)
}
}

View File

@ -0,0 +1,64 @@
package cluster
import (
"context"
"testing"
)
type fakeHandler struct {
load Load
claim func(ctx context.Context, tk *Task) error
}
func (h *fakeHandler) RuntimeLoad() Load { return h.load }
func (h *fakeHandler) Claim(ctx context.Context, tk *Task) error {
if h.claim != nil {
return h.claim(ctx, tk)
}
return nil
}
// sendNull is a no-op sender (single-node ring).
func sendNull(ctx context.Context, next string, tk *Token) error { return nil }
// TestSingleNodeCycle: leader starts ring, processes phases locally, and the
// task gets claimed (lowest load = only node).
func TestSingleNodeCycle(t *testing.T) {
eng := NewEngine("n1", "n1:7500", "u", "p", "0.71.0", []string{"0.71.0"},
&fakeHandler{load: Load{MemPct: 30, NetPct: 30}}, sendNull, "n1:7500", true)
eng.StartRing(context.Background())
// single node: token stays local; no successor so nothing travels.
if eng.State().Nodes[0].ID != "n1" {
t.Fatalf("nodes = %+v", eng.State().Nodes)
}
}
// TestTwoNodesPhaseCollect: leader n1 sends to n2; n1 collects both after n2
// stamps; then phase flips and second round syncs.
func TestTwoNodesCollectAndSync(t *testing.T) {
var n2loaded bool
eng2 := NewEngine("n2", "n2:7500", "u", "p", "0.71.0", nil,
&fakeHandler{load: Load{MemPct: 10, NetPct: 10}}, sendNull, "n2:7500", false)
// n2's state must include both (leader + itself) so AliveSuccessor works
eng2.state.UpsertNode(Node{ID: "n1", Addr: "n1:7500", Alive: true, IsLeader: true, Load: Load{MemPct: 50, NetPct: 50}})
// n1 sends a collect-phase token with itself in Passed.
eng2.state.UpsertNode(Node{ID: "n1", Addr: "n1:7500", Alive: true, IsLeader: true, Load: eng2.state.Nodes[0].Load})
tk := &Token{Cycle: 1, Phase: PhaseCollect, Passed: []string{"n1"},
State: *func() *State {
s := &State{}
s.UpsertNode(Node{ID: "n1", Addr: "n1:7500", Alive: true, IsLeader: true})
s.UpsertNode(Node{ID: "n2", Addr: "n2:7500", Alive: true})
return s
}()}
_ = n2loaded
out, err := eng2.OnToken(context.Background(), tk)
if err != nil {
t.Fatal(err)
}
if !contains(out.Passed, "n2") {
t.Fatalf("n2 not stamped in round1, passed=%v", out.Passed)
}
}

View File

@ -0,0 +1,61 @@
// Runtime handlers the ring engine needs, provided by the app:
// - RuntimeLoad: sample this node's memory/network load;
// - Claim: create the frpc worker/forward for a claimed task.
package cluster
import (
"context"
"os"
"runtime"
)
// AppHandler is the app-provided bridge for ring load sampling + task claims.
type AppHandler struct {
// LoadFn returns (mem%, net%) — lower is more idle.
LoadFn func() (memPct, netPct float64)
// ClaimFn creates the forward on this node (persist + spawn worker).
ClaimFn func(ctx context.Context, tk *Task) error
}
// RuntimeLoad implements Handler.
func (a *AppHandler) RuntimeLoad() Load {
mem, net := 0.0, 0.0
if a.LoadFn != nil {
mem, net = a.LoadFn()
}
return Load{MemPct: mem, NetPct: net}
}
// Claim implements Handler.
func (a *AppHandler) Claim(ctx context.Context, tk *Task) error {
if a.ClaimFn == nil {
return nil
}
return a.ClaimFn(ctx, tk)
}
// SampleMemLoad returns a cheap memory-usage percentage (0..100).
func SampleMemLoad() float64 {
var m runtime.MemStats
runtime.ReadMemStats(&m)
total := m.Sys
if total == 0 {
return 0
}
return float64(m.Alloc) / float64(total) * 100
}
// SampleNetLoad is an approximation: bytes in/out relative to a soft budget.
// Kept simple; production could use /proc/net/dev deltas.
func SampleNetLoad() float64 {
return 0
}
// HostID returns a stable node id (hostname; empty fallback to pid).
func HostID() string {
h, _ := os.Hostname()
if h == "" {
return "node"
}
return h
}

View File

@ -0,0 +1,213 @@
// Leader-side cycle control + fault tolerance for the token ring:
// - phase flip (round1 -> round2 -> next cycle) decided by the leader when
// the token returns with all alive nodes passed;
// - token-loss resend when it does not come back within roundDelay/2 + 20ms;
// - predecessor monitors the leader by heartbeat and promotes itself when the
// leader is gone;
// - send failures (successor down) mark the neighbor offline, reattach its
// tasks, and hop to the next alive successor.
package cluster
import (
"context"
"log"
"sync"
"time"
)
// LossTimeout returns the token-loss threshold per the authoritative design:
// roundDelay/2 + 20ms.
func LossTimeout(roundDelay time.Duration) time.Duration {
return roundDelay/2 + 20*time.Millisecond
}
// inFlight tracks token-in-flight state (leader only).
type inFlight struct {
mu sync.Mutex
active bool
sentAt time.Time
delay time.Duration
}
func (f *inFlight) mark(delay time.Duration) {
f.mu.Lock()
f.active = true
f.sentAt = time.Now()
f.delay = delay
f.mu.Unlock()
}
func (f *inFlight) clear() {
f.mu.Lock()
f.active = false
f.mu.Unlock()
}
func (f *inFlight) inflight() bool {
f.mu.Lock()
defer f.mu.Unlock()
return f.active
}
func (f *inFlight) age() time.Duration {
f.mu.Lock()
defer f.mu.Unlock()
if !f.active {
return 0
}
return time.Since(f.sentAt)
}
func (f *inFlight) lastDelay() time.Duration {
f.mu.Lock()
defer f.mu.Unlock()
return f.delay
}
// flipIfComplete promotes the collect round to the sync round when every alive
// node has been stamped; at the end of sync it starts a fresh cycle. Only the
// leader flips.
func (e *Engine) flipIfComplete(tk *Token) {
if e.state.LeaderID != e.ID {
return
}
switch tk.Phase {
case PhaseCollect:
if allAlivePassed(&e.state, tk.Passed) {
tk.Phase = PhaseSync
tk.Passed = nil
e.state.RoundDelay = tkDelaySince(tk)
}
case PhaseSync:
if allAlivePassed(&e.state, tk.Passed) {
// cycle done: start next collect round.
e.state.Cycle++
e.state.RoundDelay = tkDelaySince(tk)
}
}
}
func allAlivePassed(s *State, passed []string) bool {
for _, n := range s.Nodes {
if !n.Alive {
continue
}
if !contains(passed, n.ID) {
return false
}
}
return true
}
// Send moves the token onward (or completes the cycle at the leader). It is
// called by the HTTP handler after OnToken. A full cycle stops at the leader.
func (e *Engine) Send(ctx context.Context, tk *Token) error {
if e.state.LeaderID == e.ID {
e.flipIfComplete(tk)
// If the sync round completed, the leader holds the token; watchdog
// StartRing launches the next cycle. Clear in-flight marker.
if tk.Phase == PhaseSync && allAlivePassed(&e.state, tk.Passed) {
e.inflight.clear()
return nil
}
}
return e.forwardToNext(ctx, tk)
}
// forwardToNext sends the token to the next alive successor; on failure it
// marks that node offline, reattaches its tasks, and tries the next hop.
func (e *Engine) forwardToNext(ctx context.Context, tk *Token) error {
for hops := 0; hops < len(e.state.Nodes); hops++ {
next, ok := e.nextRecipient()
if !ok {
e.inflight.clear()
return nil
}
if e.send == nil {
return nil
}
err := e.send(ctx, next, tk)
if err == nil {
e.inflight.mark(e.state.RoundDelay)
return nil
}
log.Printf("ring[%s] send to %s failed: %v", e.ID, next, err)
e.state.MarkOffline(next)
e.state.OfflineReassign(next)
if e.state.LeaderID == next {
e.becomeLeader()
}
}
e.inflight.clear()
return nil
}
// becomeLeader promotes this node (used when the monitored leader dies).
func (e *Engine) becomeLeader() {
e.state.LeaderID = e.ID
for i := range e.state.Nodes {
e.state.Nodes[i].IsLeader = e.state.Nodes[i].ID == e.ID
}
log.Printf("ring[%s] promoted to leader", e.ID)
}
// WatchLeader runs the heartbeat monitor: if our successor is the leader, ping
// it; on failure mark it offline and promote ourselves. Stops on ctx cancel.
func (e *Engine) WatchLeader(ctx context.Context) {
tick := time.NewTicker(2 * time.Second)
defer tick.Stop()
for {
select {
case <-ctx.Done():
return
case <-tick.C:
succ, ok := e.state.AliveSuccessor(e.ID)
if !ok {
continue
}
if e.state.LeaderID != succ {
continue
}
if e.send != nil {
hbCtx, cancel := context.WithTimeout(ctx, 1500*time.Millisecond)
err := e.send(hbCtx, succ, nil) // nil token = heartbeat ping
cancel()
if err != nil {
log.Printf("ring[%s] heartbeat to leader %s failed: %v", e.ID, succ, err)
e.state.MarkOffline(succ)
e.state.OfflineReassign(succ)
e.becomeLeader()
}
}
}
}
}
// WatchTokenLoss runs on the leader: if a token was sent and does not return
// within LossTimeout, resend it. Stops on ctx cancel.
func (e *Engine) WatchTokenLoss(ctx context.Context) {
tick := time.NewTicker(200 * time.Millisecond)
defer tick.Stop()
for {
select {
case <-ctx.Done():
return
case <-tick.C:
if e.ID != e.state.LeaderID {
continue
}
if !e.inflight.inflight() {
continue
}
delay := e.inflight.lastDelay()
if delay <= 0 {
delay = 200 * time.Millisecond
}
if e.inflight.age() > LossTimeout(delay) {
log.Printf("ring[%s] token lost, resending cycle %d", e.ID, e.state.Cycle)
e.inflight.clear()
e.StartRing(ctx)
}
}
}
}

View File

@ -0,0 +1,95 @@
package cluster
import (
"testing"
"webui4frpc/internal/store"
)
func newNode(id string, mem float64) Node {
return Node{ID: id, Addr: id + ":7500", Alive: true, Load: Load{MemPct: mem, NetPct: 10}}
}
func TestRingSuccessors(t *testing.T) {
s := &State{}
s.InsertAfter("", newNode("a", 50))
s.InsertAfter("a", newNode("b", 40))
s.InsertAfter("b", newNode("c", 30))
if got, _ := s.AliveSuccessor("a"); got != "b" {
t.Fatalf("succ(a)=%s want b", got)
}
if got, _ := s.AliveSuccessor("c"); got != "a" {
t.Fatalf("succ(c)=%s want a (wrap)", got)
}
s.MarkOffline("b")
if got, _ := s.AliveSuccessor("a"); got != "c" {
t.Fatalf("succ(a) after b down =%s want c", got)
}
if got, _ := s.AlivePredecessor("a"); got != "c" {
t.Fatalf("pred(a)=%s want c", got)
}
}
func TestLowestLoadClaimsTask(t *testing.T) {
s := &State{}
s.InsertAfter("", newNode("a", 90))
s.InsertAfter("a", newNode("b", 20)) // idle
t1 := s.AddPending(store.Local{Name: "l1"}, store.Remote{Name: "r1"}, store.Link{})
low := s.LowestAlive()
if low == nil || low.ID != "b" {
t.Fatalf("lowest = %+v want b", low)
}
// b claims + establishes topology
claimed := s.ClaimPending(t1.ID)
if claimed == nil {
t.Fatal("claim should succeed")
}
if len(s.PendingList()) != 0 {
t.Fatalf("pending after claim = %+v", s.PendingList())
}
s.AddTopology(claimed, "b")
if len(s.TopologyList()) != 1 || s.TopologyList()[0].OwnerID != "b" {
t.Fatalf("topology = %+v", s.TopologyList())
}
}
func TestOfflineReassignTopology(t *testing.T) {
s := &State{}
s.InsertAfter("", newNode("a", 50))
// a establishes two forwards
for _, ln := range []string{"l1", "l2"} {
p := s.AddPending(store.Local{Name: ln}, store.Remote{Name: "r1"}, store.Link{})
s.ClaimPending(p.ID)
s.AddTopology(p, "a")
}
if len(s.TopologyList()) != 2 {
t.Fatalf("topology = %+v", s.TopologyList())
}
// a goes offline: forwards re-attached as pending
reattached := s.OfflineReassign("a")
if len(reattached) != 2 {
t.Fatalf("reattached=%v want 2", reattached)
}
if len(s.TopologyList()) != 0 {
t.Fatalf("topology after reassign = %+v", s.TopologyList())
}
if len(s.PendingList()) != 2 {
t.Fatalf("pending after reassign = %+v", s.PendingList())
}
}
func TestInsertAfterReorders(t *testing.T) {
s := &State{}
s.InsertAfter("", newNode("a", 50))
s.InsertAfter("a", newNode("b", 50))
s.InsertAfter("a", newNode("new", 50)) // new lands after a, b pushed after new
var order []string
for _, n := range s.Nodes {
order = append(order, n.ID)
}
if order[0] != "a" || order[1] != "new" || order[2] != "b" {
t.Fatalf("order = %v want [a new b]", order)
}
}

View File

@ -39,6 +39,8 @@ type Handler struct {
SyncWorkers func()
// Cluster is the peer-to-peer binary registry (M6). Nil disables M6 routes.
Cluster *cluster.Registry
// Ring is the token-ring engine (M6). Nil disables ring routes.
Ring *cluster.Engine
// SelfAddr is this node's reachable listen address (from -addr), used for
// cluster discovery so peers can reach back for binary exchange.
SelfAddr string
@ -90,6 +92,8 @@ func NewServeMux(h *Handler) (http.Handler, error) {
// M6: cluster nodes + per-node cached versions (UI + discovery).
mux.HandleFunc(apiPrefix+"/cluster/nodes", auth(h.handleClusterNodes))
mux.HandleFunc(apiPrefix+"/cluster/cache", auth(h.handleClusterCache))
mux.HandleFunc(apiPrefix+"/cluster/token", auth(h.handleClusterToken))
mux.HandleFunc(apiPrefix+"/cluster/ring", auth(h.handleClusterRing))
// M6: peer-to-peer binary exchange endpoint (Basic Auth, same creds).
// Not under /api so peers hit it directly; auth still applied.
@ -349,6 +353,45 @@ func (h *Handler) handleClusterCache(w http.ResponseWriter, r *http.Request) {
}
}
// handleClusterToken receives the circulating token (POST), lets the ring
// engine process it, returns the updated token so the caller can forward it.
func (h *Handler) handleClusterToken(w http.ResponseWriter, r *http.Request) {
if h.Ring == nil {
http.Error(w, "ring engine not enabled", http.StatusNotFound)
return
}
if r.Method != http.MethodPost {
methodNotAllowed(w)
return
}
var tk cluster.Token
if err := json.NewDecoder(r.Body).Decode(&tk); err != nil {
http.Error(w, "parse token: "+err.Error(), http.StatusBadRequest)
return
}
updated, err := h.Ring.OnToken(r.Context(), &tk)
if err != nil {
http.Error(w, "token process: "+err.Error(), http.StatusInternalServerError)
return
}
// After processing, the receiving node passes it along to its successor.
_ = h.Ring.Forward(r.Context(), updated)
writeJSON(w, http.StatusOK, updated)
}
// handleClusterRing reports the local ring engine state snapshot (frontend).
func (h *Handler) handleClusterRing(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w)
return
}
if h.Ring == nil {
http.Error(w, "ring engine not enabled", http.StatusNotFound)
return
}
writeJSON(w, http.StatusOK, h.Ring.Snapshot())
}
// frpcVersionOf extracts the frpc version from a binary path like
// .../bin/frpc-0.71.0/frpc. Empty when not a versioned cache path.
func frpcVersionOf(binPath string) string {

49
plan.md
View File

@ -78,17 +78,48 @@
> 注:发布类条目已并入 **M7 运维与发布(原 M5**,本节 M5 保留初始清单。
>
### M6 集群内 frpc 二进制分发(节点间优先,外部 URL 兜底
### M6 集群(令牌环网拓扑
> 诉求:集群内 frpc 二进制**优先通过集群节点间交换**获得,无法交换时才走外部 URL 下载;新节点加入集群时,**自动由邻居节点向其传输二进制**
> **权威设计(用户约定,必须固化)**:集群节点间通过**令牌环网token ring**通信,两轮为一个周期
- [x] 二进制来源优先级:本地已有缓存 > 集群节点间交换HTTP/Chunked 拉取) > 外部 URLGitHub Releases
- [x] 节点注册表Registrypeers/nodeInf 内存态)+ EnsureVersion 优先级分发
- [x] 传输通道:节点间 HTTP GET /frpc/{version}Basic Auth + 256MiB 上限 + SHA-256 校验),回退 GitHub URL
- [x] 入网引导new node joins with -peer and auto-pulls binary from neighbors (verified e2e)
- [x] 失败回退EnsureVersion 内建回退外部 URL单测覆盖 peer 优先 + 回退)
- [ ] Cache 管理版本保留策略LRU / 仅保留常用)、可用性标记(黑名单失效节点)
- [ ] UI设置页展示二进制来源与缓存状态集群页展示节点间传输进度
#### 拓扑与角色
- 集群 = 若干 webui4frpc 节点组成的内网协作网络;逻辑上各节点**平等**,均持有**完整集群信息**(节点表、转发链、各自配置)与完整 webui。
- **leader**:唯一特殊角色,负责启动时发送第一个令牌;默认**创建集群的节点**为初始 leader。
- **转发链ring**:令牌按固定顺序传递;每个节点记录前驱/后继。
#### 令牌环协议(两轮一周期)
- **第一轮**:令牌在环上传递,各节点收到后**把自己的信息追加到令牌**地址、负载指标、frpc 缓存、转发能力等)。
- **第二轮**:令牌再次环行,各节点**按令牌内容同步集群信息**(更新自己的完整集群视图)。
- 新转发任务:需要建立转发时,**把任务附加在令牌中**(而非直接指派);因每节点都持完整集群信息,由**负载最低的节点****内存使用率 + 网络使用率共同判断**)自行摘取并创建转发。
- **负载摘取**:令牌传递到某节点时,若该节点在上一轮被判定为负载最低,则**先主动摘取任务**,随后在令牌中更新自身信息。
#### 容错与邻居离线
- 令牌传递超时(未收到回执)→ 判定邻居离线。因每节点持完整集群信息,当前节点可**自动修改集群状态**,并把邻居负责的转发**作为新任务追加到令牌**,再传给下一节点。
- leader 检测令牌丢失:发出令牌后超过 **(轮次延迟 / 2 + 20ms)** 未回传 → 判定令牌丢失(可能某节点接令后崩溃),**重发令牌**。
- leader 每轮探测后更新一次轮次延迟。
#### leader 补充/监控
- leader 受其上家邻居监控:二者**交换心跳包**(因节点都持完整信息,容易做到)。
- 上家邻居探测到 leader 崩溃 → **自身成为新 leader**
#### 新节点加入
- 每个节点都有 webui用户登录到哪个节点的 webui就由**该节点**执行新节点加入:令牌发到自己时,**先转发给新节点**,并把新节点加入令牌中的集群信息,**更新转发链**:新节点放在自己后面,自己原本的后继放在新节点之后。
#### 实现里程碑(待按上述设计重建)
- [ ] 数据面TypeSet 集群状态(节点表/转发链/leader/轮次延迟/负载指标/待办任务),每节点一份完整副本
- [ ] 令牌环传输HTTP 轮转传递 + 回执;两轮一周期(信息追加 → 同步)
- [ ] leader初始 leader 选择(创建集群者)、首令牌发送、轮次延迟测量、令牌丢失重发
- [ ] 新转发任务:挂到令牌 → 各节点负载评估(内存+网络)→ 负载最低节点摘取并创建
- [ ] 离线处理:令牌超时 → 改集群状态 → 邻居转发转任务重挂
- [ ] leader 监控:上家邻居心跳探测 → 崩溃提升为新 leader
- [ ] 新节点加入:入口节点 webui → 令牌插入新节点 + 更新转发链
- [ ] 前端:集群页展示环拓扑/令牌轮次/负载/任务摘取;设置页二进制缓存状态
### M7 运维与发布(原 M5

View File

@ -2,143 +2,102 @@
<div class="cluster-page">
<div class="cluster-top">
<h2 class="page-title">集群</h2>
<span class="page-sub">负载均衡组 + 健康检查状态M3</span>
<button class="refresh-btn" @click="load">刷新</button>
<span class="page-sub">webui4frpc 节点网络 · frpc 二进制分发</span>
<button class="refresh-btn" @click="loadAll">刷新</button>
</div>
<div class="cluster-body">
<!-- 远程节点 × LB 组概览 -->
<section class="section" v-if="profileList.length">
<h3 class="section-title">
远程节点 × 负载均衡组
<span class="hint">group 中同一 local 多成员自动负载均衡</span>
</h3>
<div class="node-grid">
<div v-for="p in profileList" :key="p.name" class="node-card">
<div class="nc-head">
<span class="nc-name">{{ p.name }}</span>
<span class="nc-badge" :class="connClass(p.connState)">
{{ connLabel(p.connState) }}
</span>
<span v-if="p.groupCount" class="nc-lb">LB ×{{ p.groupCount }}</span>
</div>
<div class="nc-meta">
<span v-if="p.adminEnabled" class="tag">admin API 状态</span>
<span class="fwd-count">{{ p.forwards.length }} 转发</span>
</div>
<!-- per-proxy 健康状态 -->
<div v-if="p.proxyStates?.length" class="proxy-list">
<div
v-for="ps in p.proxyStates"
:key="ps.name"
class="proxy-row"
:class="psClass(ps.status)"
>
<span class="pr-name">{{ ps.name }}</span>
<span class="pr-type">{{ ps.type }}</span>
<span class="pr-status">{{ psLabel(ps.status) }}</span>
<span v-if="ps.remote_addr" class="pr-addr">{{ ps.remote_addr }}</span>
<span v-if="ps.err" class="pr-err" :title="ps.err">{{ ps.err }}</span>
</div>
</div>
<div v-else class="proxy-empty">
{{ p.adminEnabled ? 'admin API 未返回状态frpc 未连上 frps' : '未启用 admin API回退日志推断' }}
</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>
</section>
<!-- 本地服务 LB 组成员 -->
<section class="section" v-if="localStatusList.length">
<h3 class="section-title">本地服务 转发成员</h3>
<div class="ls-list">
<div v-for="ls in localStatusList" :key="ls.local.name" class="ls-card">
<div class="ls-head">
<span class="ls-name">{{ ls.local.name }}</span>
<span class="ls-addr">{{ ls.local.ip }}:{{ ls.local.port }}</span>
<span class="ls-proto">{{ ls.local.protocol }}</span>
<span v-if="ls.local.lbGroup" class="ls-lb">
group: {{ ls.local.lbGroup }}
</span>
</div>
<div class="ls-targets">
<div v-for="t in ls.targets" :key="t.remote + ':' + t.remotePort" class="ls-tgt">
{{ t.remote }}:{{ t.remotePort }}
<span class="ls-state" :class="{ ok: t.workerState === 'running' }">
{{ t.workerState }}
</span>
</div>
<div v-if="!ls.targets.length" class="ls-none">未转发</div>
</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>
</section>
</div>
<div v-if="!nodes.length" class="empty-tip">尚未加入集群本机始终在内 -peer 连接邻居</div>
</section>
<el-empty v-if="!profileList.length && !localStatusList.length" description="暂无集群配置(先在状态/连接配置页添加 remote 与 local" />
</div>
<!-- 本机二进制缓存 + 管理 -->
<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>
</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>
<el-empty v-if="!nodes.length && !cache.length" description="集群无数据(未配置 -peer 且无 frpc 缓存)" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { ElMessage } from 'element-plus'
import { api } from '../api'
import type { StatusResp } from '../types'
import type { ClusterNode, CacheEntry } from '../types'
const status = ref<StatusResp | null>(null)
const nodes = ref<ClusterNode[]>([])
const cache = ref<CacheEntry[]>([])
const keep = ref(2)
const prunedMsg = ref('')
let timer: number | null = null
const profileList = computed(() => status.value?.profiles ?? [])
const localStatusList = computed(() => status.value?.localStatus ?? [])
const load = async () => {
const loadAll = async () => {
try {
status.value = await api.status()
} catch {
// keep last on transient errors
}
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 connLabel = (s?: string) => {
switch (s) {
case 'connected': return '已连接'
case 'connecting': return '连接中'
case 'failed': return '连接失败'
case 'disabled': return '已停用'
default: return '未启动'
}
}
const connClass = (s?: string) => {
switch (s) {
case 'connected': return 'ok'
case 'connecting': return 'pending'
case 'failed': return 'err'
default: return ''
}
}
const psLabel = (s?: string) => {
switch (s) {
case 'running': return '运行中'
case 'wait start': return '等待启动'
case 'start error': return '启动错误'
case 'check failed': return '健康检查失败'
case 'closed': return '已关闭'
default: return '新建'
}
}
const psClass = (s?: string) => {
switch (s) {
case 'running': return 'ok'
case 'check failed':
case 'start error': return 'err'
case 'wait start': return 'pending'
default: return ''
// 首个节点恒为本机;其后为心跳发现的邻居。状态按 seenAt/可达性粗分:
// 本机=ok邻居暂显示已发现后续可加 seenAt 超时判定)。
const nodeStateLabel = (i: number) => (i === 0 ? '在线(本机)' : '已发现')
const nodeStateClass = (i: number) => (i === 0 ? 'ok' : 'pending')
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))
}
}
onMounted(() => {
load()
timer = window.setInterval(load, 8000)
loadAll()
timer = window.setInterval(loadAll, 8000)
})
onBeforeUnmount(() => {
if (timer !== null) { clearInterval(timer); timer = null }
@ -146,85 +105,31 @@ onBeforeUnmount(() => {
</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;
}
.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; }
}
.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(340px, 1fr));
gap: 12px;
}
.node-card {
border: 1px solid $color-border-light;
border-radius: 10px;
padding: 12px 14px;
background: #fff;
}
.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-badge { font-size: 12px; padding: 1px 8px; border-radius: 8px; }
.nc-badge.ok { background: #f0f9eb; color: $color-success; }
.nc-badge.pending { background: #ecf5ff; color: #409eff; }
.nc-badge.err { background: #fef0f0; color: $color-danger; }
.nc-badge:not(.ok):not(.pending):not(.err) { background: #f2f3f5; color: $color-text-muted; }
.nc-lb { margin-left: auto; font-size: 11px; background: rgba(64,158,255,.12); color: #409eff; border-radius: 10px; padding: 1px 8px; }
.nc-meta { margin: 6px 0 8px; display: flex; gap: 8px; align-items: center; }
.tag { font-size: 11px; background: #ecf5ff; color: #409eff; border-radius: 6px; padding: 1px 6px; }
.fwd-count { font-size: 12px; color: $color-text-muted; }
.proxy-list { display: flex; flex-direction: column; gap: 4px; }
.proxy-row {
display: flex; align-items: center; gap: 8px;
font-size: 12px; border: 1px solid $color-border-lighter;
border-radius: 8px; padding: 3px 8px;
}
.proxy-row.ok { border-left: 3px solid $color-success; }
.proxy-row.pending { border-left: 3px solid #409eff; }
.proxy-row.err { border-left: 3px solid $color-danger; }
.pr-name { font-weight: 600; }
.pr-type { font-size: 11px; color: $color-text-muted; }
.pr-status { color: $color-text-secondary; }
.pr-addr { margin-left: auto; font-size: 11px; color: $color-text-muted; }
.pr-err { margin-left: auto; font-size: 11px; color: $color-danger; max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.proxy-empty { font-size: 12px; color: $color-text-muted; }
.ls-list { display: flex; flex-direction: column; gap: 8px; }
.ls-card { border: 1px solid $color-border-light; border-radius: 10px; padding: 10px 14px; background: #fafafa; }
.ls-head { display: flex; align-items: center; gap: 8px; }
.ls-name { font-weight: 600; }
.ls-addr { font-size: 12px; color: $color-text-secondary; }
.ls-proto { font-size: 11px; background: #ecf5ff; color: #409eff; border-radius: 6px; padding: 1px 6px; }
.ls-lb { font-size: 11px; background: rgba(64,158,255,.12); color: #409eff; border-radius: 6px; padding: 1px 6px; }
.ls-targets { margin-top: 6px; display: flex; flex-direction: column; gap: 4px; }
.ls-tgt { font-size: 13px; display: flex; align-items: center; gap: 6px; }
.ls-state { margin-left: auto; font-size: 12px; color: $color-danger; }
.ls-state.ok { color: $color-success; }
.ls-none { color: $color-text-light; font-size: 12px; }
</style>
.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; }
</style>