mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-20 08:57:55 +00:00
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:
@ -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 }),
|
||||
}),
|
||||
};
|
||||
|
||||
@ -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[];
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user