mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-22 18:07:55 +00:00
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:
@ -4,7 +4,7 @@
|
||||
<div class="topbar w4f-card">
|
||||
<div class="tb-left">
|
||||
<h2 class="page-title">状态总览</h2>
|
||||
<span class="page-sub">每 5 秒自动刷新 · frpc worker 进程态 + frps 连接态</span>
|
||||
<span class="page-sub">每 2.5 秒自动刷新</span>
|
||||
</div>
|
||||
<div class="tb-right">
|
||||
<button class="w4f-btn ghost small" @click="loadStatus">⟳ 刷新</button>
|
||||
@ -37,9 +37,57 @@
|
||||
</div>
|
||||
|
||||
<div class="status-body" v-if="status">
|
||||
<!-- 远程节点状态 -->
|
||||
<!-- 转发 (forward-centric): 本地/远程 by localOnly, per-card 启停, 分组一键 -->
|
||||
<section v-for="g in forwardsByGroup" :key="g.key || '__none__'" class="section">
|
||||
<h3 class="section-title">
|
||||
<span class="k-ic">→</span>{{ g.label }}
|
||||
<span class="grp-count">{{ g.items.length }} 条</span>
|
||||
<span class="grp-actions">
|
||||
<button class="w4f-btn small" @click="groupStart(g.key)">一键启动整组</button>
|
||||
<button class="w4f-btn ghost small" @click="groupStop(g.key)">一键停止整组</button>
|
||||
<button v-if="g.key" class="w4f-btn ghost small danger" @click="deleteGroup(g.key)">删除分组</button>
|
||||
</span>
|
||||
</h3>
|
||||
<div class="card-grid">
|
||||
<div
|
||||
v-for="f in g.items"
|
||||
:key="f.local + ':' + f.remote + ':' + f.remotePort"
|
||||
class="fwd-card w4f-card"
|
||||
:class="{ active: f.active, stopped: f.disabled }"
|
||||
>
|
||||
<div class="fwd-head">
|
||||
<span class="fwd-kind" :class="f.kind">{{ f.kind === 'local' ? '本地' : '远程' }}</span>
|
||||
<span
|
||||
class="fwd-dot"
|
||||
:class="{ on: f.active }"
|
||||
:title="f.active ? '运行中' : f.disabled ? '已停止' : '未运行'"
|
||||
/>
|
||||
<span class="fwd-route">
|
||||
<b>{{ f.local }}</b><span class="fwd-ip" v-if="f.localIp"> {{ f.localIp }}:{{ f.localPort }}</span>
|
||||
<span class="fwd-arrow">→</span>
|
||||
<b>{{ f.remote }}</b>:<span class="fwd-port">{{ f.remotePort }}</span>
|
||||
</span>
|
||||
<span v-if="f.localProto" class="w4f-tag w4f-tag-primary">{{ f.localProto }}</span>
|
||||
<span
|
||||
class="w4f-tag w4f-tag-info group-chip"
|
||||
@click="editGroup(f)"
|
||||
:title="'点击修改分组(当前:' + (f.group || '未分组') + ')'"
|
||||
>{{ f.group || '未分组' }}</span>
|
||||
<span v-if="f.kind === 'remote'" class="fwd-owner" :title="f.ownerId || ''">{{ ownerLabel(f) }}</span>
|
||||
<span class="fwd-actions">
|
||||
<button v-if="!f.active" class="w4f-btn small" @click="startForward(f)">启动转发</button>
|
||||
<button v-else class="w4f-btn ghost small" @click="stopForward(f)">停止转发</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!g.items.length" class="empty">该组暂无转发</div>
|
||||
</div>
|
||||
</section>
|
||||
<div v-if="!status.forwards.length" class="empty">尚未配置转发 — 前往「连接配置」画布编排 local→remote 连线</div>
|
||||
|
||||
<!-- 远程 frps 节点 (只读连接态; 启停已移至转发卡) -->
|
||||
<section class="section">
|
||||
<h3 class="section-title"><span class="k-ic">⊕</span>远程节点状态<span class="hint">管理本地 frpc worker 进程(不管理远程 frps)</span></h3>
|
||||
<h3 class="section-title"><span class="k-ic">⊕</span>远程节点状态</h3>
|
||||
<div class="card-grid">
|
||||
<div
|
||||
v-for="p in status.profiles"
|
||||
@ -53,8 +101,6 @@
|
||||
<span class="w4f-dot" :class="connDotClass(p.connState)" />{{ connStateLabel(p.connState) }}
|
||||
</span>
|
||||
<span class="nc-actions">
|
||||
<button v-if="p.process.state === 'running'" class="w4f-btn ghost small" @click="stopRemote(p.name)">停 worker</button>
|
||||
<button v-else class="w4f-btn small" @click="startRemote(p.name)">启 worker</button>
|
||||
<button class="w4f-btn ghost small" @click="openEdit(p)">编辑</button>
|
||||
<button class="w4f-btn danger small" @click="removeRemote(p.name)">删除</button>
|
||||
</span>
|
||||
@ -95,15 +141,16 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 本地服务转发状态 -->
|
||||
<!-- 本地服务 (overview) -->
|
||||
<section class="section">
|
||||
<h3 class="section-title"><span class="k-ic">◳</span>本地服务转发<span class="hint">本地服务经 frpc worker 转发至远程 frps</span></h3>
|
||||
<h3 class="section-title"><span class="k-ic">◳</span>本地服务</h3>
|
||||
<div class="card-grid">
|
||||
<div v-for="ls in status.localStatus" :key="ls.local.name" class="local-card w4f-card">
|
||||
<div class="lc-head">
|
||||
<span class="lc-name">{{ ls.local.name }}</span>
|
||||
<span class="lc-addr">{{ ls.local.ip }}:{{ ls.local.port }}</span>
|
||||
<span class="w4f-tag w4f-tag-primary">{{ ls.local.protocol }}</span>
|
||||
<span v-if="ls.local.localOnly" class="w4f-tag w4f-tag-ok">本地</span>
|
||||
</div>
|
||||
<div class="lc-targets">
|
||||
<div v-for="t in ls.targets" :key="t.remote + ':' + t.remotePort" class="lc-target">
|
||||
@ -157,12 +204,13 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { api } from '../api'
|
||||
import type { Remote, StatusResp } from '../types'
|
||||
import type { ForwardStatus, Remote, StatusResp } from '../types'
|
||||
|
||||
const status = ref<StatusResp | null>(null)
|
||||
let timer: number | null = null
|
||||
let visHandler: (() => void) | null = null
|
||||
|
||||
interface RemoteDialog {
|
||||
visible: boolean
|
||||
@ -216,24 +264,93 @@ const saveRemote = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const startRemote = async (name: string) => {
|
||||
// ---- Per-forward start/stop (forwards page) ----
|
||||
// local-only → toggle local frpc worker (render skips disabled proxies);
|
||||
// cluster → ring submit/revoke. Idempotent on the backend.
|
||||
const startForward = async (f: ForwardStatus) => {
|
||||
try {
|
||||
await api.profileStart(name)
|
||||
await api.forwardStart(f.local, f.remote, f.remotePort)
|
||||
await loadStatus()
|
||||
} catch (e: any) {
|
||||
ElMessage.error('启动失败: ' + (e.message || e))
|
||||
ElMessage.error('启动转发失败: ' + (e.message || e))
|
||||
}
|
||||
}
|
||||
const stopForward = async (f: ForwardStatus) => {
|
||||
try {
|
||||
await api.forwardStop(f.local, f.remote, f.remotePort)
|
||||
await loadStatus()
|
||||
} catch (e: any) {
|
||||
ElMessage.error('停止转发失败: ' + (e.message || e))
|
||||
}
|
||||
}
|
||||
const groupStart = async (group: string) => {
|
||||
try {
|
||||
await api.groupStart(group)
|
||||
await loadStatus()
|
||||
} catch (e: any) {
|
||||
ElMessage.error('启动分组失败: ' + (e.message || e))
|
||||
}
|
||||
}
|
||||
const groupStop = async (group: string) => {
|
||||
try {
|
||||
await api.groupStop(group)
|
||||
await loadStatus()
|
||||
} catch (e: any) {
|
||||
ElMessage.error('停止分组失败: ' + (e.message || e))
|
||||
}
|
||||
}
|
||||
|
||||
const stopRemote = async (name: string) => {
|
||||
// editGroup opens a prompt to change a single forward's group label from the
|
||||
// card's group chip. Lists existing group names for quick selection; empty
|
||||
// input moves the forward to 未分组. Pure DB update — no worker/ring action.
|
||||
const editGroup = async (f: ForwardStatus) => {
|
||||
const existing = new Set<string>()
|
||||
for (const fw of status.value?.forwards ?? []) {
|
||||
if (fw.group) existing.add(fw.group)
|
||||
}
|
||||
const hint = existing.size
|
||||
? `现有分组:${[...existing].join('、')}(留空=移出分组)`
|
||||
: '输入分组名称(留空=移出分组)'
|
||||
try {
|
||||
await api.profileStop(name)
|
||||
const { value } = await ElMessageBox.prompt(hint, '修改分组', {
|
||||
inputValue: f.group || '',
|
||||
inputPlaceholder: '分组名称',
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
const g = (value || '').trim()
|
||||
await api.assignGroup(f.local, f.remote, f.remotePort, g)
|
||||
await loadStatus()
|
||||
} catch (e: any) {
|
||||
ElMessage.error('停止失败: ' + (e.message || e))
|
||||
// user cancelled (reject) → no action; real errors show a toast
|
||||
if (e !== 'cancel' && e?.message) {
|
||||
ElMessage.error('修改分组失败: ' + (e.message || e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// deleteGroup dissolves a named group: all members are moved to 未分组. The
|
||||
// group itself is just a label on links, so clearing all members is the
|
||||
// complete delete. Confirms first since it affects every forward in the group.
|
||||
const deleteGroup = async (group: string) => {
|
||||
if (!window.confirm(`删除分组「${group}」?组内所有转发将移至未分组(转发本身不受影响)。`)) return
|
||||
try {
|
||||
await api.deleteGroup(group)
|
||||
await loadStatus()
|
||||
ElMessage.success(`已删除分组「${group}」`)
|
||||
} catch (e: any) {
|
||||
ElMessage.error('删除分组失败: ' + (e.message || e))
|
||||
}
|
||||
}
|
||||
|
||||
// ownerLabel: human-readable topology owner for a remote (cluster) forward.
|
||||
const ownerLabel = (f: ForwardStatus) => {
|
||||
if (f.kind !== 'remote') return ''
|
||||
if (!f.ownerId) return '待分配'
|
||||
if (f.ownerId === status.value?.selfId) return '本机持有'
|
||||
return '持有 ' + f.ownerId
|
||||
}
|
||||
|
||||
const removeRemote = async (name: string) => {
|
||||
if (!window.confirm(`删除远程节点 ${name}?`)) return
|
||||
try {
|
||||
@ -257,7 +374,29 @@ const workerHealthy = (s?: string) => s === 'running'
|
||||
const runningCount = computed(() => status.value?.profiles.filter((p) => p.process.state === 'running').length ?? 0)
|
||||
const onlineCount = computed(() => status.value?.profiles.filter((p) => p.connState === 'connected').length ?? 0)
|
||||
const allConnected = computed(() => (status.value?.profiles.length ?? 0) > 0 && onlineCount.value === status.value?.profiles.length)
|
||||
const activeForwards = computed(() => status.value?.profiles.reduce((s, p) => s + p.forwards.length, 0) ?? 0)
|
||||
const activeForwards = computed(() => status.value?.forwards.filter((f) => f.active).length ?? 0)
|
||||
|
||||
// forwardsByGroup buckets the forward-centric list by Link.group; the empty
|
||||
// group is labelled 未分组 and sorted last so named groups surface first.
|
||||
const forwardsByGroup = computed(() => {
|
||||
const map = new Map<string, ForwardStatus[]>()
|
||||
for (const f of status.value?.forwards ?? []) {
|
||||
const key = f.group || ''
|
||||
let arr = map.get(key)
|
||||
if (!arr) {
|
||||
arr = []
|
||||
map.set(key, arr)
|
||||
}
|
||||
arr.push(f)
|
||||
}
|
||||
return [...map.entries()]
|
||||
.map(([key, items]) => ({ key, label: key || '未分组', items }))
|
||||
.sort((a, b) => {
|
||||
if (!a.key) return 1
|
||||
if (!b.key) return -1
|
||||
return a.label.localeCompare(b.label, 'zh')
|
||||
})
|
||||
})
|
||||
|
||||
// connState refers to the frps connection from the local frpc worker.
|
||||
// We never control the remote frps itself.
|
||||
@ -332,7 +471,14 @@ const remoteVhostPort = (name: string): number =>
|
||||
|
||||
onMounted(() => {
|
||||
loadStatus()
|
||||
timer = window.setInterval(loadStatus, 5000)
|
||||
// 2.5s cadence keeps the status page live against cluster dispatch +
|
||||
// worker state changes; visibilitychange reloads instantly on tab refocus
|
||||
// so a stale tab never lingers on pre-action data.
|
||||
timer = window.setInterval(loadStatus, 2500)
|
||||
visHandler = () => {
|
||||
if (!document.hidden) loadStatus()
|
||||
}
|
||||
document.addEventListener('visibilitychange', visHandler)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@ -340,6 +486,10 @@ onBeforeUnmount(() => {
|
||||
window.clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
if (visHandler) {
|
||||
document.removeEventListener('visibilitychange', visHandler)
|
||||
visHandler = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -395,7 +545,6 @@ onBeforeUnmount(() => {
|
||||
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; }
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
@ -413,6 +562,8 @@ onBeforeUnmount(() => {
|
||||
.nc-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.nc-name { font-weight: 800; font-size: 15px; margin-right: auto; }
|
||||
.nc-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.nc-cluster-note { font-size: 11.5px; color: $color-text-muted; padding: 3px 9px; border-radius: $radius-pill;
|
||||
background: var(--w4f-card-2); cursor: help; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.nc-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||
.meta-pill { font-size: 11.5px; padding: 2px 9px; border-radius: $radius-pill; background: var(--w4f-card-2); color: $color-text-muted;
|
||||
font-family: var(--w4f-mono); max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
@ -436,6 +587,37 @@ onBeforeUnmount(() => {
|
||||
.np-err { margin-left: auto; font-size: 11px; color: $color-danger; max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.nc-proxies-empty { margin-top: 8px; font-size: 12px; color: $color-text-muted; }
|
||||
|
||||
/* forward card (转发为单元) */
|
||||
.fwd-card {
|
||||
padding: 12px 14px;
|
||||
border-left: 4px solid var(--w4f-muted);
|
||||
transition: border-color 0.18s var(--w4f-ease-spring);
|
||||
&.active { border-left-color: var(--w4f-ok); }
|
||||
&.stopped { border-left-color: var(--w4f-danger); opacity: 0.78; }
|
||||
}
|
||||
.fwd-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.fwd-kind {
|
||||
font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: $radius-pill;
|
||||
color: var(--w4f-muted); background: var(--w4f-card-2);
|
||||
&.local { color: var(--w4f-ok-h, #fff); background: var(--w4f-ok-50, var(--w4f-primary-50)); }
|
||||
&.remote { color: var(--w4f-secondary-h); background: var(--w4f-secondary-50); }
|
||||
}
|
||||
.fwd-dot {
|
||||
width: 9px; height: 9px; border-radius: 50%; flex: 0 0 auto;
|
||||
background: var(--w4f-muted); box-shadow: 0 0 0 3px var(--w4f-card-2);
|
||||
&.on { background: var(--w4f-ok); box-shadow: 0 0 0 3px var(--w4f-ok-50, var(--w4f-primary-50)); }
|
||||
}
|
||||
.fwd-route { font-size: 13.5px; display: flex; align-items: center; gap: 5px; margin-right: auto; }
|
||||
.fwd-ip { font-size: 11.5px; color: $color-text-muted; font-family: var(--w4f-mono); }
|
||||
.fwd-arrow { color: var(--w4f-primary); font-weight: 800; }
|
||||
.fwd-port { font-family: var(--w4f-mono); color: $color-text-secondary; }
|
||||
.fwd-owner { font-size: 11.5px; color: $color-text-muted; padding: 2px 8px; border-radius: $radius-pill;
|
||||
background: var(--w4f-card-2); max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.fwd-actions { display: flex; gap: 6px; }
|
||||
.grp-count { font-size: 12px; color: $color-text-muted; font-weight: 600; }
|
||||
.grp-actions { margin-left: auto; display: flex; gap: 6px; }
|
||||
.group-chip { cursor: pointer; transition: background $transition-fast; &:hover { background: color-mix(in srgb, var(--w4f-info) 30%, transparent); } }
|
||||
|
||||
/* local service card */
|
||||
.local-card { padding: 14px 16px; }
|
||||
.lc-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
Reference in New Issue
Block a user