feat: cluster reliability (leader failover, crash rejoin, key exchange) + auth/users + canvas/forwards enhancements + comprehensive README + API docs

- Cluster: forwardToNext offline detection (leader+non-leader), WatchLeader 1s heartbeat fallback, 409 for standalone nodes, Node.NodeKey key exchange via token ring, ClusterPeers persistence + auto-rejoin, Forward delegates to forwardToNext (bugfix)
- Auth: Basic Auth (flag-creds fast path) + bcrypt users (admin/viewer) + Bearer API keys (read/write/admin scope)
- Frontend: UsersView (accounts+API keys), ClusterView (ring/nodeKey/tasks/topology/log), StatusView (group management, per-proxy status), CanvasView (edge toggle/group), PortEdge (disabled/group labels)
- API: handlers split (canvas/forwards/users/logs), canvas export/import, forwards group start/stop/assign/delete, cluster endpoints
- Docs: comprehensive README rewrite (all flags/APIs/auth/cluster), docs/cluster-api.md (cluster management API reference)
- Deploy: run-cluster.sh now 4-node ring + 1 isolated standalone, test-forward.sh updated for 4 nodes
- Removed plan.md (design notes consolidated into README + API docs)
This commit is contained in:
2026-08-19 21:09:24 +08:00
parent b518a13446
commit eda9bb9597
55 changed files with 5462 additions and 793 deletions

View File

