mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-20 08:57:55 +00:00
feat: Phase C 完成 — ModelRouter 风格 UI 重设计 + 模拟 frps 测试 + lastSync 修复 + 集群作坊搭建
- 主题: sakura×frost 玻璃拟态 (theme.css) + SCSS 变量重映射 - 侧栏: 玻璃侧栏 246px + 渐变品牌区 + 面包屑导航 - 状态页: 玻璃 KPI 卡 + 远程节点/本地服务卡片网格 - 集群页: 英雄玻璃卡 + 横向环拓扑链 + 待办命令/活跃拓扑/日志区 - 令牌环: 新增 lastSync 上次同步时间替代周期计数 - 模拟 frps: frps2/frps3 容器 + test-forward.sh 全链路验证脚本 - 修复: BinaryPath 空导致 worker 不启动, 撤销仅撤第一个 link, 任务复活风暴 (published 追踪)
This commit is contained in:
270
web/src/App.vue
270
web/src/App.vue
@ -1,100 +1,236 @@
|
||||
<!-- 简单 SPA 外壳:顶部切换 状态 / 连接配置 / 设置 -->
|
||||
<!--
|
||||
SPA shell — sakura × frost glassmorphism (ModelRouter-style):
|
||||
a fixed animated gradient-mesh background, a 246px glass sidebar, and a
|
||||
content area with a breadcrumb header. Navigation is simple view-state
|
||||
switching (no router); views own their own padding/scrolling below the
|
||||
breadcrumb.
|
||||
-->
|
||||
<template>
|
||||
<div id="bgfx" aria-hidden="true">
|
||||
<i class="blob b1"></i><i class="blob b2"></i><i class="blob b3"></i>
|
||||
</div>
|
||||
|
||||
<div class="app-shell">
|
||||
<header class="topbar">
|
||||
<span class="brand">webui4frpc</span>
|
||||
<nav class="nav">
|
||||
<button class="nav-btn" :class="{ active: view === 'status' }" @click="view = 'status'">
|
||||
状态
|
||||
</button>
|
||||
<button class="nav-btn" :class="{ active: view === 'canvas' }" @click="view = 'canvas'">
|
||||
连接配置
|
||||
</button>
|
||||
<button class="nav-btn" :class="{ active: view === 'settings' }" @click="view = 'settings'">
|
||||
设置
|
||||
</button>
|
||||
<button class="nav-btn" :class="{ active: view === 'cluster' }" @click="view = 'cluster'">
|
||||
集群
|
||||
<aside class="sidebar">
|
||||
<div class="sb-brand">
|
||||
<span class="sb-logo" aria-hidden="true">W</span>
|
||||
<span class="sb-brand-text">
|
||||
<b>webui4frpc</b>
|
||||
<small>令牌环 · 转发编排</small>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<nav class="sb-nav">
|
||||
<button
|
||||
v-for="item in nav"
|
||||
:key="item.key"
|
||||
class="sb-i"
|
||||
:class="{ active: view === item.key }"
|
||||
@click="view = item.key"
|
||||
>
|
||||
<span class="ic" v-html="item.icon" />
|
||||
<span class="label">{{ item.label }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="content">
|
||||
<CanvasView v-if="view === 'canvas'" />
|
||||
<SettingsView v-else-if="view === 'settings'" />
|
||||
<ClusterView v-else-if="view === 'cluster'" />
|
||||
<StatusView v-else />
|
||||
</main>
|
||||
|
||||
<div class="sb-foot">
|
||||
<span class="sb-status"><span class="dot" /> token-ring · M6</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="main">
|
||||
<div class="breadcrumb">
|
||||
<span class="crumb">{{ currentLabel }}</span>
|
||||
</div>
|
||||
<main class="content">
|
||||
<CanvasView v-if="view === 'canvas'" />
|
||||
<SettingsView v-else-if="view === 'settings'" />
|
||||
<ClusterView v-else-if="view === 'cluster'" />
|
||||
<StatusView v-else />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } 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'
|
||||
|
||||
const view = ref<'canvas' | 'settings' | 'status' | 'cluster'>('status')
|
||||
type ViewKey = 'canvas' | 'settings' | 'status' | 'cluster'
|
||||
const view = ref<ViewKey>('status')
|
||||
|
||||
const nav: { key: ViewKey; label: string; icon: string }[] = [
|
||||
{ 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>' },
|
||||
]
|
||||
|
||||
const currentLabel = computed(() => nav.find((n) => n.key === view.value)?.label ?? '')
|
||||
</script>
|
||||
|
||||
<style>
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
<style scoped>
|
||||
.app-shell {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
background: #f7f8fa;
|
||||
font-family: var(--w4f-font);
|
||||
color: var(--w4f-fg);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
flex-shrink: 0;
|
||||
/* ---------- sidebar ---------- */
|
||||
.sidebar {
|
||||
flex: 0 0 246px;
|
||||
width: 246px;
|
||||
padding: 18px 14px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
overflow-y: auto;
|
||||
background: var(--w4f-card);
|
||||
border-right: 1px solid var(--w4f-line);
|
||||
backdrop-filter: blur(calc(var(--w4f-glass) * 0.7)) saturate(1.3);
|
||||
-webkit-backdrop-filter: blur(calc(var(--w4f-glass) * 0.7)) saturate(1.3);
|
||||
box-shadow: var(--w4f-sh-md);
|
||||
z-index: 30;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sidebar { backdrop-filter: none; -webkit-backdrop-filter: none; }
|
||||
}
|
||||
|
||||
.sb-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 0 20px;
|
||||
height: 52px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
gap: 11px;
|
||||
padding: 6px 8px 18px;
|
||||
}
|
||||
.sb-logo {
|
||||
width: 40px;
|
||||
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);
|
||||
}
|
||||
.sb-brand-text b {
|
||||
font-size: 16.5px;
|
||||
letter-spacing: 0.2px;
|
||||
display: block;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.sb-brand-text small {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--w4f-muted);
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
.sb-nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 6px 16px;
|
||||
border-radius: 8px;
|
||||
.sb-i {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 10px 13px;
|
||||
border-radius: 13px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
|
||||
&:hover {
|
||||
background: #f2f3f5;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #303133;
|
||||
color: #fff;
|
||||
}
|
||||
color: var(--w4f-muted);
|
||||
font-weight: 600;
|
||||
font-size: 13.5px;
|
||||
font-family: inherit;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
transition: background 0.18s var(--w4f-ease-spring), color 0.18s var(--w4f-ease-spring),
|
||||
border-color 0.18s var(--w4f-ease-spring), box-shadow 0.18s var(--w4f-ease-spring);
|
||||
}
|
||||
.sb-i .ic {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 auto;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.sb-i:hover {
|
||||
background: var(--w4f-card-2);
|
||||
color: var(--w4f-fg);
|
||||
}
|
||||
.sb-i:hover .ic { opacity: 1; }
|
||||
.sb-i.active {
|
||||
background: linear-gradient(120deg, var(--w4f-primary-50), var(--w4f-secondary-50));
|
||||
color: var(--w4f-primary-h);
|
||||
border-color: var(--w4f-line);
|
||||
box-shadow: var(--w4f-sh-sm);
|
||||
}
|
||||
.sb-i.active .ic { opacity: 1; }
|
||||
|
||||
.sb-foot {
|
||||
margin-top: auto;
|
||||
padding-top: 16px;
|
||||
}
|
||||
.sb-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 13px;
|
||||
border-radius: var(--w4f-radius-pill);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--w4f-primary-h);
|
||||
background: var(--w4f-primary-50);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
@keyframes blink { 50% { opacity: 0.25; } }
|
||||
|
||||
/* ---------- main ---------- */
|
||||
.main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 24px 6px;
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
}
|
||||
.breadcrumb .crumb {
|
||||
opacity: 1;
|
||||
color: var(--w4f-fg);
|
||||
}
|
||||
.content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 8px 24px 40px;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.sidebar { flex: 0 0 210px; width: 210px; }
|
||||
.breadcrumb { padding: 12px 16px 6px; }
|
||||
.content { padding: 8px 14px 32px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -95,4 +95,23 @@ export const api = {
|
||||
|
||||
// M6 token ring snapshot.
|
||||
ring: () => request<RingSnapshot>("/api/manager/cluster/ring"),
|
||||
|
||||
// M6 cluster lifecycle (创建/加入/退出/移除).
|
||||
// clusterCreate: reseed THIS node as a fresh standalone leader (创建集群).
|
||||
clusterCreate: () =>
|
||||
request<RingSnapshot>("/api/manager/cluster/create", { method: "POST" }),
|
||||
// clusterJoinRing: THIS node joins the cluster at peer addr (加入集群).
|
||||
clusterJoinRing: (addr: string) =>
|
||||
request<RingSnapshot>("/api/manager/cluster/join-ring", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ addr }),
|
||||
}),
|
||||
// clusterRemoveNode: publish a node-removal command (移除节点 / 退出集群[self]).
|
||||
clusterRemoveNode: (id: string) =>
|
||||
request<{ task: unknown }>("/api/manager/cluster/node-remove", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id }),
|
||||
}),
|
||||
};
|
||||
|
||||
@ -48,15 +48,15 @@
|
||||
</label>
|
||||
<label>限速 <input v-model="bandwidthLimitField" placeholder="如 1MB" /></label>
|
||||
<label>连接池 <input v-model.number="poolCountField" type="number" min="0" /></label>
|
||||
<label>元数据(JSON) <input v-model="metadatasField" placeholder='{"env":"prod"}' /></label>
|
||||
<label>注解(JSON) <input v-model="annotationsField" placeholder='{"owner":"ops"}' /></label>
|
||||
<label class="adv-wide">元数据(JSON) <input v-model="metadatasField" placeholder='{"env":"prod"}' /></label>
|
||||
<label class="adv-wide">注解(JSON) <input v-model="annotationsField" placeholder='{"owner":"ops"}' /></label>
|
||||
|
||||
<div class="adv-sep">HTTP/HTTPS 路由</div>
|
||||
<label>域名(逗号分隔) <input v-model="customDomainsField" placeholder="app.example.com" /></label>
|
||||
<label class="adv-wide">域名(逗号分隔) <input v-model="customDomainsField" placeholder="app.example.com" /></label>
|
||||
<label>子域 <input v-model="subdomainField" placeholder="app (需 frps subdomain_host)" /></label>
|
||||
<label>路径路由(JSON) <input v-model="locationsField" placeholder='["/api","/admin"]' /></label>
|
||||
<label class="adv-wide">路径路由(JSON) <input v-model="locationsField" placeholder='["/api","/admin"]' /></label>
|
||||
<label>Host 改写 <input v-model="hostHeaderRewriteField" placeholder="backend.internal" /></label>
|
||||
<label>请求头(JSON) <input v-model="httpHeadersField" placeholder='{"X-Custom":"val"}' /></label>
|
||||
<label class="adv-wide">请求头(JSON) <input v-model="httpHeadersField" placeholder='{"X-Custom":"val"}' /></label>
|
||||
<label>BasicAuth 用户 <input v-model="basicAuthUserField" placeholder="user" /></label>
|
||||
<label>BasicAuth 密码 <input v-model="basicAuthPasswordField" type="password" placeholder="pass" /></label>
|
||||
|
||||
@ -195,7 +195,7 @@ const httpHeadersField = computed({
|
||||
|
||||
<style scoped lang="scss">
|
||||
.local-node {
|
||||
min-width: 190px;
|
||||
min-width: 240px;
|
||||
border-radius: 14px 14px 14px 4px;
|
||||
background: #e8f5e9;
|
||||
border: 2px solid #4caf50;
|
||||
@ -260,12 +260,29 @@ const httpHeadersField = computed({
|
||||
}
|
||||
}
|
||||
|
||||
// Shared input look for both the basic fields and the advanced grid.
|
||||
%field-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 1px solid #a5d6a7;
|
||||
border-radius: 6px;
|
||||
padding: 2px 6px;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #4caf50;
|
||||
}
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
|
||||
// basic field: label text + control inline on one row
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -275,72 +292,76 @@ const httpHeadersField = computed({
|
||||
|
||||
input,
|
||||
select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 1px solid #a5d6a7;
|
||||
border-radius: 6px;
|
||||
padding: 2px 6px;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #4caf50;
|
||||
}
|
||||
@extend %field-input;
|
||||
}
|
||||
|
||||
input[type='number'] {
|
||||
width: 64px;
|
||||
flex: 0 0 64px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.adv {
|
||||
// NOTE: .adv is a SIBLING of .fields (both children of .local-node), not a
|
||||
// descendant — it MUST NOT be nested under .fields in SCSS or the selector
|
||||
// `.fields .adv .adv-body` never matches the DOM and the whole advanced body
|
||||
// renders unstyled (labels flow inline, no line breaks → card stretches).
|
||||
.adv {
|
||||
margin-top: 6px;
|
||||
|
||||
.adv-toggle {
|
||||
border: 1px dashed #a5d6a7;
|
||||
background: transparent;
|
||||
color: #558b2f;
|
||||
font-size: 11px;
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.adv-body {
|
||||
// 2-column responsive grid: short fields share a row, long/JSON fields
|
||||
// and section headers span the full width (adv-wide / adv-sep). This
|
||||
// keeps the card compact instead of one-field-per-line = very tall.
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
.adv-toggle {
|
||||
border: 1px dashed #a5d6a7;
|
||||
background: transparent;
|
||||
color: #558b2f;
|
||||
font-size: 11px;
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.adv-body {
|
||||
padding: 8px;
|
||||
background: rgba(76, 175, 80, 0.06);
|
||||
border-radius: 8px;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 11px;
|
||||
color: #558b2f;
|
||||
|
||||
input,
|
||||
select {
|
||||
@extend %field-input;
|
||||
}
|
||||
}
|
||||
|
||||
// checkbox labels keep the control inline with their text (single row)
|
||||
.adv-check {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
padding: 6px;
|
||||
background: rgba(76, 175, 80, 0.06);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: #558b2f;
|
||||
|
||||
input,
|
||||
select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 1px solid #a5d6a7;
|
||||
border-radius: 6px;
|
||||
padding: 2px 6px;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #4caf50;
|
||||
}
|
||||
}
|
||||
}
|
||||
.adv-check {
|
||||
font-size: 11px;
|
||||
}
|
||||
// section divider + long/JSON fields span the full grid width
|
||||
.adv-sep,
|
||||
.adv-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.adv-sep {
|
||||
margin: 4px 0 2px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #2e7d32;
|
||||
border-top: 1px dashed rgba(76, 175, 80, 0.35);
|
||||
padding-top: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,9 @@ import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
// Imported AFTER element-plus css so our :root token overrides
|
||||
// (--el-color-primary, radii, font, ...) win the cascade at equal specificity.
|
||||
import './styles/theme.css'
|
||||
import App from './App.vue'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
249
web/src/styles/theme.css
Normal file
249
web/src/styles/theme.css
Normal file
@ -0,0 +1,249 @@
|
||||
/*
|
||||
* webui4frpc global theme — sakura × frost × glassmorphism.
|
||||
* Faithfully follows the ModelRouter WebUI design vocabulary: a soft animated
|
||||
* gradient-mesh background with drifting blurred blobs, translucent glass cards
|
||||
* with a spring entrance, KPI stat cards, gradient primary buttons, pill tags,
|
||||
* slim themed scrollbars. All theming is plain CSS custom properties (no
|
||||
* preprocessor needed at runtime); Element Plus variables are overridden so EP
|
||||
* components inherit the same sakura look.
|
||||
*
|
||||
* Semantic note: webui4frpc keeps green = "online / ok" — exactly as
|
||||
* ModelRouter does (--ok). Sakura (#FF7FAC) is the PRIMARY accent, frost
|
||||
* (#88C0D0) the secondary; online/healthy status stays green.
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* ---- palette (sakura / frost) ---- */
|
||||
--w4f-primary: #FF7FAC;
|
||||
--w4f-primary-h: #F33B7C;
|
||||
--w4f-primary-50: #FFF0F5;
|
||||
--w4f-primary-100: #FFE4E9;
|
||||
--w4f-primary-200: #FFCDD9;
|
||||
--w4f-primary-300: #FF9EB5;
|
||||
--w4f-secondary: #88C0D0;
|
||||
--w4f-secondary-h: #4C8DAE;
|
||||
--w4f-secondary-50: #F0F9FC;
|
||||
--w4f-secondary-200: #AEE1F2;
|
||||
--w4f-danger: #DB3694;
|
||||
--w4f-danger-50: #FEEAF6;
|
||||
--w4f-warning: #b7791f;
|
||||
--w4f-warning-50: #fdf3e3;
|
||||
--w4f-info: #3f6ef5;
|
||||
--w4f-ok: #17a964;
|
||||
|
||||
/* ---- surfaces / text ---- */
|
||||
--w4f-bg-1: #eef2ff;
|
||||
--w4f-bg-2: #ffffff;
|
||||
--w4f-bg-3: #ffe9f0;
|
||||
--w4f-fg: #3b3350;
|
||||
--w4f-muted: #7c7a95;
|
||||
--w4f-faint: #9c98b0;
|
||||
--w4f-card: rgba(255, 255, 255, 0.60);
|
||||
--w4f-card-solid: #ffffff;
|
||||
--w4f-card-2: rgba(255, 255, 255, 0.42);
|
||||
--w4f-line: rgba(255, 127, 172, 0.18);
|
||||
--w4f-line-strong: rgba(120, 90, 150, 0.22);
|
||||
|
||||
/* ---- background blobs ---- */
|
||||
--w4f-blob1: rgba(255, 127, 172, 0.45);
|
||||
--w4f-blob2: rgba(136, 192, 208, 0.42);
|
||||
--w4f-blob3: rgba(244, 114, 182, 0.30);
|
||||
|
||||
/* ---- shadows / radii / motion ---- */
|
||||
--w4f-sh-sm: 0 1px 2px rgba(70, 50, 110, 0.06), inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
||||
--w4f-sh-md: 0 6px 20px rgba(120, 90, 160, 0.13);
|
||||
--w4f-sh-lg: 0 18px 46px rgba(120, 90, 160, 0.22);
|
||||
--w4f-glass: 18px;
|
||||
--w4f-radius: 16px;
|
||||
--w4f-radius-sm: 12px;
|
||||
--w4f-radius-xs: 10px;
|
||||
--w4f-radius-pill: 99px;
|
||||
--w4f-ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
--w4f-ease: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--w4f-font: 'Quicksand', 'Nunito', 'Inter', 'Noto Sans SC', ui-sans-serif,
|
||||
system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
--w4f-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas,
|
||||
monospace;
|
||||
|
||||
/* ---- Element Plus variable overrides (concrete hex — EP needs real
|
||||
* colors for its color-mix / opacity helpers, not var() references). ---- */
|
||||
--el-color-primary: #FF7FAC;
|
||||
--el-color-primary-light-3: #FF9EB5;
|
||||
--el-color-primary-light-5: #ffb3c4;
|
||||
--el-color-primary-light-7: #ffcdd9;
|
||||
--el-color-primary-light-8: #ffe4e9;
|
||||
--el-color-primary-light-9: #fff0f5;
|
||||
--el-color-primary-dark-2: #f33b7c;
|
||||
--el-color-success: #17a964;
|
||||
--el-color-warning: #b7791f;
|
||||
--el-color-danger: #db3694;
|
||||
--el-color-info: #7c7a95;
|
||||
--el-border-radius-base: 12px;
|
||||
--el-border-radius-small: 8px;
|
||||
--el-font-family: var(--w4f-font);
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
html, body { overflow: hidden; }
|
||||
|
||||
body {
|
||||
font: 14px/1.55 var(--w4f-font);
|
||||
color: var(--w4f-fg);
|
||||
letter-spacing: 0.02em;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
::selection { background: #ffcdba; color: #fff; }
|
||||
a { color: var(--w4f-primary-h); }
|
||||
|
||||
/* ---- animated gradient-mesh background ----
|
||||
* Rendered once from App.vue as <div id="bgfx">. Fixed, behind everything. */
|
||||
#bgfx {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -10;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, var(--w4f-bg-1), var(--w4f-bg-2) 45%, var(--w4f-bg-3));
|
||||
}
|
||||
#bgfx .blob {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(64px);
|
||||
will-change: transform;
|
||||
opacity: 0.6;
|
||||
}
|
||||
#bgfx .b1 {
|
||||
top: -10%; left: -8%; width: 560px; height: 560px;
|
||||
background: var(--w4f-blob1);
|
||||
animation: drift1 30s ease-in-out infinite alternate;
|
||||
}
|
||||
#bgfx .b2 {
|
||||
top: 16%; right: -10%; width: 440px; height: 440px;
|
||||
background: var(--w4f-blob2);
|
||||
animation: drift2 36s ease-in-out infinite alternate;
|
||||
}
|
||||
#bgfx .b3 {
|
||||
bottom: -12%; left: 24%; width: 620px; height: 620px;
|
||||
background: var(--w4f-blob3);
|
||||
animation: drift3 42s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes drift1 { from { transform: translate(0,0) scale(1); } to { transform: translate(30px,-20px) scale(1.05); } }
|
||||
@keyframes drift2 { from { transform: translate(0,0) scale(1); } to { transform: translate(-25px,18px) scale(1.03); } }
|
||||
@keyframes drift3 { from { transform: translate(0,0) scale(1); } to { transform: translate(18px,25px) scale(1.04); } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#bgfx .blob { animation: none; opacity: 0.35; }
|
||||
}
|
||||
|
||||
/* ---- reusable glass card surface ----
|
||||
* Opt-in via class. backdrop-blur + translucent fill + soft shadow + a subtle
|
||||
* spring entrance so sections "arrive" instead of popping in. */
|
||||
.w4f-card {
|
||||
position: relative;
|
||||
background: var(--w4f-card);
|
||||
border: 1px solid var(--w4f-line);
|
||||
border-radius: var(--w4f-radius);
|
||||
box-shadow: var(--w4f-sh-md);
|
||||
backdrop-filter: blur(calc(var(--w4f-glass) * 0.7)) saturate(1.3);
|
||||
-webkit-backdrop-filter: blur(calc(var(--w4f-glass) * 0.7)) saturate(1.3);
|
||||
animation: cardIn 0.32s var(--w4f-ease-spring) both;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.w4f-card { backdrop-filter: none; -webkit-backdrop-filter: none; }
|
||||
}
|
||||
@keyframes cardIn {
|
||||
from { opacity: 0; transform: translateY(10px) scale(0.985); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
/* ---- KPI stat card ----
|
||||
* .k-hdr (label + dot) / .k-val (big number) / .k-sub (caption) / .k-chart. */
|
||||
.w4f-kpi {
|
||||
background: var(--w4f-card);
|
||||
border: 1px solid var(--w4f-line);
|
||||
border-radius: var(--w4f-radius);
|
||||
padding: 16px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: var(--w4f-sh-md);
|
||||
backdrop-filter: blur(var(--w4f-glass)) saturate(1.6);
|
||||
-webkit-backdrop-filter: blur(var(--w4f-glass)) saturate(1.6);
|
||||
transition: transform 0.25s var(--w4f-ease-spring), box-shadow 0.25s var(--w4f-ease-spring),
|
||||
border-color 0.25s var(--w4f-ease-spring);
|
||||
animation: cardIn 0.32s var(--w4f-ease-spring) both;
|
||||
}
|
||||
.w4f-kpi:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--w4f-sh-lg);
|
||||
border-color: var(--w4f-primary-200);
|
||||
}
|
||||
.w4f-kpi .k-hdr { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; }
|
||||
.w4f-kpi .k-lab { font-size: 12px; color: var(--w4f-muted); font-weight: 700; display: flex; align-items: center; gap: 6px; white-space: nowrap; }
|
||||
.w4f-kpi .k-val { font-size: 27px; font-weight: 800; margin-top: 4px; font-variant-numeric: tabular-nums; letter-spacing: 0.3px; }
|
||||
.w4f-kpi .k-sub { font-size: 11.5px; color: var(--w4f-muted); margin-top: 4px; }
|
||||
.w4f-kpi .k-ic {
|
||||
width: 26px; height: 26px; border-radius: 9px; flex: 0 0 auto;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: linear-gradient(135deg, var(--w4f-primary-50), var(--w4f-secondary-50));
|
||||
color: var(--w4f-primary-h);
|
||||
}
|
||||
.w4f-dot {
|
||||
width: 7px; height: 7px; border-radius: 50%; background: var(--w4f-ok);
|
||||
display: inline-block; animation: blink 1.6s infinite;
|
||||
}
|
||||
.w4f-dot.err { background: var(--w4f-danger); }
|
||||
.w4f-dot.pending { background: var(--w4f-secondary); }
|
||||
@keyframes blink { 50% { opacity: 0.25; } }
|
||||
|
||||
/* ---- bar indicator ---- */
|
||||
.w4f-bar { height: 5px; border-radius: 3px; background: var(--w4f-card-2); overflow: hidden; min-width: 60px; }
|
||||
.w4f-bar i { display: block; height: 100%; background: linear-gradient(90deg, var(--w4f-primary), var(--w4f-secondary)); border-radius: 3px; }
|
||||
.w4f-bar.ok i { background: var(--w4f-ok); }
|
||||
.w4f-bar.err i { background: var(--w4f-danger); }
|
||||
|
||||
/* ---- pill tags ---- */
|
||||
.w4f-tag {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
padding: 3px 10px; border-radius: var(--w4f-radius-pill);
|
||||
font-size: 12px; font-weight: 600; margin: 2px 3px 2px 0;
|
||||
}
|
||||
.w4f-tag-ok { background: rgba(23, 169, 100, 0.12); color: var(--w4f-ok); }
|
||||
.w4f-tag-danger { background: var(--w4f-danger-50); color: var(--w4f-danger); }
|
||||
.w4f-tag-info { background: var(--w4f-secondary-50); color: var(--w4f-secondary-h); }
|
||||
.w4f-tag-warn { background: rgba(183, 121, 31, 0.14); color: var(--w4f-warning); }
|
||||
.w4f-tag-primary { background: var(--w4f-primary-50); color: var(--w4f-primary-h); }
|
||||
|
||||
/* ---- gradient buttons (raw <button> utility classes) ---- */
|
||||
.w4f-btn {
|
||||
background: linear-gradient(135deg, var(--w4f-primary), var(--w4f-danger));
|
||||
color: #fff; border: 0; border-radius: 11px; padding: 8px 16px; cursor: pointer;
|
||||
font: inherit; font-weight: 600; transition: all 0.18s var(--w4f-ease-spring);
|
||||
}
|
||||
.w4f-btn:hover { background: linear-gradient(135deg, var(--w4f-primary-h), var(--w4f-danger)); transform: translateY(-1px); box-shadow: 0 8px 20px rgba(255, 127, 172, 0.35); }
|
||||
.w4f-btn:active { transform: translateY(0); }
|
||||
.w4f-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; box-shadow: none; }
|
||||
.w4f-btn.ghost { background: transparent; border: 1px solid var(--w4f-line-strong); color: var(--w4f-muted); }
|
||||
.w4f-btn.ghost:hover { border-color: var(--w4f-primary); color: var(--w4f-primary-h); box-shadow: var(--w4f-sh-sm); transform: none; background: transparent; }
|
||||
.w4f-btn.danger { background: transparent; border: 1px solid var(--w4f-danger); color: var(--w4f-danger); }
|
||||
.w4f-btn.danger:hover { background: var(--w4f-danger); color: #fff; box-shadow: 0 8px 20px rgba(219, 54, 148, 0.3); transform: translateY(-1px); }
|
||||
.w4f-btn.small { padding: 5px 12px; font-size: 12px; border-radius: var(--w4f-radius-pill); }
|
||||
|
||||
/* ---- slim themed scrollbars ---- */
|
||||
* { scrollbar-width: thin; scrollbar-color: var(--w4f-line-strong) transparent; }
|
||||
*::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
*::-webkit-scrollbar-track { background: transparent; }
|
||||
*::-webkit-scrollbar-thumb { background: rgba(255, 182, 193, 0.45); border-radius: 4px; transition: background 0.3s; }
|
||||
*::-webkit-scrollbar-thumb:hover { background: rgba(255, 127, 172, 0.7); }
|
||||
|
||||
/* ---- Element Plus dialog tweaks to read as glass ---- */
|
||||
.el-overlay { backdrop-filter: blur(3px); }
|
||||
.el-dialog { border-radius: var(--w4f-radius) !important; box-shadow: var(--w4f-sh-lg) !important; }
|
||||
.el-message { border-radius: var(--w4f-radius-sm) !important; box-shadow: var(--w4f-sh-lg) !important; }
|
||||
@ -1,25 +1,55 @@
|
||||
// Global SCSS variables for webui4frpc (light theme).
|
||||
$color-text-primary: #303133;
|
||||
$color-text-secondary: #606266;
|
||||
$color-text-muted: #909399;
|
||||
$color-text-light: #c0c4cc;
|
||||
// Global SCSS variables for webui4frpc.
|
||||
//
|
||||
// Palette mirrors the CSS custom-property tokens in theme.css (sakura × frost
|
||||
// glassmorphism, faithful to the ModelRouter WebUI). These SCSS vars are
|
||||
// auto-injected into every <style lang="scss"> block via vite.config.ts
|
||||
// additionalData, so remapping them here shifts the accent color of every
|
||||
// view at once — keeping the data-dense pages visually coherent with the
|
||||
// glass sidebar shell without per-view rewrites.
|
||||
//
|
||||
// Semantic note: green = online/ok (as in ModelRouter). Sakura is the accent.
|
||||
$color-text-primary: #3b3350;
|
||||
$color-text-secondary: #5a5470;
|
||||
$color-text-muted: #7c7a95;
|
||||
$color-text-light: #9c98b0;
|
||||
$color-bg-primary: #ffffff;
|
||||
$color-bg-secondary: #f9f9f9;
|
||||
$color-bg-tertiary: #fafafa;
|
||||
$color-bg-muted: #f4f4f5;
|
||||
$color-bg-hover: #efefef;
|
||||
$color-bg-active: #eaeaea;
|
||||
$color-border: #dcdfe6;
|
||||
$color-border-light: #e4e7ed;
|
||||
$color-border-lighter: #ebeef5;
|
||||
$color-border-extra-light: #f2f6fc;
|
||||
$color-primary: #409eff;
|
||||
$color-success: #67c23a;
|
||||
$color-warning: #e6a23c;
|
||||
$color-danger: #f56c6c;
|
||||
$color-info: #909399;
|
||||
$color-btn-primary: #303133;
|
||||
$color-btn-primary-hover: #4a4d5c;
|
||||
$color-bg-secondary: #f8f6fb;
|
||||
$color-bg-tertiary: #eef2ff;
|
||||
$color-bg-muted: #f4f1f7;
|
||||
$color-bg-hover: #f5eef3;
|
||||
$color-bg-active: #ffe4e9;
|
||||
$color-border: #e6d9e4;
|
||||
$color-border-light: #ecdfec;
|
||||
$color-border-lighter: #f2e9f4;
|
||||
$color-border-extra-light: #f7f0f7;
|
||||
|
||||
// Accents — sakura/frost identity (online/ok stays green).
|
||||
$color-primary: #ff7fac; // sakura-400
|
||||
$color-primary-h: #f33b7c; // sakura-500 (hover)
|
||||
$color-primary-50: #fff0f5; // sakura-50 (tints/chips)
|
||||
$color-primary-100: #ffe4e9;
|
||||
$color-secondary: #88c0d0; // frost
|
||||
$color-secondary-h: #4c8dae;
|
||||
$color-secondary-50: #f0f9fc;
|
||||
$color-success: #17a964; // green (online/ok)
|
||||
$color-warning: #b7791f; // amber
|
||||
$color-warning-50: #fdf3e3;
|
||||
$color-danger: #db3694; // sakura-magenta danger
|
||||
$color-danger-50: #feeaf6;
|
||||
$color-info: #3f6ef5; // periwinkle
|
||||
// Primary action button follows the sakura accent (gradient with danger).
|
||||
$color-btn-primary: #ff7fac;
|
||||
$color-btn-primary-hover: #f33b7c;
|
||||
|
||||
// Glass surfaces / shadows / radii (opt-in from views; see theme.css for the
|
||||
// matching runtime CSS custom properties --w4f-*).
|
||||
$color-glass-bg: rgba(255, 255, 255, 0.60);
|
||||
$color-glass-bg-2: rgba(255, 255, 255, 0.42);
|
||||
$color-glass-border: rgba(255, 127, 172, 0.18);
|
||||
$shadow-sm: 0 1px 2px rgba(70, 50, 110, 0.06), inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
||||
$shadow-md: 0 6px 20px rgba(120, 90, 160, 0.13);
|
||||
$shadow-lg: 0 18px 46px rgba(120, 90, 160, 0.22);
|
||||
$radius-glass: 16px;
|
||||
|
||||
$font-size-xs: 12px;
|
||||
$font-size-sm: 13px;
|
||||
@ -34,7 +64,10 @@ $spacing-sm: 8px;
|
||||
$spacing-md: 12px;
|
||||
$spacing-lg: 24px;
|
||||
$spacing-xl: 32px;
|
||||
$radius-sm: 6px;
|
||||
$radius-md: 10px;
|
||||
$radius-lg: 14px;
|
||||
$transition-fast: 0.2s ease;
|
||||
$radius-xs: 10px;
|
||||
$radius-sm: 12px;
|
||||
$radius-md: 16px;
|
||||
$radius-lg: 16px;
|
||||
$radius-pill: 99px;
|
||||
$transition-fast: 0.18s var(--w4f-ease-spring, cubic-bezier(0.34, 1.56, 0.64, 1));
|
||||
$ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
|
||||
@ -191,6 +191,7 @@ export interface RingNode {
|
||||
export interface RingTaskInfo {
|
||||
id: string;
|
||||
revoke?: boolean;
|
||||
removeNode?: string; // node-removal command (target rides the token to self-remove)
|
||||
local: { name: string };
|
||||
remote: { name: string };
|
||||
link: { remotePort?: number };
|
||||
@ -214,9 +215,9 @@ export interface RingLogEntry {
|
||||
|
||||
export interface RingSnapshot {
|
||||
selfId: string;
|
||||
leaderId: string;
|
||||
cycle: number;
|
||||
phase: number; // 1=collect, 2=sync
|
||||
leaderId: string; // 当前 leader(持首令牌 + 判丢失 + 被上家监控)
|
||||
cycle: number; // 周期号(leader 每收令牌回 +1)
|
||||
lastSync: number; // 上次同步时间 (unix sec)
|
||||
roundDelayMs?: number;
|
||||
nodes: RingNode[];
|
||||
pending: RingTaskInfo[];
|
||||
|
||||
@ -1,73 +1,124 @@
|
||||
<!--
|
||||
Cluster page — token-ring snapshot + cluster lifecycle actions.
|
||||
Sakua × frost glassmorphism (ModelRouter-style): hero glass card, KPI row,
|
||||
glass action toolbar, horizontal ring chain of glass node-cards, glass
|
||||
task/topology/log sections with cardIn entrance.
|
||||
|
||||
Four capabilities (gated by derived state to prevent split-brain):
|
||||
创建集群 — reseed THIS node as a fresh standalone leader (CreateCluster)
|
||||
加入集群 — THIS node joins an existing cluster at a peer addr (JoinRing)
|
||||
退出集群 — THIS node leaves (publishes a node-remove for self)
|
||||
移除节点 — remove SOME member (publishes a node-remove for the target)
|
||||
|
||||
加入/退出 are reflexive (this node's relation to a cluster); 移除 is
|
||||
transitive. Per plan §M6 the leader only holds the first token + judges
|
||||
loss — it does NOT route tasks.
|
||||
-->
|
||||
<template>
|
||||
<div class="cluster-page">
|
||||
<div class="cluster-top">
|
||||
<h2 class="page-title">集群令牌环</h2>
|
||||
<span class="page-sub">令牌环网 · 两轮一周期 · 画布差异命令进令牌 · 增量日志同步</span>
|
||||
<button class="refresh-btn" @click="load">刷新</button>
|
||||
<!-- Hero header -->
|
||||
<div class="hero w4f-card">
|
||||
<div class="hero-left">
|
||||
<h2 class="page-title">集群令牌环</h2>
|
||||
<span class="page-sub">令牌环网 · 单轮一周期 · 画布差异命令进令牌 · 增量日志同步</span>
|
||||
</div>
|
||||
<div class="hero-stats" v-if="ring">
|
||||
<span class="w4f-tag" :class="stateTagClass"><span class="w4f-dot" :class="stateDotClass" />{{ stateLabel }}</span>
|
||||
<span class="w4f-tag w4f-tag-info"><b>{{ aliveCount }}</b> 成员</span>
|
||||
<span class="w4f-tag w4f-tag-primary" :title="'cycle ' + ring.cycle">上次同步 <b>{{ lastSyncAgo }}</b></span>
|
||||
<span v-if="isLeader" class="w4f-tag w4f-tag-primary"><span class="crown">♔</span> 本机 leader</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="ring">
|
||||
<!-- 环状态 -->
|
||||
<section class="section">
|
||||
<h3 class="section-title">环状态
|
||||
<span class="hint">本机 = 编辑入口节点;leader 持首令牌并推进周期</span>
|
||||
</h3>
|
||||
<div class="ring-state">
|
||||
<span class="rs-item">本机: <b :class="{ leader: ring.selfId === ring.leaderId }">{{ shortId(ring.selfId) }}</b></span>
|
||||
<span class="rs-item">leader: <b>{{ shortId(ring.leaderId) }}</b></span>
|
||||
<span class="rs-item">周期: <b>{{ ring.cycle }}</b></span>
|
||||
<span class="rs-item">令牌相: <span class="phase-badge" :class="ring.phase === 2 ? 'sync' : 'collect'">{{ ring.phase === 2 ? '同步轮' : '收集轮' }}</span></span>
|
||||
<span class="rs-item">节点数: <b>{{ ring.nodes.length }}</b></span>
|
||||
<span class="rs-item">待办: <b>{{ ring.pending.length }}</b></span>
|
||||
<span class="rs-item">活跃转发: <b>{{ ring.topology.length }}</b></span>
|
||||
</div>
|
||||
</section>
|
||||
<!-- KPI row -->
|
||||
<div class="kpis" v-if="ring">
|
||||
<div class="w4f-kpi">
|
||||
<div class="k-hdr"><span class="k-lab"><span class="w4f-dot" :class="{ err: aliveCount === 0 }" />环节点</span><span class="k-ic">○</span></div>
|
||||
<div class="k-val">{{ ring.nodes.length }}</div>
|
||||
<div class="k-sub">活跃 {{ aliveCount }} · 离线 {{ ring.nodes.length - aliveCount }}</div>
|
||||
</div>
|
||||
<div class="w4f-kpi">
|
||||
<div class="k-hdr"><span class="k-lab"><span class="w4f-dot" />活跃转发</span><span class="k-ic">→</span></div>
|
||||
<div class="k-val">{{ ring.topology.length }}</div>
|
||||
<div class="k-sub">已 claim 的集群转发</div>
|
||||
</div>
|
||||
<div class="w4f-kpi">
|
||||
<div class="k-hdr"><span class="k-lab"><span class="w4f-dot" :class="{ pending: ring.pending.length > 0 }" />待办命令</span><span class="k-ic">⚙</span></div>
|
||||
<div class="k-val">{{ ring.pending.length }}</div>
|
||||
<div class="k-sub">随令牌环行待执行</div>
|
||||
</div>
|
||||
<div class="w4f-kpi">
|
||||
<div class="k-hdr"><span class="k-lab"><span class="w4f-dot" :class="{ err: lastSyncStale }" />上次同步</span><span class="k-ic">⟳</span></div>
|
||||
<div class="k-val">{{ lastSyncAgo }}</div>
|
||||
<div class="k-sub">令牌环心跳 · 停滞即异常</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 环拓扑 -->
|
||||
<section class="section">
|
||||
<h3 class="section-title">环拓扑
|
||||
<span class="hint">顺序=令牌传递链;本机高亮,leader 蓝色,离线标红;前驱监控 leader</span>
|
||||
</h3>
|
||||
<!-- Action toolbar -->
|
||||
<div class="toolbar w4f-card">
|
||||
<template v-if="!isMember">
|
||||
<button class="w4f-btn" :disabled="busy" @click="onCreate">⊕ 创建集群</button>
|
||||
<button class="w4f-btn ghost" :disabled="busy" @click="joinDialog.visible = true">↪ 加入集群…</button>
|
||||
<span class="act-hint" v-if="isStandalone && isLeader">本节点为初始 leader,已就绪;可直接创建,或输入对端地址加入既有集群</span>
|
||||
<span class="act-hint" v-else-if="!selfInRing">本节点已脱离集群,可创建新集群或加入既有集群</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button class="w4f-btn danger" :disabled="busy" @click="onLeave">⏏ 退出集群</button>
|
||||
<span class="act-hint">退出后本节点仅保留 localOnly 转发;可重新创建/加入</span>
|
||||
</template>
|
||||
<button class="w4f-btn ghost small" style="margin-left: auto" :disabled="busy" @click="load">⟳ 刷新</button>
|
||||
</div>
|
||||
|
||||
<div v-if="ring" class="cluster-body">
|
||||
<!-- Ring topology -->
|
||||
<section class="section w4f-card">
|
||||
<h3 class="section-title"><span class="k-ic">○</span>环拓扑<span class="hint">令牌传递链 · leader 青色 · 本机 sakura 高亮 · 离线标红 · 非本机节点可移除</span></h3>
|
||||
<div class="ring-line">
|
||||
<template v-for="(n, i) in ring.nodes" :key="n.id">
|
||||
<div class="ring-node" :class="{ leader: n.id === ring.leaderId, dead: !n.alive, self: n.id === ring.selfId }">
|
||||
<div class="rn-top">
|
||||
<span class="rn-id">{{ shortId(n.id) }}</span>
|
||||
<span v-if="n.id === ring.selfId" class="rn-self">本机</span>
|
||||
<span v-if="n.id === ring.leaderId" class="rn-leader">LEADER</span>
|
||||
<span v-if="!n.alive" class="rn-dead">离线</span>
|
||||
<span v-if="n.id === ring.selfId" class="w4f-tag w4f-tag-primary">本机</span>
|
||||
<span v-if="n.id === ring.leaderId" class="w4f-tag w4f-tag-info">LEADER</span>
|
||||
<span v-if="!n.alive" class="w4f-tag w4f-tag-danger">离线</span>
|
||||
</div>
|
||||
<div class="rn-load-row">
|
||||
<span class="rn-load-label">负载 {{ Math.round(n.load.memPct + n.load.netPct) }}</span>
|
||||
<div class="w4f-bar"><i :style="{ width: Math.min(100, n.load.memPct + n.load.netPct) + '%' }" /></div>
|
||||
</div>
|
||||
<div class="rn-load">负载 {{ Math.round(n.load.memPct + n.load.netPct) }}</div>
|
||||
<div class="rn-addr">{{ n.addr }}</div>
|
||||
<div v-if="n.id === ring.selfId" class="rn-role">编辑入口</div>
|
||||
<button v-if="n.id !== ring.selfId && n.alive" class="w4f-btn danger small rn-remove" :disabled="busy" @click="onRemoveNode(n.id)">移除节点</button>
|
||||
</div>
|
||||
<span v-if="i < ring.nodes.length - 1" class="ring-arrow">→</span>
|
||||
</template>
|
||||
<div v-if="!ring.nodes.length" class="empty">环节点为空(已脱离)</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 待办任务 / 命令 -->
|
||||
<section class="section" v-if="ring.pending.length">
|
||||
<h3 class="section-title">令牌命令(待执行)
|
||||
<span class="hint">画布差异产生:新增转发 / 撤销转发,随令牌环行由对应节点执行</span>
|
||||
</h3>
|
||||
<!-- Pending token commands -->
|
||||
<section class="section w4f-card" v-if="ring.pending.length">
|
||||
<h3 class="section-title"><span class="k-ic">⚙</span>令牌命令(待执行)<span class="hint">画布差异 / 节点移除产生,随令牌环行由对应节点执行</span></h3>
|
||||
<div class="task-list">
|
||||
<div v-for="t in ring.pending" :key="t.id" class="task-row" :class="t.revoke ? 'revoke' : 'pending'">
|
||||
<div v-for="t in ring.pending" :key="t.id" class="task-row" :class="taskClass(t)">
|
||||
<span class="tk-id">{{ t.id }}</span>
|
||||
<span class="tk-cmd">{{ t.revoke ? '撤销' : '新增' }}</span>
|
||||
<span class="tk-name">{{ t.local.name }}</span>
|
||||
<span class="tk-arrow">→</span>
|
||||
<span class="tk-name">{{ t.remote.name }}</span>
|
||||
<span class="tk-port">:{{ t.link?.remotePort }}</span>
|
||||
<span class="w4f-tag" :class="t.removeNode || t.revoke ? 'w4f-tag-danger' : 'w4f-tag-primary'">{{ taskCmdLabel(t) }}</span>
|
||||
<template v-if="t.removeNode">
|
||||
<span class="tk-arrow">→</span>
|
||||
<span class="tk-name">移除节点 {{ shortId(t.removeNode) }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="tk-name">{{ t.local.name }}</span>
|
||||
<span class="tk-arrow">→</span>
|
||||
<span class="tk-name">{{ t.remote.name }}</span>
|
||||
<span class="tk-port">:{{ t.link?.remotePort }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 活跃转发拓扑 -->
|
||||
<section class="section">
|
||||
<h3 class="section-title">活跃转发拓扑
|
||||
<span class="hint">每个转发由 owner 节点持有;localOnly 转发只在本节点 webui 可见</span>
|
||||
</h3>
|
||||
<!-- Active forward topology -->
|
||||
<section class="section w4f-card">
|
||||
<h3 class="section-title"><span class="k-ic">→</span>活跃转发拓扑<span class="hint">每个转发由 owner 节点持有 · localOnly 转发只在本节点可见</span></h3>
|
||||
<div class="topo-list">
|
||||
<div v-for="t in ring.topology" :key="t.taskId" class="topo-row">
|
||||
<span class="tp-id">{{ t.taskId }}</span>
|
||||
@ -76,17 +127,15 @@
|
||||
<span class="tp-name">{{ t.remote.name }}</span>
|
||||
<span class="tp-port">:{{ t.link?.remotePort }}</span>
|
||||
<span class="tp-owner" :class="{ self: t.ownerId === ring.selfId }">owner: {{ shortId(t.ownerId) }}</span>
|
||||
<span class="tp-state" :class="{ ok: t.active }">{{ t.active ? "运行中" : "已停" }}</span>
|
||||
<span class="w4f-tag" :class="t.active ? 'w4f-tag-ok' : 'w4f-tag-danger'">{{ t.active ? '运行中' : '已停' }}</span>
|
||||
</div>
|
||||
<div v-if="!ring.topology.length" class="empty">暂无活跃转发(画布编辑后将经令牌分发)</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 增量日志 -->
|
||||
<section class="section">
|
||||
<h3 class="section-title">集群日志(增量同步)
|
||||
<span class="hint">追加式,随令牌 delta 重放到全网一致</span>
|
||||
</h3>
|
||||
<!-- Cluster log -->
|
||||
<section class="section w4f-card">
|
||||
<h3 class="section-title"><span class="k-ic">≣</span>集群日志(增量同步)<span class="hint">追加式,随令牌 delta 重放到全网一致</span></h3>
|
||||
<div class="log-list">
|
||||
<div v-for="(e, i) in logView" :key="i" class="log-row" :class="logKindClass(e.kind)">
|
||||
<span class="lg-seq">#{{ e.seq }}</span>
|
||||
@ -97,24 +146,87 @@
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<el-empty v-else description="集群未启动(无环节点)" />
|
||||
<div v-else class="empty-page w4f-card">集群未启动(无环节点)</div>
|
||||
|
||||
<!-- Join dialog -->
|
||||
<el-dialog v-model="joinDialog.visible" title="加入集群" width="420px">
|
||||
<div class="form-row">
|
||||
<label>对端地址</label>
|
||||
<el-input v-model="joinDialog.addr" placeholder="host:port(如 node-a:7500)" @keyup.enter="onJoin" />
|
||||
</div>
|
||||
<div class="form-hint">本节点将向对端请求加入并采纳其环状态,令牌到达即并入环。</div>
|
||||
<template #footer>
|
||||
<button class="w4f-btn ghost small" @click="joinDialog.visible = false">取消</button>
|
||||
<button class="w4f-btn small" :disabled="busy || !joinDialog.addr.trim()" @click="onJoin">加入</button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { api } from '../api'
|
||||
import type { RingSnapshot } from '../types'
|
||||
import type { RingSnapshot, RingTaskInfo } from '../types'
|
||||
|
||||
const ring = ref<RingSnapshot | null>(null)
|
||||
const busy = ref(false)
|
||||
let timer: number | null = null
|
||||
|
||||
const joinDialog = reactive({ visible: false, addr: '' })
|
||||
|
||||
const load = async () => {
|
||||
try { ring.value = await api.ring() } catch { /* keep last */ }
|
||||
}
|
||||
|
||||
const shortId = (id: string) => (id.length > 22 ? '…' + id.slice(-20) : id)
|
||||
|
||||
// ---- state derivation (safety gates) ----
|
||||
const selfInRing = computed(() => !!ring.value?.nodes.some((n) => n.id === ring.value?.selfId))
|
||||
const aliveCount = computed(() => ring.value?.nodes.filter((n) => n.alive).length ?? 0)
|
||||
const isStandalone = computed(() => aliveCount.value <= 1 || !selfInRing.value)
|
||||
const isMember = computed(() => !!ring.value && ring.value.nodes.length > 1 && selfInRing.value)
|
||||
const isLeader = computed(() => !!ring.value && ring.value.selfId === ring.value.leaderId)
|
||||
|
||||
const stateLabel = computed(() => {
|
||||
if (!ring.value) return '未启动'
|
||||
if (!selfInRing.value) return '已脱离'
|
||||
if (isMember.value) return '运行中'
|
||||
return '待加入'
|
||||
})
|
||||
const stateTagClass = computed(() => {
|
||||
if (!ring.value) return 'w4f-tag-warn'
|
||||
if (!selfInRing.value) return 'w4f-tag-danger'
|
||||
if (isMember.value) return 'w4f-tag-ok'
|
||||
return 'w4f-tag-info'
|
||||
})
|
||||
const stateDotClass = computed(() => {
|
||||
if (!ring.value) return ''
|
||||
if (!selfInRing.value) return 'err'
|
||||
if (isMember.value) return ''
|
||||
return 'pending'
|
||||
})
|
||||
|
||||
// ---- last-sync freshness (ops cares "环还活着吗", not the cycle counter) ----
|
||||
const now = ref(Date.now())
|
||||
let tick: number | null = null
|
||||
const lastSyncAgo = computed(() => {
|
||||
const ls = ring.value?.lastSync ?? 0
|
||||
if (!ls) return '—'
|
||||
const s = Math.max(0, Math.floor(now.value / 1000) - ls)
|
||||
if (s < 2) return '刚刚'
|
||||
if (s < 60) return s + ' 秒前'
|
||||
if (s < 3600) return Math.floor(s / 60) + ' 分钟前'
|
||||
return Math.floor(s / 3600) + ' 小时前'
|
||||
})
|
||||
// 停滞告警:正常每 ~2-3 秒一轮,超过 15 秒未同步视为环异常
|
||||
const lastSyncStale = computed(() => {
|
||||
const ls = ring.value?.lastSync ?? 0
|
||||
if (!ls) return false
|
||||
return Math.floor(now.value / 1000) - ls > 15
|
||||
})
|
||||
|
||||
// ---- log/task helpers ----
|
||||
const kindLabel = (k: string) => {
|
||||
switch (k) {
|
||||
case 'forward.add': return '新增转发'
|
||||
@ -125,7 +237,6 @@ const kindLabel = (k: string) => {
|
||||
default: return k
|
||||
}
|
||||
}
|
||||
|
||||
const logKindClass = (k: string) => {
|
||||
switch (k) {
|
||||
case 'forward.add': return 'add'
|
||||
@ -136,54 +247,146 @@ const logKindClass = (k: string) => {
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
const logView = computed(() => [...(ring.value?.log ?? [])].reverse())
|
||||
|
||||
onMounted(() => { load(); timer = window.setInterval(load, 3000) })
|
||||
onBeforeUnmount(() => { if (timer !== null) { clearInterval(timer); timer = null } })
|
||||
const taskClass = (t: RingTaskInfo) => (t.removeNode ? 'remove' : t.revoke ? 'revoke' : 'pending')
|
||||
const taskCmdLabel = (t: RingTaskInfo) => (t.removeNode ? '移除节点' : t.revoke ? '撤销' : '新增')
|
||||
|
||||
// ---- lifecycle actions ----
|
||||
const onCreate = async () => {
|
||||
busy.value = true
|
||||
try {
|
||||
ring.value = await api.clusterCreate()
|
||||
ElMessage.success('已创建集群(本节点为初始 leader)')
|
||||
} catch (e: any) {
|
||||
ElMessage.error('创建失败: ' + (e.message || e))
|
||||
} finally {
|
||||
busy.value = false
|
||||
await load()
|
||||
}
|
||||
}
|
||||
const onJoin = async () => {
|
||||
const addr = joinDialog.addr.trim()
|
||||
if (!addr) return
|
||||
busy.value = true
|
||||
try {
|
||||
ring.value = await api.clusterJoinRing(addr)
|
||||
joinDialog.visible = false
|
||||
ElMessage.success(`已加入 ${addr}`)
|
||||
} catch (e: any) {
|
||||
ElMessage.error('加入失败: ' + (e.message || e))
|
||||
} finally {
|
||||
busy.value = false
|
||||
await load()
|
||||
}
|
||||
}
|
||||
const onLeave = async () => {
|
||||
if (!ring.value) return
|
||||
if (!window.confirm('退出集群?本节点将取消一切集群远程转发并脱离环。')) return
|
||||
busy.value = true
|
||||
try {
|
||||
await api.clusterRemoveNode(ring.value.selfId)
|
||||
ElMessage.success('退出指令已发布')
|
||||
} catch (e: any) {
|
||||
ElMessage.error('退出失败: ' + (e.message || e))
|
||||
} finally {
|
||||
busy.value = false
|
||||
await load()
|
||||
}
|
||||
}
|
||||
const onRemoveNode = async (id: string) => {
|
||||
if (!window.confirm(`移除节点 ${shortId(id)} 出集群?`)) return
|
||||
busy.value = true
|
||||
try {
|
||||
await api.clusterRemoveNode(id)
|
||||
ElMessage.success('移除指令已发布')
|
||||
} catch (e: any) {
|
||||
ElMessage.error('移除失败: ' + (e.message || e))
|
||||
} finally {
|
||||
busy.value = false
|
||||
await load()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
timer = window.setInterval(load, 3000)
|
||||
tick = window.setInterval(() => { now.value = Date.now() }, 1000)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
if (timer !== null) { clearInterval(timer); timer = null }
|
||||
if (tick !== null) { clearInterval(tick); tick = null }
|
||||
})
|
||||
</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; }
|
||||
.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; } }
|
||||
.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; } }
|
||||
.ring-state { display: flex; gap: 16px; flex-wrap: wrap; align-items: center; background: #fafafa; border: 1px solid $color-border-light; border-radius: 10px; padding: 10px 16px; }
|
||||
.rs-item { font-size: 13px; color: $color-text-secondary; b { color: #303133; font-weight: 600; font-size: 14px; } b.leader { color: #409eff; } }
|
||||
.phase-badge { font-size: 11px; padding: 1px 8px; border-radius: 8px; background: #ecf5ff; color: #409eff; &.sync { background: #f0f9eb; color: $color-success; } }
|
||||
.ring-line { display: flex; align-items: stretch; gap: 6px; flex-wrap: wrap; }
|
||||
.ring-node { border: 2px solid $color-border; border-radius: 10px; padding: 8px 12px; min-width: 130px; background: #fff; &.leader { border-color: #409eff; background: #ecf5ff; } &.self { outline: 2px solid #409eff33; box-shadow: 0 0 0 4px #409eff1a; } &.dead { border-color: $color-danger; background: #fef0f0; opacity: .7; } }
|
||||
.rn-top { display: flex; align-items: center; gap: 6px; }
|
||||
.rn-id { font-weight: 600; font-size: 13px; }
|
||||
.rn-self { font-size: 10px; background: #67c23a; color: #fff; border-radius: 8px; padding: 0 6px; }
|
||||
.rn-leader { font-size: 10px; background: #409eff; color: #fff; border-radius: 8px; padding: 0 6px; }
|
||||
.rn-dead { font-size: 10px; background: $color-danger; color: #fff; border-radius: 8px; padding: 0 6px; }
|
||||
.rn-load { font-size: 11px; color: $color-text-muted; margin-top: 4px; }
|
||||
.rn-addr { font-size: 11px; color: $color-text-muted; }
|
||||
.rn-role { font-size: 10px; color: $color-success; margin-top: 2px; }
|
||||
.ring-arrow { align-self: center; color: $color-text-muted; font-size: 16px; }
|
||||
.cluster-page { height: 100%; display: flex; flex-direction: column; overflow-y: auto; padding: 4px 0 40px; gap: 14px; }
|
||||
|
||||
/* hero */
|
||||
.hero { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; padding: 16px 20px; }
|
||||
.hero-left { display: flex; flex-direction: column; gap: 4px; }
|
||||
.page-title { margin: 0; font-size: 18px; font-weight: 800; }
|
||||
.page-sub { color: $color-text-muted; font-size: 12px; }
|
||||
.hero-stats { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
.hero-stats .w4f-tag b { color: $color-text-primary; font-weight: 800; }
|
||||
.crown { color: var(--w4f-primary-h); }
|
||||
|
||||
/* KPI */
|
||||
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(168px, 1fr)); gap: 14px; }
|
||||
|
||||
/* toolbar */
|
||||
.toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 12px 16px; }
|
||||
.act-hint { font-size: 12px; color: $color-text-muted; }
|
||||
|
||||
/* sections */
|
||||
.cluster-body { display: flex; flex-direction: column; gap: 14px; }
|
||||
.section { padding: 18px 20px; }
|
||||
.section-title { margin: 0 0 12px; font-size: 13.5px; font-weight: 700; color: $color-text-muted; letter-spacing: 0.02em; display: flex; align-items: center; 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; }
|
||||
}
|
||||
|
||||
/* ring topology */
|
||||
.ring-line { display: flex; align-items: stretch; gap: 8px; flex-wrap: wrap; }
|
||||
.ring-node { position: relative; border: 1px solid var(--w4f-line); border-radius: $radius-md; padding: 12px 14px; min-width: 168px;
|
||||
background: var(--w4f-card-2); box-shadow: var(--w4f-sh-sm);
|
||||
&.leader { border-color: var(--w4f-secondary); background: var(--w4f-secondary-50); }
|
||||
&.self { outline: 2px solid rgba(255, 127, 172, 0.45); box-shadow: 0 0 0 4px rgba(255, 127, 172, 0.14); border-color: var(--w4f-primary); }
|
||||
&.dead { border-color: var(--w4f-danger); background: var(--w4f-danger-50); opacity: 0.72; }
|
||||
}
|
||||
.rn-top { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.rn-id { font-weight: 800; font-size: 13px; font-family: var(--w4f-mono); }
|
||||
.rn-load-row { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
|
||||
.rn-load-label { font-size: 11px; color: $color-text-muted; white-space: nowrap; }
|
||||
.rn-load-row .w4f-bar { flex: 1; min-width: 50px; }
|
||||
.rn-addr { font-size: 11px; color: $color-text-muted; font-family: var(--w4f-mono); margin-top: 4px; }
|
||||
.rn-remove { margin-top: 8px; }
|
||||
.ring-arrow { align-self: center; color: var(--w4f-primary); font-size: 18px; font-weight: 700; }
|
||||
|
||||
/* tasks / topology / log lists */
|
||||
.task-list, .topo-list, .log-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.task-row, .topo-row { display: flex; align-items: center; gap: 8px; border: 1px solid $color-border-lighter; border-radius: 8px; padding: 6px 10px; font-size: 13px; }
|
||||
.task-row.pending { background: #ecf5ff; }
|
||||
.task-row.revoke { background: #fef0f0; }
|
||||
.tk-cmd { font-size: 11px; padding: 1px 8px; border-radius: 8px; background: #409eff; color: #fff; }
|
||||
.task-row.revoke .tk-cmd { background: $color-danger; }
|
||||
.tk-id, .tp-id { font-weight: 600; color: $color-text-secondary; font-size: 12px; }
|
||||
.tk-name, .tp-name { font-weight: 500; }
|
||||
.tk-arrow { color: $color-text-muted; }
|
||||
.tk-port, .tp-port { color: $color-text-muted; }
|
||||
.tp-owner { margin-left: auto; font-size: 11px; color: #409eff; &.self { color: $color-success; font-weight: 600; } }
|
||||
.tp-state { font-size: 11px; padding: 1px 8px; border-radius: 8px; background: #f0f9eb; color: $color-success; &:not(.ok) { background: #fef0f0; color: $color-danger; } }
|
||||
.log-row { display: flex; gap: 10px; font-size: 12px; font-family: monospace; border-bottom: 1px dashed $color-border-lighter; padding: 3px 2px; }
|
||||
.log-row.add .lg-kind { color: $color-success; }
|
||||
.log-row.remove .lg-kind { color: $color-danger; }
|
||||
.log-row.join .lg-kind { color: #909399; }
|
||||
.log-row.leader .lg-kind { color: #409eff; }
|
||||
.task-row, .topo-row { display: flex; align-items: center; gap: 8px; border: 1px solid var(--w4f-line); border-radius: $radius-xs; padding: 8px 11px; font-size: 13px; background: var(--w4f-card-2); box-shadow: var(--w4f-sh-sm); }
|
||||
.task-row.pending { background: var(--w4f-primary-50); border-color: rgba(255, 127, 172, 0.25); }
|
||||
.task-row.revoke, .task-row.remove { background: var(--w4f-danger-50); border-color: rgba(219, 54, 148, 0.25); }
|
||||
.tk-id, .tp-id { font-weight: 700; color: $color-text-secondary; font-size: 12px; font-family: var(--w4f-mono); }
|
||||
.tk-name, .tp-name { font-weight: 600; }
|
||||
.tk-arrow { color: var(--w4f-primary); font-weight: 700; }
|
||||
.tk-port, .tp-port { color: $color-text-muted; font-family: var(--w4f-mono); }
|
||||
.tp-owner { margin-left: auto; font-size: 11px; color: $color-secondary-h; &.self { color: var(--w4f-primary-h); font-weight: 700; } }
|
||||
.log-row { display: flex; gap: 10px; font-size: 12px; font-family: var(--w4f-mono); border-bottom: 1px dashed var(--w4f-line); padding: 3px 2px; }
|
||||
.log-row.add .lg-kind { color: $color-success; font-weight: 700; }
|
||||
.log-row.remove .lg-kind { color: $color-danger; font-weight: 700; }
|
||||
.log-row.join .lg-kind { color: $color-info; font-weight: 700; }
|
||||
.log-row.leave .lg-kind { color: $color-warning; font-weight: 700; }
|
||||
.log-row.leader .lg-kind { color: $color-secondary-h; font-weight: 700; }
|
||||
.lg-seq { color: $color-text-muted; }
|
||||
.lg-node { color: #777; &.self { color: #409eff; font-weight: 600; } }
|
||||
.lg-node { color: $color-text-light; &.self { color: var(--w4f-primary-h); font-weight: 700; } }
|
||||
.lg-kind { color: $color-text-secondary; }
|
||||
.empty { color: $color-text-muted; font-size: 13px; padding: 8px; }
|
||||
</style>
|
||||
.empty { color: $color-text-muted; font-size: 13px; padding: 10px; }
|
||||
.empty-page { color: $color-text-muted; font-size: 14px; padding: 40px; text-align: center; }
|
||||
|
||||
/* dialog form */
|
||||
.form-row { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; label { width: 72px; font-size: 13px; color: $color-text-secondary; flex-shrink: 0; } }
|
||||
.form-hint { font-size: 12px; color: $color-text-muted; }
|
||||
</style>
|
||||
|
||||
@ -1,94 +1,123 @@
|
||||
<template>
|
||||
<div class="status-page">
|
||||
<div class="status-top">
|
||||
<h2 class="page-title">状态</h2>
|
||||
<span class="refresh-hint">每 5 秒自动刷新</span>
|
||||
<button class="refresh-btn" @click="loadStatus">刷新</button>
|
||||
<button class="add-btn" @click="openAdd">+ 添加远程节点</button>
|
||||
<!-- top bar -->
|
||||
<div class="topbar w4f-card">
|
||||
<div class="tb-left">
|
||||
<h2 class="page-title">状态总览</h2>
|
||||
<span class="page-sub">每 5 秒自动刷新 · frpc worker 进程态 + frps 连接态</span>
|
||||
</div>
|
||||
<div class="tb-right">
|
||||
<button class="w4f-btn ghost small" @click="loadStatus">⟳ 刷新</button>
|
||||
<button class="w4f-btn small" @click="openAdd">+ 添加远程节点</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPI grid -->
|
||||
<div class="kpis" v-if="status">
|
||||
<div class="w4f-kpi">
|
||||
<div class="k-hdr"><span class="k-lab"><span class="w4f-dot" :class="{ err: !allConnected }" />远程节点</span><span class="k-ic">⊕</span></div>
|
||||
<div class="k-val">{{ status.profiles.length }}</div>
|
||||
<div class="k-sub">在线 {{ onlineCount }} · 离线 {{ status.profiles.length - onlineCount }}</div>
|
||||
</div>
|
||||
<div class="w4f-kpi">
|
||||
<div class="k-hdr"><span class="k-lab"><span class="w4f-dot" :class="{ err: runningCount === 0 && status.profiles.length > 0 }" />worker 进程</span><span class="k-ic">⚙</span></div>
|
||||
<div class="k-val">{{ runningCount }}</div>
|
||||
<div class="k-sub">运行中 frpc 进程数</div>
|
||||
</div>
|
||||
<div class="w4f-kpi">
|
||||
<div class="k-hdr"><span class="k-lab"><span class="w4f-dot" />本地服务</span><span class="k-ic">◳</span></div>
|
||||
<div class="k-val">{{ status.localStatus.length }}</div>
|
||||
<div class="k-sub">已配置本地服务</div>
|
||||
</div>
|
||||
<div class="w4f-kpi">
|
||||
<div class="k-hdr"><span class="k-lab"><span class="w4f-dot" :class="{ pending: activeForwards > 0 }" />活跃转发</span><span class="k-ic">→</span></div>
|
||||
<div class="k-val">{{ activeForwards }}</div>
|
||||
<div class="k-sub">已编排转发链路</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-body" v-if="status">
|
||||
<!-- 远程节点状态 -->
|
||||
<section class="status-section">
|
||||
<h3 class="status-title">远程节点状态</h3>
|
||||
<div class="status-cards">
|
||||
<section class="section">
|
||||
<h3 class="section-title"><span class="k-ic">⊕</span>远程节点状态<span class="hint">管理本地 frpc worker 进程(不管理远程 frps)</span></h3>
|
||||
<div class="card-grid">
|
||||
<div
|
||||
v-for="p in status.profiles"
|
||||
:key="p.name"
|
||||
class="node-status-card"
|
||||
class="node-card w4f-card"
|
||||
:class="{ healthy: workerHealthy(p.process.state) }"
|
||||
>
|
||||
<div class="ns-head">
|
||||
<span class="ns-name">{{ p.name }}</span>
|
||||
<span class="ns-badge" :class="connBadgeClass(p.connState)">
|
||||
● {{ connStateLabel(p.connState) }}
|
||||
<div class="nc-head">
|
||||
<span class="nc-name">{{ p.name }}</span>
|
||||
<span class="w4f-tag" :class="connTagClass(p.connState)">
|
||||
<span class="w4f-dot" :class="connDotClass(p.connState)" />{{ connStateLabel(p.connState) }}
|
||||
</span>
|
||||
<span class="ns-actions" title="控制本地 frpc worker 进程(不管理远程 frps)">
|
||||
<button v-if="p.process.state === 'running'" class="mini-btn" @click="stopRemote(p.name)">停本地worker</button>
|
||||
<button v-else class="mini-btn" @click="startRemote(p.name)">启本地worker</button>
|
||||
<button class="mini-btn" @click="openEdit(p)">编辑</button>
|
||||
<button class="mini-btn danger" @click="removeRemote(p.name)">删除</button>
|
||||
<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>
|
||||
</div>
|
||||
<div class="ns-meta">
|
||||
<span v-if="p.process.pid">pid {{ p.process.pid }}</span>
|
||||
<span class="ns-err" v-else-if="p.process.err">{{ p.process.err }}</span>
|
||||
<span v-if="remoteVhostPort(p.name)" class="ns-vhost">
|
||||
HTTP 访问端口 :{{ remoteVhostPort(p.name) }}
|
||||
</span>
|
||||
<div class="nc-meta">
|
||||
<span v-if="p.process.pid" class="meta-pill">pid {{ p.process.pid }}</span>
|
||||
<span v-if="p.process.restartCount" class="meta-pill warn">重启 {{ p.process.restartCount }}</span>
|
||||
<span v-if="p.process.err" class="meta-pill err" :title="p.process.err">{{ p.process.err }}</span>
|
||||
<span v-if="remoteVhostPort(p.name)" class="meta-pill info">HTTP :{{ remoteVhostPort(p.name) }}</span>
|
||||
</div>
|
||||
<div class="ns-forwards" v-if="p.forwards.length">
|
||||
<span v-for="f in p.forwards" :key="f.service" class="ns-fwd">
|
||||
<div class="nc-forwards" v-if="p.forwards.length">
|
||||
<span v-for="f in p.forwards" :key="f.service" class="w4f-tag w4f-tag-primary">
|
||||
{{ f.service }} → :{{ f.remotePort }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="p.groupCount" class="ns-groups">
|
||||
<span class="ns-group-badge">LB ×{{ p.groupCount }}</span>
|
||||
<div v-if="p.groupCount" class="nc-groups">
|
||||
<span class="w4f-tag w4f-tag-info">LB ×{{ p.groupCount }}</span>
|
||||
</div>
|
||||
<div v-if="p.proxyStates?.length" class="ns-proxies">
|
||||
<div v-if="p.proxyStates?.length" class="nc-proxies">
|
||||
<div
|
||||
v-for="ps in p.proxyStates"
|
||||
:key="ps.name"
|
||||
class="ns-proxy"
|
||||
class="nc-proxy"
|
||||
:class="proxyStateClass(ps.status)"
|
||||
>
|
||||
<span class="np-name">{{ ps.name }}</span>
|
||||
<span class="np-status">{{ proxyStateLabel(ps.status) }}</span>
|
||||
<div class="w4f-bar" :class="proxyBarClass(ps.status)"><i :style="{ width: proxyBarWidth(ps.status) }" /></div>
|
||||
<span v-if="ps.remote_addr" class="np-addr">{{ ps.remote_addr }}</span>
|
||||
<span v-if="ps.err" class="np-err" :title="ps.err">{{ ps.err }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="p.adminEnabled" class="ns-proxies-empty">
|
||||
<div v-else-if="p.adminEnabled" class="nc-proxies-empty">
|
||||
管理 API 未返回状态(frpc 未连上远程 frps 或无代理)
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!status.profiles.length" class="ns-empty">尚未配置远程节点</div>
|
||||
<div v-if="!status.profiles.length" class="empty">尚未配置远程节点 — 点击右上「+ 添加远程节点」</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 本地服务转发状态 -->
|
||||
<section class="status-section">
|
||||
<h3 class="status-title">本地服务转发</h3>
|
||||
<div class="local-status-list">
|
||||
<div v-for="ls in status.localStatus" :key="ls.local.name" class="local-status-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>
|
||||
<section class="section">
|
||||
<h3 class="section-title"><span class="k-ic">◳</span>本地服务转发<span class="hint">本地服务经 frpc worker 转发至远程 frps</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>
|
||||
</div>
|
||||
<div class="ls-targets">
|
||||
<div v-for="t in ls.targets" :key="t.remote + ':' + t.remotePort" class="ls-target">
|
||||
<span class="ls-arrow">→</span>
|
||||
<span class="ls-remote">{{ t.remote }}</span>
|
||||
<span class="ls-port">:{{ t.remotePort }}</span>
|
||||
<span class="ls-state" :class="{ ok: workerHealthy(t.workerState) }">
|
||||
<div class="lc-targets">
|
||||
<div v-for="t in ls.targets" :key="t.remote + ':' + t.remotePort" class="lc-target">
|
||||
<span class="lc-arrow">→</span>
|
||||
<span class="lc-remote">{{ t.remote }}</span>
|
||||
<span class="lc-port">:{{ t.remotePort }}</span>
|
||||
<span class="w4f-tag" :class="workerHealthy(t.workerState) ? 'w4f-tag-ok' : 'w4f-tag-danger'">
|
||||
{{ localWorkerLabel(t.workerState) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="!ls.targets.length" class="ls-none">未转发</div>
|
||||
<div v-if="!ls.targets.length" class="lc-none">未转发</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!status.localStatus.length" class="ls-empty">尚未配置本地服务</div>
|
||||
<div v-if="!status.localStatus.length" class="empty">尚未配置本地服务 — 前往「连接配置」画布编排</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@ -119,15 +148,15 @@
|
||||
<el-switch v-model="dialog.remote.enabled" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<button class="refresh-btn" @click="dialog.visible = false">取消</button>
|
||||
<button class="add-btn" @click="saveRemote">保存</button>
|
||||
<button class="w4f-btn ghost small" @click="dialog.visible = false">取消</button>
|
||||
<button class="w4f-btn small" @click="saveRemote">保存</button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { api } from '../api'
|
||||
import type { Remote, StatusResp } from '../types'
|
||||
@ -223,87 +252,77 @@ const loadStatus = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- KPI derivations ----
|
||||
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)
|
||||
|
||||
// connState refers to the frps connection from the local frpc worker.
|
||||
// We never control the remote frps itself.
|
||||
const connStateLabel = (s?: string) => {
|
||||
switch (s) {
|
||||
case 'connected':
|
||||
return '已连接'
|
||||
case 'connecting':
|
||||
return '连接中'
|
||||
case 'failed':
|
||||
return '连接失败'
|
||||
case 'disabled':
|
||||
return '已停用'
|
||||
case 'connected': return '已连接'
|
||||
case 'connecting': return '连接中'
|
||||
case 'failed': return '连接失败'
|
||||
case 'disabled': return '已停用'
|
||||
case 'not_started':
|
||||
default:
|
||||
return '未启动'
|
||||
default: return '未启动'
|
||||
}
|
||||
}
|
||||
|
||||
const connBadgeClass = (s?: string) => {
|
||||
const connTagClass = (s?: string) => {
|
||||
switch (s) {
|
||||
case 'connected':
|
||||
return 'ok'
|
||||
case 'connecting':
|
||||
return 'pending'
|
||||
case 'failed':
|
||||
return 'err'
|
||||
case 'connected': return 'w4f-tag-ok'
|
||||
case 'connecting': return 'w4f-tag-info'
|
||||
case 'failed': return 'w4f-tag-danger'
|
||||
case 'disabled':
|
||||
case 'not_started':
|
||||
default:
|
||||
return ''
|
||||
default: return 'w4f-tag-warn'
|
||||
}
|
||||
}
|
||||
const connDotClass = (s?: string) => {
|
||||
switch (s) {
|
||||
case 'connected': return ''
|
||||
case 'connecting': return 'pending'
|
||||
case 'failed': return 'err'
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
const workerHealthy = (s?: string) => s === 'running'
|
||||
|
||||
// M3: per-proxy state from frpc admin API.
|
||||
const proxyStateLabel = (s?: string) => {
|
||||
switch (s) {
|
||||
case 'running':
|
||||
return '运行中'
|
||||
case 'wait start':
|
||||
return '等待启动'
|
||||
case 'start error':
|
||||
return '启动错误'
|
||||
case 'check failed':
|
||||
return '健康检查失败'
|
||||
case 'closed':
|
||||
return '已关闭'
|
||||
case 'running': return '运行中'
|
||||
case 'wait start': return '等待启动'
|
||||
case 'start error': return '启动错误'
|
||||
case 'check failed': return '健康检查失败'
|
||||
case 'closed': return '已关闭'
|
||||
case 'new':
|
||||
default:
|
||||
return '新建'
|
||||
default: return '新建'
|
||||
}
|
||||
}
|
||||
const proxyStateClass = (s?: string) => {
|
||||
switch (s) {
|
||||
case 'running':
|
||||
return 'ok'
|
||||
case 'running': return 'ok'
|
||||
case 'check failed':
|
||||
case 'start error':
|
||||
return 'err'
|
||||
case 'wait start':
|
||||
return 'pending'
|
||||
default:
|
||||
return ''
|
||||
case 'start error': return 'err'
|
||||
case 'wait start': return 'pending'
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
const proxyBarClass = (s?: string) => (s === 'running' ? 'ok' : s === 'check failed' || s === 'start error' ? 'err' : '')
|
||||
const proxyBarWidth = (s?: string) => (s === 'running' ? '100%' : s === 'wait start' ? '45%' : s === 'new' ? '15%' : '30%')
|
||||
|
||||
// localWorkerLabel: state of the LOCAL frpc worker driving this target
|
||||
// (we do not control the remote frps).
|
||||
const localWorkerLabel = (s?: string) => {
|
||||
switch (s) {
|
||||
case 'running':
|
||||
return '运行中'
|
||||
case 'starting':
|
||||
return '启动中'
|
||||
case 'restarting':
|
||||
return '重启中'
|
||||
case 'crashed':
|
||||
return '异常'
|
||||
default:
|
||||
return '未启动'
|
||||
case 'running': return '运行中'
|
||||
case 'starting': return '启动中'
|
||||
case 'restarting': return '重启中'
|
||||
case 'crashed': return '异常'
|
||||
default: return '未启动'
|
||||
}
|
||||
}
|
||||
|
||||
@ -325,352 +344,114 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.add-btn {
|
||||
margin-left: auto;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 5px 14px;
|
||||
background: $color-btn-primary;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
|
||||
&:hover {
|
||||
background: $color-btn-primary-hover;
|
||||
}
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
label {
|
||||
width: 72px;
|
||||
font-size: 13px;
|
||||
color: $color-text-secondary;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.mini-btn {
|
||||
border: 1px solid $color-border;
|
||||
border-radius: 5px;
|
||||
padding: 1px 8px;
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
color: $color-text-secondary;
|
||||
|
||||
&:hover {
|
||||
background: $color-bg-hover;
|
||||
}
|
||||
|
||||
&.danger:hover {
|
||||
color: $color-danger;
|
||||
}
|
||||
}
|
||||
|
||||
.ns-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.status-page {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.status-top {
|
||||
/* top bar */
|
||||
.topbar {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid $color-border-light;
|
||||
background: #fff;
|
||||
padding: 14px 18px;
|
||||
}
|
||||
.tb-left { display: flex; flex-direction: column; gap: 3px; }
|
||||
.page-title { margin: 0; font-size: 17px; font-weight: 800; }
|
||||
.page-sub { font-size: 12px; color: $color-text-muted; }
|
||||
.tb-right { display: flex; gap: 8px; }
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.refresh-hint {
|
||||
font-size: 12px;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
border: 1px solid $color-border;
|
||||
border-radius: 6px;
|
||||
padding: 3px 12px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
|
||||
&:hover {
|
||||
background: $color-bg-hover;
|
||||
}
|
||||
/* KPI grid */
|
||||
.kpis {
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(168px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.status-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.status-section {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.status-title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-cards,
|
||||
.local-status-list {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
gap: 18px;
|
||||
overflow-y: auto;
|
||||
padding-right: 6px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.node-status-card {
|
||||
border: 1px solid $color-border-light;
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
background: #fafafa;
|
||||
border-left: 4px solid $color-danger;
|
||||
|
||||
&.healthy {
|
||||
border-left-color: $color-success;
|
||||
}
|
||||
}
|
||||
|
||||
.ns-head {
|
||||
.section { display: flex; flex-direction: column; }
|
||||
.section-title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 700;
|
||||
color: $color-text-muted;
|
||||
letter-spacing: 0.02em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
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; }
|
||||
}
|
||||
|
||||
.ns-name {
|
||||
font-weight: 600;
|
||||
margin-right: 8px;
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.ns-badge {
|
||||
font-size: 12px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 8px;
|
||||
background: #fef0f0;
|
||||
color: $color-danger;
|
||||
|
||||
&.ok {
|
||||
background: #f0f9eb;
|
||||
color: $color-success;
|
||||
}
|
||||
|
||||
&.pending {
|
||||
background: #ecf5ff;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
&.err {
|
||||
background: #fef0f0;
|
||||
color: $color-danger;
|
||||
}
|
||||
/* remote node card */
|
||||
.node-card {
|
||||
padding: 16px 18px;
|
||||
border-left: 4px solid var(--w4f-danger);
|
||||
&.healthy { border-left-color: var(--w4f-ok); }
|
||||
}
|
||||
|
||||
.ns-meta {
|
||||
font-size: 12px;
|
||||
color: $color-text-muted;
|
||||
margin-top: 2px;
|
||||
.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-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;
|
||||
&.warn { background: $color-warning-50; color: $color-warning; }
|
||||
&.err { background: $color-danger-50; color: $color-danger; }
|
||||
&.info { background: $color-secondary-50; color: $color-secondary-h; }
|
||||
}
|
||||
|
||||
.ns-err {
|
||||
color: $color-danger;
|
||||
.nc-forwards { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
||||
.nc-groups { margin-top: 6px; }
|
||||
.nc-proxies { display: flex; flex-direction: column; gap: 6px; margin-top: 10px; }
|
||||
.nc-proxy { display: flex; align-items: center; gap: 8px; font-size: 12px; padding: 6px 10px; border-radius: $radius-xs;
|
||||
background: var(--w4f-card-2); border: 1px solid var(--w4f-line);
|
||||
&.ok { border-left: 3px solid $color-success; }
|
||||
&.pending { border-left: 3px solid $color-secondary; }
|
||||
&.err { border-left: 3px solid $color-danger; }
|
||||
.w4f-bar { width: 60px; }
|
||||
}
|
||||
.np-name { font-weight: 700; }
|
||||
.np-status { font-size: 11px; color: $color-text-secondary; }
|
||||
.np-addr { margin-left: auto; font-size: 11px; color: $color-text-muted; font-family: var(--w4f-mono); }
|
||||
.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; }
|
||||
|
||||
.ns-vhost {
|
||||
margin-left: 8px;
|
||||
background: #ecf5ff;
|
||||
color: #409eff;
|
||||
border-radius: 6px;
|
||||
padding: 1px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
/* local service card */
|
||||
.local-card { padding: 14px 16px; }
|
||||
.lc-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.lc-name { font-weight: 800; font-size: 14px; }
|
||||
.lc-addr { font-size: 12px; color: $color-text-secondary; font-family: var(--w4f-mono); }
|
||||
.lc-targets { margin-top: 10px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.lc-target { display: flex; align-items: center; gap: 6px; font-size: 13px; }
|
||||
.lc-arrow { color: var(--w4f-primary); font-weight: 700; }
|
||||
.lc-remote { font-weight: 600; }
|
||||
.lc-port { color: $color-text-secondary; font-family: var(--w4f-mono); margin-right: auto; }
|
||||
.lc-none { font-size: 12px; color: $color-text-light; }
|
||||
|
||||
.ns-forwards {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.empty { grid-column: 1 / -1; color: $color-text-muted; font-size: 13px; padding: 18px; text-align: center; }
|
||||
|
||||
.ns-groups {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.ns-group-badge {
|
||||
font-size: 11px;
|
||||
background: rgba(64, 158, 255, 0.12);
|
||||
color: #409eff;
|
||||
border-radius: 10px;
|
||||
padding: 1px 8px;
|
||||
}
|
||||
|
||||
.ns-proxies {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.ns-proxy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
border: 1px solid $color-border-lighter;
|
||||
border-radius: 8px;
|
||||
padding: 3px 8px;
|
||||
background: #fff;
|
||||
|
||||
&.ok {
|
||||
border-left: 3px solid $color-success;
|
||||
}
|
||||
|
||||
&.pending {
|
||||
border-left: 3px solid #409eff;
|
||||
}
|
||||
|
||||
&.err {
|
||||
border-left: 3px solid $color-danger;
|
||||
}
|
||||
}
|
||||
|
||||
.np-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.np-status {
|
||||
font-size: 11px;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
.np-addr {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.np-err {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: $color-danger;
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ns-proxies-empty {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.ns-empty,
|
||||
.ls-empty {
|
||||
color: $color-text-muted;
|
||||
font-size: 13px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.local-status-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-targets {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ls-target {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ls-arrow {
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.ls-remote {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ls-port {
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
.ls-state {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: $color-danger;
|
||||
|
||||
&.ok {
|
||||
color: $color-success;
|
||||
}
|
||||
}
|
||||
|
||||
.ls-none {
|
||||
color: $color-text-light;
|
||||
font-size: 12px;
|
||||
/* dialog form */
|
||||
.form-row { display: flex; align-items: center; gap: 10px; margin-bottom: 12px;
|
||||
label { width: 72px; font-size: 13px; color: $color-text-secondary; flex-shrink: 0; }
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user