feat: M3 cluster page (LB groups + health check status) e2e-verified failover; M6 cache API + version path hardening + frontend cluster methods

This commit is contained in:
2026-08-17 22:44:04 +08:00
parent a842198447
commit 5bd70b90c0
11 changed files with 470 additions and 128 deletions

View File

@ -1,84 +1,94 @@
// HTTP client and API functions for webui4frpc.
import type {
BinaryStatus,
CacheResp,
CanvasData,
ClusterNodesResp,
InstallResult,
Remote,
Settings,
StatusResp,
} from './types'
} from "./types";
class HTTPError extends Error {
status: number
status: number;
constructor(status: number, message: string) {
super(message)
this.status = status
super(message);
this.status = status;
}
}
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(url, {
credentials: 'same-origin',
credentials: "same-origin",
...options,
})
});
if (!response.ok) {
throw new HTTPError(response.status, `HTTP ${response.status}`)
throw new HTTPError(response.status, `HTTP ${response.status}`);
}
const ct = response.headers.get('content-type') || ''
if (ct.includes('application/json')) {
return response.json() as Promise<T>
const ct = response.headers.get("content-type") || "";
if (ct.includes("application/json")) {
return response.json() as Promise<T>;
}
return response.text() as unknown as Promise<T>
return response.text() as unknown as Promise<T>;
}
const json = (body: unknown): RequestInit => ({
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
});
export const api = {
status: () => request<StatusResp>('/api/manager/status'),
status: () => request<StatusResp>("/api/manager/status"),
canvas: () => request<CanvasData>('/api/manager/canvas'),
canvas: () => request<CanvasData>("/api/manager/canvas"),
saveCanvas: (data: CanvasData) =>
request<CanvasData>('/api/manager/canvas', json(data)),
request<CanvasData>("/api/manager/canvas", json(data)),
settings: () => request<Settings>('/api/manager/settings'),
settings: () => request<Settings>("/api/manager/settings"),
saveSettings: (s: Settings) =>
request<Settings>('/api/manager/settings', json(s)),
request<Settings>("/api/manager/settings", json(s)),
binaryStatus: () => request<BinaryStatus>('/api/manager/binary/status'),
binaryStatus: () => request<BinaryStatus>("/api/manager/binary/status"),
installBinary: (version?: string) =>
request<InstallResult>('/api/manager/binary/install', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
request<InstallResult>("/api/manager/binary/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: version ? JSON.stringify({ version }) : undefined,
}),
saveRemote: (remote: Remote) =>
request<Remote>('/api/manager/remotes', json(remote)),
request<Remote>("/api/manager/remotes", json(remote)),
deleteRemote: (name: string) =>
request<void>(`/api/manager/remotes/${encodeURIComponent(name)}`, {
method: 'DELETE',
method: "DELETE",
}),
profileStart: (name: string) =>
request<void>(`/api/manager/profiles/${encodeURIComponent(name)}/start`, {
method: 'POST',
method: "POST",
}),
profileStop: (name: string) =>
request<void>(`/api/manager/profiles/${encodeURIComponent(name)}/stop`, {
method: 'POST',
method: "POST",
}),
profileRestart: (name: string) =>
request<void>(
`/api/manager/profiles/${encodeURIComponent(name)}/restart`,
{ method: 'POST' },
),
request<void>(`/api/manager/profiles/${encodeURIComponent(name)}/restart`, {
method: "POST",
}),
profileConfig: (name: string) =>
request<string>(`/api/manager/profiles/${encodeURIComponent(name)}/config`),
profileLogs: (name: string) =>
request<string>(`/api/manager/profiles/${encodeURIComponent(name)}/logs`),
}
// M6: cluster nodes + binary cache management.
clusterNodes: () => request<ClusterNodesResp>("/api/manager/cluster/nodes"),
clusterCache: () => request<CacheResp>("/api/manager/cluster/cache"),
pruneCache: (keep: number) =>
request<CacheResp>("/api/manager/cluster/cache", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ keep }),
}),
};

View File

@ -150,3 +150,26 @@ export interface InstallResult {
path: string;
version: string;
}
// M6: cluster node registry + binary cache.
export interface ClusterNode {
addr: string;
cache?: string[];
version?: string;
}
export interface CacheEntry {
version: string;
path: string;
size: number;
modTime: number;
}
export interface ClusterNodesResp {
nodes: ClusterNode[];
}
export interface CacheResp {
cache: CacheEntry[];
removed?: string[];
}

View File

@ -2,18 +2,147 @@
<div class="cluster-page">
<div class="cluster-top">
<h2 class="page-title">集群</h2>
<span class="page-sub">
负载均衡组group与健康检查health check将在这里展示 M3 功能占位
</span>
<span class="page-sub">负载均衡组 + 健康检查状态M3</span>
<button class="refresh-btn" @click="load">刷新</button>
</div>
<div class="cluster-body">
<el-empty description="集群功能开发中M3负载均衡与健康检查" />
<!-- 远程节点 × 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>
</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>
</div>
</section>
<el-empty v-if="!profileList.length && !localStatusList.length" description="暂无集群配置(先在状态/连接配置页添加 remote 与 local" />
</div>
</div>
</template>
<script setup lang="ts">
// M3 集群页骨架group / groupKey 多后端负载均衡 + 健康检查tcp/http将在此呈现。
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { api } from '../api'
import type { StatusResp } from '../types'
const status = ref<StatusResp | null>(null)
let timer: number | null = null
const profileList = computed(() => status.value?.profiles ?? [])
const localStatusList = computed(() => status.value?.localStatus ?? [])
const load = async () => {
try {
status.value = await api.status()
} catch {
// keep last on transient errors
}
}
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 ''
}
}
onMounted(() => {
load()
timer = window.setInterval(load, 8000)
})
onBeforeUnmount(() => {
if (timer !== null) { clearInterval(timer); timer = null }
})
</script>
<style scoped lang="scss">
@ -30,16 +159,72 @@
gap: 12px;
margin-bottom: 16px;
}
.page-title {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.page-sub {
color: $color-text-muted;
.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; }
}
.cluster-body {
flex: 1;
.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;
}
.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>