@ -13,7 +13,7 @@
<div class="app-shell">
<aside class="sidebar">
<div class="sb-brand">
<span class="sb-logo" aria-hidden="true">W</span>
<img class="sb-logo" src="/favicon.svg" alt="webui4frpc" />
<span class="sb-brand-text">
<b>webui4frpc</b>
<small>令牌环 · 转发编排</small>
@ -34,7 +34,15 @@
</nav>
<div class="sb-foot">
<span class="sb-status"><span class="dot" /> token-ring · M6</span>
<div class="sb-identity" v-if="authReady">
<span class="id-name">{{ authName || '匿名' }}</span>
<span class="id-level" :class="authLevel">{{ levelLabel }}</span>
</div>
<div class="sb-theme" role="group" aria-label="主题">
<button class="sw white" :class="{ on: theme === 'white' }" title="白色" @click="setTheme('white')" />
<button class="sw blue" :class="{ on: theme === 'blue' }" title="蓝色" @click="setTheme('blue')" />
<button class="sw pink" :class="{ on: theme === 'pink' }" title="粉色" @click="setTheme('pink')" />
</div>
</div>
</aside>
@ -46,6 +54,7 @@
<CanvasView v-if="view === 'canvas'" />
<SettingsView v-else-if="view === 'settings'" />
<ClusterView v-else-if="view === 'cluster'" />
<UsersView v-else-if="view === 'users'" />
<StatusView v-else />
</main>
</div>
@ -53,23 +62,54 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { computed, onMounted, ref } from 'vue'
import CanvasView from './views/CanvasView.vue'
import SettingsView from './views/SettingsView.vue'
import StatusView from './views/StatusView.vue'
import ClusterView from './views/ClusterView.vue'
import UsersView from './views/UsersView.vue'
import { authLevel, authName, authReady, fetchMe, isAdmin } from './auth'
type ViewKey = 'canvas' | 'settings' | 'status' | 'cluster'
type ViewKey = 'canvas' | 'settings' | 'status' | 'cluster' | 'users'
const view = ref<ViewKey>('status')
const nav: { key: ViewKey; label: string; icon: string }[] = [
onMounted(() => { fetchMe() })
const levelLabel = computed(() =>
authLevel.value === 'admin' ? '管理员' : authLevel.value === 'write' ? '写' : '只读',
)
// ---- theme switcher (white default / blue / pink) ----
// The data-theme attribute is applied pre-mount by main.ts (no flash); here we
// only mirror it so the active swatch highlights, and update it on click.
type Theme = 'white' | 'blue' | 'pink'
const VALID_THEMES: Theme[] = ['white', 'blue', 'pink']
const theme = ref<Theme>(
(VALID_THEMES as string[]).includes(document.documentElement.getAttribute('data-theme') || '')
? (document.documentElement.getAttribute('data-theme') as Theme)
: 'white',
)
const setTheme = (t: Theme) => {
theme.value = t
document.documentElement.setAttribute('data-theme', t)
localStorage.setItem('w4f-theme', t)
}
const allNav: { key: ViewKey; label: string; icon: string; adminOnly?: boolean }[] = [
{ key: 'status', label: '状态', icon: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18"/><path d="m19 9-5 5-4-4-3 3"/></svg>' },
{ key: 'canvas', label: '连接配置', icon: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><circle cx="12" cy="18" r="3"/><path d="M8.5 7.5 16 16M15.5 7.5 8 16"/></svg>' },
{ key: 'settings', label: '设置', icon: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>' },
{ key: 'cluster', label: '集群', icon: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="2"/><circle cx="5" cy="19" r="2"/><circle cx="19" cy="19" r="2"/><path d="M12 7v4m0 0-5 6m5-6 5 6"/></svg>' },
{ key: 'users', label: '账号与密钥', adminOnly: true, icon: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>' },
]
const currentLabel = computed(() => nav.find((n) => n.key === view.value)?.label ?? '')
// nav filters admin-only entries (账号与密钥) until /me resolves to admin.
// Until authReady, the users entry is hidden so a viewer never sees it flash.
const nav = computed(() =>
allNav.filter((n) => !n.adminOnly || (authReady.value && isAdmin.value)),
)
const currentLabel = computed(() => nav.value.find((n) => n.key === view.value)?.label ?? '')
</script>
<style scoped>
@ -111,14 +151,8 @@ const currentLabel = computed(() => nav.find((n) => n.key === view.value)?.label
height: 40px;
border-radius: 13px;
flex: 0 0 auto;
display: grid;
place-items: center;
color: #fff;
font-weight: 800;
font-size: 19px;
letter-spacing: 0.5px;
background: linear-gradient(135deg, var(--w4f-primary), var(--w4f-secondary));
box-shadow: 0 6px 18px rgba(255, 127, 172, 0.45);
object-fit: contain;
box-shadow: 0 6px 18px var(--w4f-glow);
}
.sb-brand-text b {
font-size: 16.5px;
@ -182,26 +216,61 @@ const currentLabel = computed(() => nav.find((n) => n.key === view.value)?.label
margin-top: auto;
padding-top: 16px;
}
.sb-status {
display: inline-flex;
/* ---------- identity chip ---------- */
.sb-identity {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 9px 13px;
border-radius: var(--w4f-radius-pill);
padding: 8px 10px;
margin-bottom: 12px;
border-radius: 10px;
background: var(--w4f-card-2);
border: 1px solid var(--w4f-line);
font-size: 12.5px;
}
.sb-identity .id-name {
font-weight: 600;
color: var(--w4f-primary-h);
background: var(--w4f-primary-50);
color: var(--w4f-fg);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sb-status .dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--w4f-primary);
box-shadow: 0 0 0 3px rgba(255, 127, 172, 0.18);
animation: blink 1.6s infinite;
.sb-identity .id-level {
flex: 0 0 auto;
padding: 2px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 700;
color: var(--w4f-muted);
background: var(--w4f-card);
}
@keyframes blink { 50% { opacity: 0.25; } }
.sb-identity .id-level.admin {
color: #fff;
background: linear-gradient(135deg, var(--w4f-primary), var(--w4f-secondary));
}
.sb-identity .id-level.write {
color: var(--w4f-warning);
background: var(--w4f-warning-50);
}
.sb-identity .id-level.read {
color: var(--w4f-muted);
}
/* ---------- theme switcher ---------- */
.sb-theme { display: flex; gap: 6px; margin-bottom: 12px; }
.sb-theme .sw {
width: 26px; height: 26px; border-radius: 8px; border: 2px solid var(--w4f-line);
cursor: pointer; padding: 0;
transition: transform 0.15s var(--w4f-ease-spring), box-shadow 0.15s var(--w4f-ease-spring);
}
.sb-theme .sw:hover { transform: translateY(-1px); }
.sb-theme .sw.on { border-color: var(--w4f-primary); box-shadow: 0 0 0 3px var(--w4f-glow-soft); }
/* swatch fills are FIXED (represent each theme's identity), not theme-driven */
.sb-theme .sw.white { background: linear-gradient(135deg, #ffffff, #cdd4e0); }
.sb-theme .sw.blue { background: linear-gradient(135deg, #3b82f6, #06b6d4); }
.sb-theme .sw.pink { background: linear-gradient(135deg, #ff7fac, #f33b7c); }
/* ---------- main ---------- */
.main {