mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-20 00:47:57 +00:00
feat: cluster reliability (leader failover, crash rejoin, key exchange) + auth/users + canvas/forwards enhancements + comprehensive README + API docs
- Cluster: forwardToNext offline detection (leader+non-leader), WatchLeader 1s heartbeat fallback, 409 for standalone nodes, Node.NodeKey key exchange via token ring, ClusterPeers persistence + auto-rejoin, Forward delegates to forwardToNext (bugfix) - Auth: Basic Auth (flag-creds fast path) + bcrypt users (admin/viewer) + Bearer API keys (read/write/admin scope) - Frontend: UsersView (accounts+API keys), ClusterView (ring/nodeKey/tasks/topology/log), StatusView (group management, per-proxy status), CanvasView (edge toggle/group), PortEdge (disabled/group labels) - API: handlers split (canvas/forwards/users/logs), canvas export/import, forwards group start/stop/assign/delete, cluster endpoints - Docs: comprehensive README rewrite (all flags/APIs/auth/cluster), docs/cluster-api.md (cluster management API reference) - Deploy: run-cluster.sh now 4-node ring + 1 isolated standalone, test-forward.sh updated for 4 nodes - Removed plan.md (design notes consolidated into README + API docs)
This commit is contained in:
@ -3,7 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>webui-frpc</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<title>webui4frpc</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
283
web/public/favicon.svg
Normal file
283
web/public/favicon.svg
Normal file
@ -0,0 +1,283 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500" width="500" height="500" shape-rendering="crispEdges">
|
||||
<defs>
|
||||
<!-- 主字母像素块(黑色) -->
|
||||
<rect id="p" width="12" height="12" fill="#000000" />
|
||||
<!-- frpc 小像素块(蓝色) -->
|
||||
<rect id="q" width="5" height="5" fill="#2563eb" />
|
||||
<!-- 装饰小方块(浅灰) -->
|
||||
<rect id="d" width="8" height="8" fill="#94a3b8" />
|
||||
<!-- 小十字星装饰 -->
|
||||
<rect id="s" width="6" height="6" fill="#94a3b8" />
|
||||
</defs>
|
||||
|
||||
<!-- 圆角白色背景(模拟 App 图标外框) -->
|
||||
<rect width="500" height="500" rx="40" fill="#ffffff" stroke="#e2e8f0" stroke-width="4" />
|
||||
|
||||
<!-- ========== 主字母整体居中(横向间距更合理) ========== -->
|
||||
<g transform="translate(70, 160)">
|
||||
<!-- ===== W(基于原始结构,高度约 150px) ===== -->
|
||||
<g transform="translate(0, 0)">
|
||||
<!-- 左竖线(占2列) -->
|
||||
<use href="#p" x="0" y="0" />
|
||||
<use href="#p" x="12" y="0" />
|
||||
<use href="#p" x="0" y="12" />
|
||||
<use href="#p" x="12" y="12" />
|
||||
<use href="#p" x="0" y="24" />
|
||||
<use href="#p" x="12" y="24" />
|
||||
<use href="#p" x="0" y="36" />
|
||||
<use href="#p" x="12" y="36" />
|
||||
<use href="#p" x="0" y="48" />
|
||||
<use href="#p" x="12" y="48" />
|
||||
<use href="#p" x="0" y="60" />
|
||||
<use href="#p" x="12" y="60" />
|
||||
<use href="#p" x="0" y="72" />
|
||||
<use href="#p" x="12" y="72" />
|
||||
<use href="#p" x="0" y="84" />
|
||||
<use href="#p" x="12" y="84" />
|
||||
<use href="#p" x="0" y="96" />
|
||||
<use href="#p" x="12" y="96" />
|
||||
<use href="#p" x="0" y="108" />
|
||||
<use href="#p" x="12" y="108" />
|
||||
<use href="#p" x="0" y="120" />
|
||||
<use href="#p" x="12" y="120" />
|
||||
<use href="#p" x="0" y="132" />
|
||||
<use href="#p" x="12" y="132" />
|
||||
|
||||
<!-- 右竖线(占2列) -->
|
||||
<use href="#p" x="84" y="0" />
|
||||
<use href="#p" x="96" y="0" />
|
||||
<use href="#p" x="84" y="12" />
|
||||
<use href="#p" x="96" y="12" />
|
||||
<use href="#p" x="84" y="24" />
|
||||
<use href="#p" x="96" y="24" />
|
||||
<use href="#p" x="84" y="36" />
|
||||
<use href="#p" x="96" y="36" />
|
||||
<use href="#p" x="84" y="48" />
|
||||
<use href="#p" x="96" y="48" />
|
||||
<use href="#p" x="84" y="60" />
|
||||
<use href="#p" x="96" y="60" />
|
||||
<use href="#p" x="84" y="72" />
|
||||
<use href="#p" x="96" y="72" />
|
||||
<use href="#p" x="84" y="84" />
|
||||
<use href="#p" x="96" y="84" />
|
||||
<use href="#p" x="84" y="96" />
|
||||
<use href="#p" x="96" y="96" />
|
||||
<use href="#p" x="84" y="108" />
|
||||
<use href="#p" x="96" y="108" />
|
||||
<use href="#p" x="84" y="120" />
|
||||
<use href="#p" x="96" y="120" />
|
||||
<use href="#p" x="84" y="132" />
|
||||
<use href="#p" x="96" y="132" />
|
||||
|
||||
<!-- 中间 V 形(从 y=60 开始) -->
|
||||
<use href="#p" x="24" y="60" />
|
||||
<use href="#p" x="48" y="60" />
|
||||
<use href="#p" x="72" y="60" />
|
||||
<use href="#p" x="24" y="72" />
|
||||
<use href="#p" x="48" y="72" />
|
||||
<use href="#p" x="72" y="72" />
|
||||
<use href="#p" x="24" y="84" />
|
||||
<use href="#p" x="48" y="84" />
|
||||
<use href="#p" x="72" y="84" />
|
||||
<use href="#p" x="24" y="96" />
|
||||
<use href="#p" x="48" y="96" />
|
||||
<use href="#p" x="72" y="96" />
|
||||
<!-- 底部汇合 -->
|
||||
<use href="#p" x="36" y="108" />
|
||||
<use href="#p" x="60" y="108" />
|
||||
<use href="#p" x="36" y="120" />
|
||||
<use href="#p" x="60" y="120" />
|
||||
<use href="#p" x="36" y="132" />
|
||||
<use href="#p" x="60" y="132" />
|
||||
<use href="#p" x="48" y="132" />
|
||||
</g>
|
||||
|
||||
<!-- ===== U(高度与 W 一致,内部空间增大) ===== -->
|
||||
<g transform="translate(124, 0)">
|
||||
<!-- 左竖线(占2列) -->
|
||||
<use href="#p" x="0" y="0" />
|
||||
<use href="#p" x="12" y="0" />
|
||||
<use href="#p" x="0" y="12" />
|
||||
<use href="#p" x="12" y="12" />
|
||||
<use href="#p" x="0" y="24" />
|
||||
<use href="#p" x="12" y="24" />
|
||||
<use href="#p" x="0" y="36" />
|
||||
<use href="#p" x="12" y="36" />
|
||||
<use href="#p" x="0" y="48" />
|
||||
<use href="#p" x="12" y="48" />
|
||||
<use href="#p" x="0" y="60" />
|
||||
<use href="#p" x="12" y="60" />
|
||||
<use href="#p" x="0" y="72" />
|
||||
<use href="#p" x="12" y="72" />
|
||||
<use href="#p" x="0" y="84" />
|
||||
<use href="#p" x="12" y="84" />
|
||||
<use href="#p" x="0" y="96" />
|
||||
<use href="#p" x="12" y="96" />
|
||||
<use href="#p" x="0" y="108" />
|
||||
<use href="#p" x="12" y="108" />
|
||||
<use href="#p" x="0" y="120" />
|
||||
<use href="#p" x="12" y="120" />
|
||||
<use href="#p" x="0" y="132" />
|
||||
<use href="#p" x="12" y="132" />
|
||||
|
||||
<!-- 右竖线(占2列) -->
|
||||
<use href="#p" x="84" y="0" />
|
||||
<use href="#p" x="96" y="0" />
|
||||
<use href="#p" x="84" y="12" />
|
||||
<use href="#p" x="96" y="12" />
|
||||
<use href="#p" x="84" y="24" />
|
||||
<use href="#p" x="96" y="24" />
|
||||
<use href="#p" x="84" y="36" />
|
||||
<use href="#p" x="96" y="36" />
|
||||
<use href="#p" x="84" y="48" />
|
||||
<use href="#p" x="96" y="48" />
|
||||
<use href="#p" x="84" y="60" />
|
||||
<use href="#p" x="96" y="60" />
|
||||
<use href="#p" x="84" y="72" />
|
||||
<use href="#p" x="96" y="72" />
|
||||
<use href="#p" x="84" y="84" />
|
||||
<use href="#p" x="96" y="84" />
|
||||
<use href="#p" x="84" y="96" />
|
||||
<use href="#p" x="96" y="96" />
|
||||
<use href="#p" x="84" y="108" />
|
||||
<use href="#p" x="96" y="108" />
|
||||
<use href="#p" x="84" y="120" />
|
||||
<use href="#p" x="96" y="120" />
|
||||
<use href="#p" x="84" y="132" />
|
||||
<use href="#p" x="96" y="132" />
|
||||
|
||||
<!-- 底部横线 -->
|
||||
<use href="#p" x="0" y="132" />
|
||||
<use href="#p" x="12" y="132" />
|
||||
<use href="#p" x="24" y="132" />
|
||||
<use href="#p" x="36" y="132" />
|
||||
<use href="#p" x="48" y="132" />
|
||||
<use href="#p" x="60" y="132" />
|
||||
<use href="#p" x="72" y="132" />
|
||||
<use href="#p" x="84" y="132" />
|
||||
<use href="#p" x="96" y="132" />
|
||||
|
||||
<!-- ===== U 内部:竖向排列的 frpc(蓝色,5×5 像素) ===== -->
|
||||
<g transform="translate(32, 18)">
|
||||
<!-- f -->
|
||||
<use href="#q" x="0" y="0" />
|
||||
<use href="#q" x="0" y="6" />
|
||||
<use href="#q" x="0" y="12" />
|
||||
<use href="#q" x="0" y="18" />
|
||||
<use href="#q" x="6" y="0" />
|
||||
<use href="#q" x="12" y="0" />
|
||||
<use href="#q" x="18" y="0" />
|
||||
<use href="#q" x="6" y="12" />
|
||||
<use href="#q" x="12" y="12" />
|
||||
|
||||
<!-- r -->
|
||||
<use href="#q" x="0" y="28" />
|
||||
<use href="#q" x="0" y="34" />
|
||||
<use href="#q" x="0" y="40" />
|
||||
<use href="#q" x="0" y="46" />
|
||||
<use href="#q" x="6" y="28" />
|
||||
<use href="#q" x="12" y="28" />
|
||||
<use href="#q" x="18" y="28" />
|
||||
<use href="#q" x="6" y="40" />
|
||||
<use href="#q" x="12" y="40" />
|
||||
|
||||
<!-- p -->
|
||||
<use href="#q" x="0" y="56" />
|
||||
<use href="#q" x="0" y="62" />
|
||||
<use href="#q" x="0" y="68" />
|
||||
<use href="#q" x="0" y="74" />
|
||||
<use href="#q" x="0" y="80" />
|
||||
<use href="#q" x="6" y="56" />
|
||||
<use href="#q" x="12" y="56" />
|
||||
<use href="#q" x="18" y="56" />
|
||||
<use href="#q" x="18" y="62" />
|
||||
<use href="#q" x="18" y="68" />
|
||||
<use href="#q" x="6" y="74" />
|
||||
<use href="#q" x="12" y="74" />
|
||||
<use href="#q" x="18" y="74" />
|
||||
|
||||
<!-- c -->
|
||||
<use href="#q" x="6" y="90" />
|
||||
<use href="#q" x="12" y="90" />
|
||||
<use href="#q" x="18" y="90" />
|
||||
<use href="#q" x="0" y="96" />
|
||||
<use href="#q" x="0" y="102" />
|
||||
<use href="#q" x="6" y="108" />
|
||||
<use href="#q" x="12" y="108" />
|
||||
<use href="#q" x="18" y="108" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- ===== I(中间竖线 2 列宽,高度一致) ===== -->
|
||||
<g transform="translate(248, 0)">
|
||||
<!-- 顶部横线(占6列) -->
|
||||
<use href="#p" x="0" y="0" />
|
||||
<use href="#p" x="12" y="0" />
|
||||
<use href="#p" x="24" y="0" />
|
||||
<use href="#p" x="36" y="0" />
|
||||
<use href="#p" x="48" y="0" />
|
||||
<use href="#p" x="60" y="0" />
|
||||
|
||||
<!-- 中间竖线(2 列宽) -->
|
||||
<use href="#p" x="24" y="12" />
|
||||
<use href="#p" x="36" y="12" />
|
||||
<use href="#p" x="24" y="24" />
|
||||
<use href="#p" x="36" y="24" />
|
||||
<use href="#p" x="24" y="36" />
|
||||
<use href="#p" x="36" y="36" />
|
||||
<use href="#p" x="24" y="48" />
|
||||
<use href="#p" x="36" y="48" />
|
||||
<use href="#p" x="24" y="60" />
|
||||
<use href="#p" x="36" y="60" />
|
||||
<use href="#p" x="24" y="72" />
|
||||
<use href="#p" x="36" y="72" />
|
||||
<use href="#p" x="24" y="84" />
|
||||
<use href="#p" x="36" y="84" />
|
||||
<use href="#p" x="24" y="96" />
|
||||
<use href="#p" x="36" y="96" />
|
||||
<use href="#p" x="24" y="108" />
|
||||
<use href="#p" x="36" y="108" />
|
||||
<use href="#p" x="24" y="120" />
|
||||
<use href="#p" x="36" y="120" />
|
||||
|
||||
<!-- 底部横线(占6列) -->
|
||||
<use href="#p" x="0" y="132" />
|
||||
<use href="#p" x="12" y="132" />
|
||||
<use href="#p" x="24" y="132" />
|
||||
<use href="#p" x="36" y="132" />
|
||||
<use href="#p" x="48" y="132" />
|
||||
<use href="#p" x="60" y="132" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- ========== 外围装饰元素(像素风,增加层次) ========== -->
|
||||
<!-- 左上角小十字星 -->
|
||||
<g transform="translate(40, 40)">
|
||||
<use href="#s" x="0" y="0" />
|
||||
<use href="#s" x="0" y="12" />
|
||||
<use href="#s" x="6" y="6" />
|
||||
<use href="#s" x="12" y="0" />
|
||||
<use href="#s" x="12" y="12" />
|
||||
</g>
|
||||
<!-- 右上角小方块 -->
|
||||
<use href="#d" x="440" y="40" />
|
||||
<!-- 左下角小方块 -->
|
||||
<use href="#d" x="40" y="440" />
|
||||
<!-- 右下角小十字星 -->
|
||||
<g transform="translate(436, 436)">
|
||||
<use href="#s" x="0" y="0" />
|
||||
<use href="#s" x="0" y="12" />
|
||||
<use href="#s" x="6" y="6" />
|
||||
<use href="#s" x="12" y="0" />
|
||||
<use href="#s" x="12" y="12" />
|
||||
</g>
|
||||
|
||||
<!-- 三个随机黑色装饰点(更大一点,12×12) -->
|
||||
<rect x="90" y="380" width="12" height="12" fill="#000000" />
|
||||
<rect x="420" y="140" width="12" height="12" fill="#000000" />
|
||||
<rect x="240" y="60" width="12" height="12" fill="#000000" />
|
||||
|
||||
<!-- 额外小点缀:蓝色小点,呼应 frpc 颜色 -->
|
||||
<rect x="180" y="400" width="6" height="6" fill="#2563eb" />
|
||||
<rect x="370" y="80" width="6" height="6" fill="#2563eb" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 10 KiB |
125
web/src/App.vue
125
web/src/App.vue
@ -13,7 +13,7 @@
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar">
|
||||
<div class="sb-brand">
|
||||
<span class="sb-logo" aria-hidden="true">W</span>
|
||||
<img class="sb-logo" src="/favicon.svg" alt="webui4frpc" />
|
||||
<span class="sb-brand-text">
|
||||
<b>webui4frpc</b>
|
||||
<small>令牌环 · 转发编排</small>
|
||||
@ -34,7 +34,15 @@
|
||||
</nav>
|
||||
|
||||
<div class="sb-foot">
|
||||
<span class="sb-status"><span class="dot" /> token-ring · M6</span>
|
||||
<div class="sb-identity" v-if="authReady">
|
||||
<span class="id-name">{{ authName || '匿名' }}</span>
|
||||
<span class="id-level" :class="authLevel">{{ levelLabel }}</span>
|
||||
</div>
|
||||
<div class="sb-theme" role="group" aria-label="主题">
|
||||
<button class="sw white" :class="{ on: theme === 'white' }" title="白色" @click="setTheme('white')" />
|
||||
<button class="sw blue" :class="{ on: theme === 'blue' }" title="蓝色" @click="setTheme('blue')" />
|
||||
<button class="sw pink" :class="{ on: theme === 'pink' }" title="粉色" @click="setTheme('pink')" />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@ -46,6 +54,7 @@
|
||||
<CanvasView v-if="view === 'canvas'" />
|
||||
<SettingsView v-else-if="view === 'settings'" />
|
||||
<ClusterView v-else-if="view === 'cluster'" />
|
||||
<UsersView v-else-if="view === 'users'" />
|
||||
<StatusView v-else />
|
||||
</main>
|
||||
</div>
|
||||
@ -53,23 +62,54 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import CanvasView from './views/CanvasView.vue'
|
||||
import SettingsView from './views/SettingsView.vue'
|
||||
import StatusView from './views/StatusView.vue'
|
||||
import ClusterView from './views/ClusterView.vue'
|
||||
import UsersView from './views/UsersView.vue'
|
||||
import { authLevel, authName, authReady, fetchMe, isAdmin } from './auth'
|
||||
|
||||
type ViewKey = 'canvas' | 'settings' | 'status' | 'cluster'
|
||||
type ViewKey = 'canvas' | 'settings' | 'status' | 'cluster' | 'users'
|
||||
const view = ref<ViewKey>('status')
|
||||
|
||||
const nav: { key: ViewKey; label: string; icon: string }[] = [
|
||||
onMounted(() => { fetchMe() })
|
||||
|
||||
const levelLabel = computed(() =>
|
||||
authLevel.value === 'admin' ? '管理员' : authLevel.value === 'write' ? '写' : '只读',
|
||||
)
|
||||
|
||||
// ---- theme switcher (white default / blue / pink) ----
|
||||
// The data-theme attribute is applied pre-mount by main.ts (no flash); here we
|
||||
// only mirror it so the active swatch highlights, and update it on click.
|
||||
type Theme = 'white' | 'blue' | 'pink'
|
||||
const VALID_THEMES: Theme[] = ['white', 'blue', 'pink']
|
||||
const theme = ref<Theme>(
|
||||
(VALID_THEMES as string[]).includes(document.documentElement.getAttribute('data-theme') || '')
|
||||
? (document.documentElement.getAttribute('data-theme') as Theme)
|
||||
: 'white',
|
||||
)
|
||||
const setTheme = (t: Theme) => {
|
||||
theme.value = t
|
||||
document.documentElement.setAttribute('data-theme', t)
|
||||
localStorage.setItem('w4f-theme', t)
|
||||
}
|
||||
|
||||
const allNav: { key: ViewKey; label: string; icon: string; adminOnly?: boolean }[] = [
|
||||
{ key: 'status', label: '状态', icon: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18"/><path d="m19 9-5 5-4-4-3 3"/></svg>' },
|
||||
{ key: 'canvas', label: '连接配置', icon: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><circle cx="12" cy="18" r="3"/><path d="M8.5 7.5 16 16M15.5 7.5 8 16"/></svg>' },
|
||||
{ key: 'settings', label: '设置', icon: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>' },
|
||||
{ key: 'cluster', label: '集群', icon: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="2"/><circle cx="5" cy="19" r="2"/><circle cx="19" cy="19" r="2"/><path d="M12 7v4m0 0-5 6m5-6 5 6"/></svg>' },
|
||||
{ key: 'users', label: '账号与密钥', adminOnly: true, icon: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>' },
|
||||
]
|
||||
|
||||
const currentLabel = computed(() => nav.find((n) => n.key === view.value)?.label ?? '')
|
||||
// nav filters admin-only entries (账号与密钥) until /me resolves to admin.
|
||||
// Until authReady, the users entry is hidden so a viewer never sees it flash.
|
||||
const nav = computed(() =>
|
||||
allNav.filter((n) => !n.adminOnly || (authReady.value && isAdmin.value)),
|
||||
)
|
||||
|
||||
const currentLabel = computed(() => nav.value.find((n) => n.key === view.value)?.label ?? '')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@ -111,14 +151,8 @@ const currentLabel = computed(() => nav.find((n) => n.key === view.value)?.label
|
||||
height: 40px;
|
||||
border-radius: 13px;
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
font-size: 19px;
|
||||
letter-spacing: 0.5px;
|
||||
background: linear-gradient(135deg, var(--w4f-primary), var(--w4f-secondary));
|
||||
box-shadow: 0 6px 18px rgba(255, 127, 172, 0.45);
|
||||
object-fit: contain;
|
||||
box-shadow: 0 6px 18px var(--w4f-glow);
|
||||
}
|
||||
.sb-brand-text b {
|
||||
font-size: 16.5px;
|
||||
@ -182,26 +216,61 @@ const currentLabel = computed(() => nav.find((n) => n.key === view.value)?.label
|
||||
margin-top: auto;
|
||||
padding-top: 16px;
|
||||
}
|
||||
.sb-status {
|
||||
display: inline-flex;
|
||||
|
||||
/* ---------- identity chip ---------- */
|
||||
.sb-identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 9px 13px;
|
||||
border-radius: var(--w4f-radius-pill);
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 10px;
|
||||
background: var(--w4f-card-2);
|
||||
border: 1px solid var(--w4f-line);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.sb-identity .id-name {
|
||||
font-weight: 600;
|
||||
color: var(--w4f-primary-h);
|
||||
background: var(--w4f-primary-50);
|
||||
color: var(--w4f-fg);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sb-status .dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--w4f-primary);
|
||||
box-shadow: 0 0 0 3px rgba(255, 127, 172, 0.18);
|
||||
animation: blink 1.6s infinite;
|
||||
.sb-identity .id-level {
|
||||
flex: 0 0 auto;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--w4f-muted);
|
||||
background: var(--w4f-card);
|
||||
}
|
||||
@keyframes blink { 50% { opacity: 0.25; } }
|
||||
.sb-identity .id-level.admin {
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, var(--w4f-primary), var(--w4f-secondary));
|
||||
}
|
||||
.sb-identity .id-level.write {
|
||||
color: var(--w4f-warning);
|
||||
background: var(--w4f-warning-50);
|
||||
}
|
||||
.sb-identity .id-level.read {
|
||||
color: var(--w4f-muted);
|
||||
}
|
||||
|
||||
/* ---------- theme switcher ---------- */
|
||||
.sb-theme { display: flex; gap: 6px; margin-bottom: 12px; }
|
||||
.sb-theme .sw {
|
||||
width: 26px; height: 26px; border-radius: 8px; border: 2px solid var(--w4f-line);
|
||||
cursor: pointer; padding: 0;
|
||||
transition: transform 0.15s var(--w4f-ease-spring), box-shadow 0.15s var(--w4f-ease-spring);
|
||||
}
|
||||
.sb-theme .sw:hover { transform: translateY(-1px); }
|
||||
.sb-theme .sw.on { border-color: var(--w4f-primary); box-shadow: 0 0 0 3px var(--w4f-glow-soft); }
|
||||
/* swatch fills are FIXED (represent each theme's identity), not theme-driven */
|
||||
.sb-theme .sw.white { background: linear-gradient(135deg, #ffffff, #cdd4e0); }
|
||||
.sb-theme .sw.blue { background: linear-gradient(135deg, #3b82f6, #06b6d4); }
|
||||
.sb-theme .sw.pink { background: linear-gradient(135deg, #ff7fac, #f33b7c); }
|
||||
|
||||
/* ---------- main ---------- */
|
||||
.main {
|
||||
|
||||
@ -1,14 +1,20 @@
|
||||
// HTTP client and API functions for webui4frpc.
|
||||
import type {
|
||||
ApiKey,
|
||||
ApiKeyCreated,
|
||||
BinaryStatus,
|
||||
CacheResp,
|
||||
CanvasData,
|
||||
CanvasExportEnvelope,
|
||||
ClusterNodesResp,
|
||||
InstallResult,
|
||||
MeResp,
|
||||
Remote,
|
||||
RingSnapshot,
|
||||
Settings,
|
||||
StatusResp,
|
||||
User,
|
||||
WorkerLogBundle,
|
||||
} from "./types";
|
||||
|
||||
class HTTPError extends Error {
|
||||
@ -66,6 +72,49 @@ export const api = {
|
||||
method: "DELETE",
|
||||
}),
|
||||
|
||||
// Per-forward start/stop (forwards page). local-only forwards toggle the
|
||||
// local frpc worker; cluster forwards submit/revoke via the ring.
|
||||
forwardStart: (local: string, remote: string, remotePort: number) =>
|
||||
request<{ ok: boolean }>("/api/manager/forwards/start", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ local, remote, remotePort }),
|
||||
}),
|
||||
forwardStop: (local: string, remote: string, remotePort: number) =>
|
||||
request<{ ok: boolean }>("/api/manager/forwards/stop", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ local, remote, remotePort }),
|
||||
}),
|
||||
groupStart: (group: string) =>
|
||||
request<{ ok: boolean }>("/api/manager/forwards/group/start", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ group }),
|
||||
}),
|
||||
groupStop: (group: string) =>
|
||||
request<{ ok: boolean }>("/api/manager/forwards/group/stop", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ group }),
|
||||
}),
|
||||
// assignGroup changes a single forward's group label (status page chip).
|
||||
// Empty group clears the assignment (移出分组). Pure DB update, no worker.
|
||||
assignGroup: (local: string, remote: string, remotePort: number, group: string) =>
|
||||
request<{ ok: boolean }>("/api/manager/forwards/assign", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ local, remote, remotePort, group }),
|
||||
}),
|
||||
// deleteGroup dissolves a group: all members moved to 未分组. The group is
|
||||
// just a label on links, so clearing all members is the complete delete.
|
||||
deleteGroup: (group: string) =>
|
||||
request<{ ok: boolean }>("/api/manager/forwards/group/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ group }),
|
||||
}),
|
||||
|
||||
profileStart: (name: string) =>
|
||||
request<void>(`/api/manager/profiles/${encodeURIComponent(name)}/start`, {
|
||||
method: "POST",
|
||||
@ -101,11 +150,12 @@ export const api = {
|
||||
clusterCreate: () =>
|
||||
request<RingSnapshot>("/api/manager/cluster/create", { method: "POST" }),
|
||||
// clusterJoinRing: THIS node joins the cluster at peer addr (加入集群).
|
||||
clusterJoinRing: (addr: string) =>
|
||||
// joinKey is the sponsor node's nodeKey — required for admission security.
|
||||
clusterJoinRing: (addr: string, joinKey: string) =>
|
||||
request<RingSnapshot>("/api/manager/cluster/join-ring", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ addr }),
|
||||
body: JSON.stringify({ addr, joinKey }),
|
||||
}),
|
||||
// clusterRemoveNode: publish a node-removal command (移除节点 / 退出集群[self]).
|
||||
clusterRemoveNode: (id: string) =>
|
||||
@ -114,4 +164,45 @@ export const api = {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id }),
|
||||
}),
|
||||
|
||||
// M7 auth/accounts/API keys/canvas export-import/worker logs.
|
||||
me: () => request<MeResp>("/api/manager/me"),
|
||||
listUsers: () => request<{ users: User[] }>("/api/manager/users"),
|
||||
createUser: (username: string, password: string, role: 'admin' | 'viewer') =>
|
||||
request<User>("/api/manager/users", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password, role }),
|
||||
}),
|
||||
updateUser: (name: string, patch: { password?: string; role?: 'admin' | 'viewer'; enabled?: boolean }) =>
|
||||
request<User>(`/api/manager/users/${encodeURIComponent(name)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
}),
|
||||
deleteUser: (name: string) =>
|
||||
request<void>(`/api/manager/users/${encodeURIComponent(name)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
listApiKeys: () => request<{ apiKeys: ApiKey[] }>("/api/manager/apikeys"),
|
||||
createApiKey: (userId: number, label: string, scope: 'read' | 'write' | 'admin') =>
|
||||
request<ApiKeyCreated>("/api/manager/apikeys", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId, label, scope }),
|
||||
}),
|
||||
deleteApiKey: (id: number) =>
|
||||
request<void>(`/api/manager/apikeys/${id}`, { method: "DELETE" }),
|
||||
|
||||
// Canvas export/import (转发表 备份/还原).
|
||||
exportCanvas: () => request<CanvasExportEnvelope>("/api/manager/canvas/export"),
|
||||
importCanvas: (data: CanvasData | CanvasExportEnvelope) =>
|
||||
request<CanvasData>("/api/manager/canvas/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
// Worker-log bundle (HTTP fan-out across ring nodes; read-level/auditor).
|
||||
exportWorkerLogs: () => request<WorkerLogBundle>("/api/manager/cluster/logs/export"),
|
||||
};
|
||||
|
||||
34
web/src/auth.ts
Normal file
34
web/src/auth.ts
Normal file
@ -0,0 +1,34 @@
|
||||
// Lightweight reactive auth principal for the SPA. The browser handles Basic
|
||||
// Auth (prompted once when the SPA shell loads behind auth("read")); this just
|
||||
// mirrors the resolved identity from GET /me so views can gate their UI.
|
||||
//
|
||||
// We default to read until /me resolves so a viewer never sees a flash of
|
||||
// admin controls. canWrite/isAdmin are memoized over the reactive level.
|
||||
import { computed, ref } from 'vue'
|
||||
import type { AccessLevel } from './types'
|
||||
import { api } from './api'
|
||||
|
||||
export const authLevel = ref<AccessLevel>('read')
|
||||
export const authName = ref('')
|
||||
export const authReady = ref(false)
|
||||
|
||||
const RANK: Record<AccessLevel, number> = { read: 1, write: 2, admin: 3 }
|
||||
|
||||
export const canWrite = computed(() => RANK[authLevel.value] >= RANK.write)
|
||||
export const isAdmin = computed(() => authLevel.value === 'admin')
|
||||
|
||||
// fetchMe resolves the current principal. Called once on app mount; safe to
|
||||
// call again after account changes that could affect the live session.
|
||||
export async function fetchMe(): Promise<void> {
|
||||
try {
|
||||
const me = await api.me()
|
||||
authLevel.value = me.level
|
||||
authName.value = me.name
|
||||
} catch {
|
||||
// 401 (no/failed auth) — stay read-only; the browser will have prompted.
|
||||
authLevel.value = 'read'
|
||||
authName.value = ''
|
||||
} finally {
|
||||
authReady.value = true
|
||||
}
|
||||
}
|
||||
@ -197,19 +197,19 @@ const httpHeadersField = computed({
|
||||
.local-node {
|
||||
min-width: 240px;
|
||||
border-radius: 14px 14px 14px 4px;
|
||||
background: #e8f5e9;
|
||||
border: 2px solid #4caf50;
|
||||
box-shadow: 0 2px 8px rgba(76, 175, 80, 0.18);
|
||||
background: var(--w4f-ok-50);
|
||||
border: 2px solid var(--w4f-ok);
|
||||
box-shadow: 0 2px 8px var(--w4f-ok-glow);
|
||||
padding: 8px 10px;
|
||||
|
||||
&.selected {
|
||||
border-color: #ffd54f;
|
||||
box-shadow: 0 0 0 3px rgba(255, 213, 79, 0.45);
|
||||
border-color: var(--w4f-warning);
|
||||
box-shadow: 0 0 0 3px var(--w4f-warn-glow);
|
||||
}
|
||||
|
||||
&.conflicted {
|
||||
border-color: #f5222d;
|
||||
box-shadow: 0 0 0 3px rgba(245, 34, 45, 0.4);
|
||||
border-color: var(--w4f-danger);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--w4f-danger) 40%, transparent);
|
||||
animation: conflict-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
@ -217,10 +217,10 @@ const httpHeadersField = computed({
|
||||
@keyframes conflict-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 3px rgba(245, 34, 45, 0.4);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--w4f-danger) 40%, transparent);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 5px rgba(245, 34, 45, 0.7);
|
||||
box-shadow: 0 0 0 5px color-mix(in srgb, var(--w4f-danger) 70%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@ -230,7 +230,7 @@ const httpHeadersField = computed({
|
||||
gap: 6px;
|
||||
|
||||
.node-icon {
|
||||
color: #4caf50;
|
||||
color: var(--w4f-ok);
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
@ -242,21 +242,21 @@ const httpHeadersField = computed({
|
||||
background: transparent;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #2e7d32;
|
||||
color: var(--w4f-ok-text);
|
||||
&:focus {
|
||||
outline: 1px solid #4caf50;
|
||||
outline: 1px solid var(--w4f-ok);
|
||||
}
|
||||
}
|
||||
|
||||
.del-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #81c784;
|
||||
color: var(--w4f-faint);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
&:hover {
|
||||
color: #c62828;
|
||||
color: var(--w4f-danger);
|
||||
}
|
||||
}
|
||||
|
||||
@ -264,15 +264,15 @@ const httpHeadersField = computed({
|
||||
%field-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 1px solid #a5d6a7;
|
||||
border: 1px solid var(--w4f-ok-border);
|
||||
border-radius: 6px;
|
||||
padding: 2px 6px;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
background: var(--w4f-card-solid);
|
||||
color: var(--w4f-fg);
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #4caf50;
|
||||
border-color: var(--w4f-ok);
|
||||
}
|
||||
}
|
||||
|
||||
@ -288,7 +288,7 @@ const httpHeadersField = computed({
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: #558b2f;
|
||||
color: var(--w4f-ok-text);
|
||||
|
||||
input,
|
||||
select {
|
||||
@ -309,9 +309,9 @@ const httpHeadersField = computed({
|
||||
margin-top: 6px;
|
||||
|
||||
.adv-toggle {
|
||||
border: 1px dashed #a5d6a7;
|
||||
border: 1px dashed var(--w4f-ok-border);
|
||||
background: transparent;
|
||||
color: #558b2f;
|
||||
color: var(--w4f-ok-text);
|
||||
font-size: 11px;
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
@ -327,7 +327,7 @@ const httpHeadersField = computed({
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
padding: 8px;
|
||||
background: rgba(76, 175, 80, 0.06);
|
||||
background: var(--w4f-ok-100);
|
||||
border-radius: 8px;
|
||||
|
||||
label {
|
||||
@ -335,7 +335,7 @@ const httpHeadersField = computed({
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 11px;
|
||||
color: #558b2f;
|
||||
color: var(--w4f-ok-text);
|
||||
|
||||
input,
|
||||
select {
|
||||
@ -359,8 +359,8 @@ const httpHeadersField = computed({
|
||||
margin: 4px 0 2px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #2e7d32;
|
||||
border-top: 1px dashed rgba(76, 175, 80, 0.35);
|
||||
color: var(--w4f-ok-text);
|
||||
border-top: 1px dashed var(--w4f-ok-border);
|
||||
padding-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
class="port-edge-path"
|
||||
:class="{ selected, conflicted }"
|
||||
fill="none"
|
||||
:stroke="strokeColor"
|
||||
:style="{ stroke: strokeColor }"
|
||||
:stroke-width="selected ? 3.5 : 2.5"
|
||||
/>
|
||||
<!-- Draggable port label. Dragging it moves the curve (and its midpoint)
|
||||
@ -21,7 +21,19 @@
|
||||
@pointerup="onPointerUp"
|
||||
@pointercancel="onPointerUp"
|
||||
>
|
||||
{{ displayPort }}
|
||||
<span
|
||||
class="port-toggle"
|
||||
:class="{ off: isDisabled }"
|
||||
@pointerdown.stop
|
||||
@click.stop="emit('toggle-disabled', { edgeId: props.id })"
|
||||
>{{ isDisabled ? '禁' : '通' }}</span>
|
||||
<span class="port-num">{{ displayPort }}</span>
|
||||
<span
|
||||
v-if="groupName"
|
||||
class="port-group"
|
||||
@pointerdown.stop
|
||||
@click.stop="emit('edit-group', { edgeId: props.id })"
|
||||
>{{ groupName }}</span>
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
</g>
|
||||
@ -46,6 +58,8 @@ const emit = defineEmits<{
|
||||
e: 'label-drag',
|
||||
payload: { edgeId: string; offset: { x: number; y: number } },
|
||||
): void
|
||||
(e: 'toggle-disabled', payload: { edgeId: string }): void
|
||||
(e: 'edit-group', payload: { edgeId: string }): void
|
||||
}>()
|
||||
|
||||
// User-dragged offset relative to the curve midpoint, initialized from the
|
||||
@ -141,6 +155,11 @@ function readZoom(): number {
|
||||
// above earlier ones.
|
||||
const layer = computed(() => Number(props.data?.layer ?? 0))
|
||||
|
||||
// isDisabled / groupName: read from edge data so the toggle badge and group
|
||||
// label reflect the persisted state (disabled/group survive canvas saves).
|
||||
const isDisabled = computed(() => Boolean(props.data?.disabled))
|
||||
const groupName = computed(() => String(props.data?.group ?? ''))
|
||||
|
||||
const displayPort = computed(() => {
|
||||
const t = String(props.label ?? '')
|
||||
return t || '?'
|
||||
@ -186,19 +205,24 @@ const labelStyle = computed(() => ({
|
||||
}))
|
||||
|
||||
const strokeColor = computed(() => {
|
||||
if (isDisabled.value) {
|
||||
return 'var(--el-color-info-light-5)'
|
||||
}
|
||||
if (props.conflicted) {
|
||||
return '#f5222d'
|
||||
return 'var(--w4f-danger)'
|
||||
}
|
||||
const l = layer.value
|
||||
// Muted, UI-friendly palette that matches the existing theme (Element Plus
|
||||
// primary / success / warning / danger / info), cycled per layer.
|
||||
// Theme-aware palette: cycle the Element Plus semantic colors per layer so
|
||||
// parallel edges stay distinguishable while following the active accent.
|
||||
// Returned as CSS var() strings — applied via inline :style (presentation
|
||||
// attributes don't resolve var()), so the edge recolors live on theme switch.
|
||||
const hues = [
|
||||
'#409eff',
|
||||
'#67c23a',
|
||||
'#e6a23c',
|
||||
'#f56c6c',
|
||||
'#909399',
|
||||
'#409eff',
|
||||
'var(--el-color-primary)',
|
||||
'var(--el-color-success)',
|
||||
'var(--el-color-warning)',
|
||||
'var(--el-color-danger)',
|
||||
'var(--el-color-info)',
|
||||
'var(--el-color-primary)',
|
||||
]
|
||||
return hues[l % hues.length]
|
||||
})
|
||||
@ -288,10 +312,10 @@ function midpointOf(path: string): { x: number; y: number } {
|
||||
<style scoped lang="scss">
|
||||
.port-edge-path {
|
||||
&.selected {
|
||||
filter: drop-shadow(0 0 4px rgba(244, 67, 54, 0.6));
|
||||
filter: drop-shadow(0 0 4px color-mix(in srgb, var(--w4f-primary) 60%, transparent));
|
||||
}
|
||||
&.conflicted {
|
||||
filter: drop-shadow(0 0 6px rgba(245, 34, 45, 0.8));
|
||||
filter: drop-shadow(0 0 6px color-mix(in srgb, var(--w4f-danger) 80%, transparent));
|
||||
animation: conflict-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
@ -307,7 +331,7 @@ function midpointOf(path: string): { x: number; y: number } {
|
||||
}
|
||||
|
||||
.port-label {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
background: color-mix(in srgb, var(--w4f-card-solid) 90%, transparent);
|
||||
color: $color-text-primary;
|
||||
border: 1px solid $color-border-light;
|
||||
border-radius: 10px;
|
||||
@ -316,7 +340,7 @@ function midpointOf(path: string): { x: number; y: number } {
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
|
||||
box-shadow: $shadow-sm;
|
||||
user-select: none;
|
||||
transition:
|
||||
box-shadow $transition-fast,
|
||||
@ -324,7 +348,40 @@ function midpointOf(path: string): { x: number; y: number } {
|
||||
|
||||
&.dragging {
|
||||
cursor: grabbing;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.22);
|
||||
box-shadow: $shadow-md;
|
||||
}
|
||||
|
||||
.port-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 6px;
|
||||
padding: 0 4px;
|
||||
margin-right: 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
background: color-mix(in srgb, var(--el-color-success) 18%, transparent);
|
||||
color: var(--el-color-success);
|
||||
cursor: pointer;
|
||||
transition: background $transition-fast, color $transition-fast;
|
||||
&.off {
|
||||
background: color-mix(in srgb, var(--el-color-info) 18%, transparent);
|
||||
color: var(--el-color-info);
|
||||
}
|
||||
}
|
||||
|
||||
.port-group {
|
||||
margin-left: 6px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
background: color-mix(in srgb, var(--w4f-secondary) 16%, transparent);
|
||||
color: var(--w4f-secondary-h);
|
||||
cursor: pointer;
|
||||
transition: background $transition-fast;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -107,19 +107,19 @@ const showAdv = ref(false)
|
||||
.remote-node {
|
||||
min-width: 200px;
|
||||
border-radius: 14px 14px 4px 14px;
|
||||
background: #fff3e0;
|
||||
border: 2px solid #ff9800;
|
||||
box-shadow: 0 2px 8px rgba(255, 152, 0, 0.18);
|
||||
background: var(--w4f-warn-50);
|
||||
border: 2px solid var(--w4f-warning);
|
||||
box-shadow: 0 2px 8px var(--w4f-warn-glow);
|
||||
padding: 8px 10px;
|
||||
|
||||
&.selected {
|
||||
border-color: #ffd54f;
|
||||
box-shadow: 0 0 0 3px rgba(255, 213, 79, 0.45);
|
||||
border-color: var(--w4f-warning);
|
||||
box-shadow: 0 0 0 3px var(--w4f-warn-glow);
|
||||
}
|
||||
|
||||
&.conflicted {
|
||||
border-color: #f5222d;
|
||||
box-shadow: 0 0 0 3px rgba(245, 34, 45, 0.4);
|
||||
border-color: var(--w4f-danger);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--w4f-danger) 40%, transparent);
|
||||
animation: conflict-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
@ -127,10 +127,10 @@ const showAdv = ref(false)
|
||||
@keyframes conflict-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 3px rgba(245, 34, 45, 0.4);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--w4f-danger) 40%, transparent);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 5px rgba(245, 34, 45, 0.7);
|
||||
box-shadow: 0 0 0 5px color-mix(in srgb, var(--w4f-danger) 70%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@ -140,7 +140,7 @@ const showAdv = ref(false)
|
||||
gap: 6px;
|
||||
|
||||
.node-icon {
|
||||
color: #ff9800;
|
||||
color: var(--w4f-warning);
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
@ -152,9 +152,9 @@ const showAdv = ref(false)
|
||||
background: transparent;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #e65100;
|
||||
color: var(--w4f-warn-text);
|
||||
&:focus {
|
||||
outline: 1px solid #ff9800;
|
||||
outline: 1px solid var(--w4f-warning);
|
||||
}
|
||||
}
|
||||
|
||||
@ -163,18 +163,18 @@ const showAdv = ref(false)
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: 10px;
|
||||
color: #ef6c00;
|
||||
color: var(--w4f-warn-text);
|
||||
}
|
||||
|
||||
.del-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #ffb74d;
|
||||
color: var(--w4f-faint);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
&:hover {
|
||||
color: #c62828;
|
||||
color: var(--w4f-danger);
|
||||
}
|
||||
}
|
||||
|
||||
@ -189,20 +189,20 @@ const showAdv = ref(false)
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: #ef6c00;
|
||||
color: var(--w4f-warn-text);
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 1px solid #ffcc80;
|
||||
border: 1px solid var(--w4f-warn-border);
|
||||
border-radius: 6px;
|
||||
padding: 2px 6px;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
background: var(--w4f-card-solid);
|
||||
color: var(--w4f-fg);
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #ff9800;
|
||||
border-color: var(--w4f-warning);
|
||||
}
|
||||
}
|
||||
|
||||
@ -215,9 +215,9 @@ const showAdv = ref(false)
|
||||
.adv {
|
||||
margin-top: 6px;
|
||||
.adv-toggle {
|
||||
border: 1px dashed #ffcc80;
|
||||
border: 1px dashed var(--w4f-warn-border);
|
||||
background: transparent;
|
||||
color: #ef6c00;
|
||||
color: var(--w4f-warn-text);
|
||||
font-size: 11px;
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
@ -229,7 +229,7 @@ const showAdv = ref(false)
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
padding: 6px;
|
||||
background: rgba(255, 152, 0, 0.06);
|
||||
background: var(--w4f-warn-100);
|
||||
border-radius: 8px;
|
||||
|
||||
label {
|
||||
@ -237,21 +237,21 @@ const showAdv = ref(false)
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: #ef6c00;
|
||||
color: var(--w4f-warn-text);
|
||||
|
||||
input,
|
||||
select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 1px solid #ffcc80;
|
||||
border: 1px solid var(--w4f-warn-border);
|
||||
border-radius: 6px;
|
||||
padding: 2px 6px;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
background: var(--w4f-card-solid);
|
||||
color: var(--w4f-fg);
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #ff9800;
|
||||
border-color: var(--w4f-warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,20 @@ import 'element-plus/dist/index.css'
|
||||
import './styles/theme.css'
|
||||
import App from './App.vue'
|
||||
|
||||
// Apply the saved theme BEFORE mount so the first paint already uses the
|
||||
// correct palette (no white→pink/blue flash). :root is the white default, so
|
||||
// only blue/pink need an explicit data-theme attribute.
|
||||
(function applyThemeEarly() {
|
||||
try {
|
||||
const saved = localStorage.getItem('w4f-theme')
|
||||
if (saved === 'blue' || saved === 'pink') {
|
||||
document.documentElement.setAttribute('data-theme', saved)
|
||||
}
|
||||
} catch {
|
||||
/* localStorage unavailable (private mode) — stay on the white default */
|
||||
}
|
||||
})()
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(ElementPlus)
|
||||
|
||||
@ -1,19 +1,190 @@
|
||||
/*
|
||||
* 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.
|
||||
* webui4frpc global theme — glassmorphism with a switchable accent.
|
||||
*
|
||||
* 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.
|
||||
* Three themes share the same surface treatment (translucent glass cards,
|
||||
* animated gradient-mesh background, KPI cards, gradient buttons, pill tags,
|
||||
* slim scrollbars); only the accent hue changes:
|
||||
* :root = white (neutral slate) — the DEFAULT
|
||||
* [data-theme="blue"] = blue / cyan
|
||||
* [data-theme="pink"] = sakura / frost (the original ModelRouter palette)
|
||||
* The active theme is applied by setting data-theme on <html> (see App.vue +
|
||||
* main.ts; persisted in localStorage). Element Plus variables are overridden
|
||||
* per theme so EP components inherit the active accent live.
|
||||
*
|
||||
* Semantic note: green = "online / ok" in EVERY theme (as in ModelRouter).
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* ---- palette (sakura / frost) ---- */
|
||||
/* ---- palette (white / neutral slate — default) ---- */
|
||||
--w4f-primary: #5b6478;
|
||||
--w4f-primary-h: #3f4654;
|
||||
--w4f-primary-50: #f1f3f7;
|
||||
--w4f-primary-100: #e3e7ef;
|
||||
--w4f-primary-200: #cdd4e0;
|
||||
--w4f-primary-300: #a7b1c3;
|
||||
--w4f-secondary: #8a93a8;
|
||||
--w4f-secondary-h: #5b6478;
|
||||
--w4f-secondary-50: #f4f6f9;
|
||||
--w4f-secondary-200: #c8ced9;
|
||||
--w4f-danger: #d6495f;
|
||||
--w4f-danger-50: #fdecef;
|
||||
--w4f-warning: #b7791f;
|
||||
--w4f-warning-50: #fdf3e3;
|
||||
--w4f-info: #3f6ef5;
|
||||
--w4f-ok: #17a964;
|
||||
|
||||
/* ---- surfaces / text ---- */
|
||||
--w4f-bg-1: #f4f5f7;
|
||||
--w4f-bg-2: #ffffff;
|
||||
--w4f-bg-3: #eef0f3;
|
||||
--w4f-bg-soft: #f8f9fb;
|
||||
--w4f-bg-muted: #f4f5f7;
|
||||
--w4f-bg-hover: #eef0f3;
|
||||
--w4f-bg-active: #e3e7ef;
|
||||
--w4f-fg: #2b2f3a;
|
||||
--w4f-fg-2: #5a6072;
|
||||
--w4f-muted: #6b7280;
|
||||
--w4f-faint: #9ca3af;
|
||||
--w4f-card: rgba(255, 255, 255, 0.66);
|
||||
--w4f-card-solid: #ffffff;
|
||||
--w4f-card-2: rgba(255, 255, 255, 0.46);
|
||||
--w4f-line: rgba(91, 100, 120, 0.16);
|
||||
--w4f-line-2: rgba(91, 100, 120, 0.10);
|
||||
--w4f-line-3: rgba(91, 100, 120, 0.06);
|
||||
--w4f-line-strong: rgba(60, 70, 90, 0.22);
|
||||
|
||||
/* ---- background blobs ---- */
|
||||
--w4f-blob1: rgba(160, 170, 190, 0.30);
|
||||
--w4f-blob2: rgba(200, 210, 225, 0.28);
|
||||
--w4f-blob3: rgba(150, 160, 180, 0.22);
|
||||
|
||||
/* ---- shadows / radii / motion (theme-independent) ---- */
|
||||
--w4f-sh-sm: 0 1px 2px rgba(40, 50, 70, 0.06), inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
||||
--w4f-sh-md: 0 6px 20px rgba(40, 50, 70, 0.12);
|
||||
--w4f-sh-lg: 0 18px 46px rgba(40, 50, 70, 0.20);
|
||||
--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;
|
||||
|
||||
/* ---- accent-derived glow / chrome (theme-specific) ---- */
|
||||
--w4f-glow: rgba(91, 100, 120, 0.30);
|
||||
--w4f-danger-glow: rgba(214, 73, 95, 0.30);
|
||||
--w4f-glow-soft: rgba(91, 100, 120, 0.18);
|
||||
--w4f-thumb: rgba(120, 130, 150, 0.40);
|
||||
--w4f-thumb-hover: rgba(91, 100, 120, 0.60);
|
||||
--w4f-selection: #cdd4e0;
|
||||
|
||||
/* ---- Element Plus variable overrides (concrete hex — EP needs real
|
||||
* colors for its color-mix / opacity helpers, not var() references). ---- */
|
||||
--el-color-primary: #5b6478;
|
||||
--el-color-primary-light-3: #a7b1c3;
|
||||
--el-color-primary-light-5: #b9c1cf;
|
||||
--el-color-primary-light-7: #cdd4e0;
|
||||
--el-color-primary-light-8: #e3e7ef;
|
||||
--el-color-primary-light-9: #f1f3f7;
|
||||
--el-color-primary-dark-2: #3f4654;
|
||||
--el-color-success: #17a964;
|
||||
--el-color-warning: #b7791f;
|
||||
--el-color-danger: #d6495f;
|
||||
--el-color-info: #6b7280;
|
||||
--el-border-radius-base: 12px;
|
||||
--el-border-radius-small: 8px;
|
||||
--el-font-family: var(--w4f-font);
|
||||
|
||||
/* ---- semantic tint sets (ok/warning are constant across all themes —
|
||||
* defined once in :root so canvas node cards stay semantic-green/amber in
|
||||
* every theme while their SURFACES follow the active theme's card/fg).
|
||||
* color-mix blends the semantic hue into the live surface/text so tints
|
||||
* adapt when the theme changes the underlying --w4f-card-solid / --w4f-fg. */
|
||||
--w4f-ok-50: color-mix(in srgb, var(--w4f-ok) 7%, var(--w4f-card-solid));
|
||||
--w4f-ok-100: color-mix(in srgb, var(--w4f-ok) 13%, var(--w4f-card-solid));
|
||||
--w4f-ok-border: color-mix(in srgb, var(--w4f-ok) 40%, transparent);
|
||||
--w4f-ok-glow: color-mix(in srgb, var(--w4f-ok) 20%, transparent);
|
||||
--w4f-ok-text: color-mix(in srgb, var(--w4f-ok) 48%, var(--w4f-fg));
|
||||
--w4f-warn-50: color-mix(in srgb, var(--w4f-warning) 9%, var(--w4f-card-solid));
|
||||
--w4f-warn-100: color-mix(in srgb, var(--w4f-warning) 15%, var(--w4f-card-solid));
|
||||
--w4f-warn-border: color-mix(in srgb, var(--w4f-warning) 42%, transparent);
|
||||
--w4f-warn-glow: color-mix(in srgb, var(--w4f-warning) 20%, transparent);
|
||||
--w4f-warn-text: color-mix(in srgb, var(--w4f-warning) 52%, var(--w4f-fg));
|
||||
}
|
||||
|
||||
/* ---- blue theme ---- */
|
||||
[data-theme='blue'] {
|
||||
--w4f-primary: #3b82f6;
|
||||
--w4f-primary-h: #2563eb;
|
||||
--w4f-primary-50: #eff6ff;
|
||||
--w4f-primary-100: #dbeafe;
|
||||
--w4f-primary-200: #bfdbfe;
|
||||
--w4f-primary-300: #93c5fd;
|
||||
--w4f-secondary: #06b6d4;
|
||||
--w4f-secondary-h: #0891b2;
|
||||
--w4f-secondary-50: #ecfeff;
|
||||
--w4f-secondary-200: #a5f3fc;
|
||||
--w4f-danger: #ef4444;
|
||||
--w4f-danger-50: #fee2e2;
|
||||
--w4f-warning: #b7791f;
|
||||
--w4f-warning-50: #fdf3e3;
|
||||
--w4f-info: #3f6ef5;
|
||||
--w4f-ok: #17a964;
|
||||
|
||||
--w4f-bg-1: #eff6ff;
|
||||
--w4f-bg-2: #ffffff;
|
||||
--w4f-bg-3: #dbeafe;
|
||||
--w4f-bg-soft: #f5f9ff;
|
||||
--w4f-bg-muted: #eff6ff;
|
||||
--w4f-bg-hover: #e0ebfd;
|
||||
--w4f-bg-active: #dbeafe;
|
||||
--w4f-fg: #1e293b;
|
||||
--w4f-fg-2: #475569;
|
||||
--w4f-muted: #64748b;
|
||||
--w4f-faint: #94a3b8;
|
||||
--w4f-card: rgba(255, 255, 255, 0.66);
|
||||
--w4f-card-solid: #ffffff;
|
||||
--w4f-card-2: rgba(255, 255, 255, 0.46);
|
||||
--w4f-line: rgba(59, 130, 246, 0.16);
|
||||
--w4f-line-2: rgba(59, 130, 246, 0.10);
|
||||
--w4f-line-3: rgba(59, 130, 246, 0.06);
|
||||
--w4f-line-strong: rgba(30, 64, 175, 0.22);
|
||||
|
||||
--w4f-blob1: rgba(59, 130, 246, 0.40);
|
||||
--w4f-blob2: rgba(6, 182, 212, 0.36);
|
||||
--w4f-blob3: rgba(99, 102, 241, 0.26);
|
||||
|
||||
--w4f-sh-sm: 0 1px 2px rgba(30, 58, 138, 0.06), inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
||||
--w4f-sh-md: 0 6px 20px rgba(30, 58, 138, 0.13);
|
||||
--w4f-sh-lg: 0 18px 46px rgba(30, 58, 138, 0.22);
|
||||
|
||||
--w4f-glow: rgba(59, 130, 246, 0.32);
|
||||
--w4f-danger-glow: rgba(239, 68, 68, 0.30);
|
||||
--w4f-glow-soft: rgba(59, 130, 246, 0.18);
|
||||
--w4f-thumb: rgba(59, 130, 246, 0.40);
|
||||
--w4f-thumb-hover: rgba(37, 99, 235, 0.60);
|
||||
--w4f-selection: #bfdbfe;
|
||||
|
||||
--el-color-primary: #3b82f6;
|
||||
--el-color-primary-light-3: #93c5fd;
|
||||
--el-color-primary-light-5: #a5c8fb;
|
||||
--el-color-primary-light-7: #bfdbfe;
|
||||
--el-color-primary-light-8: #d6e8fd;
|
||||
--el-color-primary-light-9: #eff6ff;
|
||||
--el-color-primary-dark-2: #2563eb;
|
||||
--el-color-success: #17a964;
|
||||
--el-color-warning: #b7791f;
|
||||
--el-color-danger: #ef4444;
|
||||
--el-color-info: #64748b;
|
||||
}
|
||||
|
||||
/* ---- pink theme (sakura × frost — the original ModelRouter palette) ---- */
|
||||
[data-theme='pink'] {
|
||||
--w4f-primary: #FF7FAC;
|
||||
--w4f-primary-h: #F33B7C;
|
||||
--w4f-primary-50: #FFF0F5;
|
||||
@ -31,43 +202,40 @@
|
||||
--w4f-info: #3f6ef5;
|
||||
--w4f-ok: #17a964;
|
||||
|
||||
/* ---- surfaces / text ---- */
|
||||
--w4f-bg-1: #eef2ff;
|
||||
--w4f-bg-2: #ffffff;
|
||||
--w4f-bg-3: #ffe9f0;
|
||||
--w4f-bg-soft: #f8f6fb;
|
||||
--w4f-bg-muted: #f4f1f7;
|
||||
--w4f-bg-hover: #f5eef3;
|
||||
--w4f-bg-active: #ffe4e9;
|
||||
--w4f-fg: #3b3350;
|
||||
--w4f-fg-2: #5a5470;
|
||||
--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-2: rgba(255, 127, 172, 0.12);
|
||||
--w4f-line-3: rgba(255, 127, 172, 0.06);
|
||||
--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). ---- */
|
||||
--w4f-glow: rgba(255, 127, 172, 0.35);
|
||||
--w4f-danger-glow: rgba(219, 54, 148, 0.30);
|
||||
--w4f-glow-soft: rgba(255, 127, 172, 0.18);
|
||||
--w4f-thumb: rgba(255, 182, 193, 0.45);
|
||||
--w4f-thumb-hover: rgba(255, 127, 172, 0.7);
|
||||
--w4f-selection: #ffcdba;
|
||||
|
||||
--el-color-primary: #FF7FAC;
|
||||
--el-color-primary-light-3: #FF9EB5;
|
||||
--el-color-primary-light-5: #ffb3c4;
|
||||
@ -79,9 +247,6 @@
|
||||
--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,
|
||||
@ -102,7 +267,7 @@ body {
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
::selection { background: #ffcdba; color: #fff; }
|
||||
::selection { background: var(--w4f-selection); color: #fff; }
|
||||
a { color: var(--w4f-primary-h); }
|
||||
|
||||
/* ---- animated gradient-mesh background ----
|
||||
@ -227,21 +392,21 @@ a { color: var(--w4f-primary-h); }
|
||||
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:hover { background: linear-gradient(135deg, var(--w4f-primary-h), var(--w4f-danger)); transform: translateY(-1px); box-shadow: 0 8px 20px var(--w4f-glow); }
|
||||
.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.danger:hover { background: var(--w4f-danger); color: #fff; box-shadow: 0 8px 20px var(--w4f-danger-glow); 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); }
|
||||
*::-webkit-scrollbar-thumb { background: var(--w4f-thumb); border-radius: 4px; transition: background 0.3s; }
|
||||
*::-webkit-scrollbar-thumb:hover { background: var(--w4f-thumb-hover); }
|
||||
|
||||
/* ---- Element Plus dialog tweaks to read as glass ---- */
|
||||
.el-overlay { backdrop-filter: blur(3px); }
|
||||
|
||||
@ -1,55 +1,57 @@
|
||||
// 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.
|
||||
// These are thin passthroughs to the runtime CSS custom-property tokens
|
||||
// defined in theme.css (--w4f-*). They are auto-injected into every
|
||||
// <style lang="scss"> block via vite.config.ts additionalData, so every view
|
||||
// follows the active theme (white default / blue / pink) without per-view
|
||||
// rewrites. There is NO SCSS color math on these (no lighten/darken/mix), so
|
||||
// a var() reference is safe at compile time and resolves to the live theme
|
||||
// color in the browser.
|
||||
//
|
||||
// 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: #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;
|
||||
// Semantic note: green = online/ok (as in ModelRouter). The primary accent is
|
||||
// theme-driven; online/ok stays green in every theme.
|
||||
$color-text-primary: var(--w4f-fg);
|
||||
$color-text-secondary: var(--w4f-fg-2);
|
||||
$color-text-muted: var(--w4f-muted);
|
||||
$color-text-light: var(--w4f-faint);
|
||||
$color-bg-primary: var(--w4f-bg-2);
|
||||
$color-bg-secondary: var(--w4f-bg-soft);
|
||||
$color-bg-tertiary: var(--w4f-bg-1);
|
||||
$color-bg-muted: var(--w4f-bg-muted);
|
||||
$color-bg-hover: var(--w4f-bg-hover);
|
||||
$color-bg-active: var(--w4f-bg-active);
|
||||
$color-border: var(--w4f-line-strong);
|
||||
$color-border-light: var(--w4f-line);
|
||||
$color-border-lighter: var(--w4f-line-2);
|
||||
$color-border-extra-light: var(--w4f-line-3);
|
||||
|
||||
// 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;
|
||||
// Accents — identity follows the active theme (online/ok stays green).
|
||||
$color-primary: var(--w4f-primary);
|
||||
$color-primary-h: var(--w4f-primary-h);
|
||||
$color-primary-50: var(--w4f-primary-50);
|
||||
$color-primary-100: var(--w4f-primary-100);
|
||||
$color-secondary: var(--w4f-secondary);
|
||||
$color-secondary-h: var(--w4f-secondary-h);
|
||||
$color-secondary-50: var(--w4f-secondary-50);
|
||||
$color-success: var(--w4f-ok);
|
||||
$color-warning: var(--w4f-warning);
|
||||
$color-warning-50: var(--w4f-warning-50);
|
||||
$color-danger: var(--w4f-danger);
|
||||
$color-danger-50: var(--w4f-danger-50);
|
||||
$color-info: var(--w4f-info);
|
||||
// Primary action button follows the theme accent.
|
||||
$color-btn-primary: var(--w4f-primary);
|
||||
$color-btn-primary-hover: var(--w4f-primary-h);
|
||||
|
||||
// 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;
|
||||
// Glass surfaces / shadows (opt-in from views; see theme.css for the runtime
|
||||
// custom properties --w4f-*).
|
||||
$color-glass-bg: var(--w4f-card);
|
||||
$color-glass-bg-2: var(--w4f-card-2);
|
||||
$color-glass-border: var(--w4f-line);
|
||||
$shadow-sm: var(--w4f-sh-sm);
|
||||
$shadow-md: var(--w4f-sh-md);
|
||||
$shadow-lg: var(--w4f-sh-lg);
|
||||
$radius-glass: var(--w4f-glass);
|
||||
|
||||
$font-size-xs: 12px;
|
||||
$font-size-sm: 13px;
|
||||
@ -70,4 +72,4 @@ $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);
|
||||
$ease-spring: var(--w4f-ease-spring, cubic-bezier(0.34, 1.56, 0.64, 1));
|
||||
|
||||
116
web/src/types.ts
116
web/src/types.ts
@ -74,6 +74,13 @@ export interface Link {
|
||||
remotePort: number;
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
// group: management label for one-click group start/stop on the forwards
|
||||
// page (unrelated to frps load-balancing lbGroup on Local).
|
||||
group?: string;
|
||||
// disabled: per-forward stop flag. A disabled forward is omitted from the
|
||||
// frpc config (local) / left out of the ring topology (cluster), so stop is
|
||||
// per-card and survives canvas re-saves.
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface CanvasData {
|
||||
@ -130,6 +137,28 @@ export interface StatusResp {
|
||||
binaryPath: string;
|
||||
profiles: StatusProfile[];
|
||||
localStatus: LocalStatus[];
|
||||
// forwards: the forward-centric list rendered as cards on the status page.
|
||||
forwards: ForwardStatus[];
|
||||
// selfId: this node's ring ID (== SelfAddr), to tell whether a cluster
|
||||
// forward's owner is this node (worker runs here) or a peer.
|
||||
selfId: string;
|
||||
}
|
||||
|
||||
// ForwardStatus is one forward (link) in the forward-centric status view.
|
||||
// kind distinguishes 本地转发 (localOnly) from 远程转发 (cluster-distributed).
|
||||
export interface ForwardStatus {
|
||||
local: string;
|
||||
remote: string;
|
||||
remotePort: number;
|
||||
localOnly: boolean;
|
||||
kind: "local" | "remote";
|
||||
ownerId?: string; // remote = topology owner; local = selfId
|
||||
active: boolean; // remote = in topology; local = local worker running
|
||||
disabled: boolean; // per-forward stop flag (true = stopped)
|
||||
group?: string;
|
||||
localIp?: string;
|
||||
localPort?: number;
|
||||
localProto?: string;
|
||||
}
|
||||
|
||||
export interface LocalTargetStatus {
|
||||
@ -182,7 +211,7 @@ export interface RingNode {
|
||||
addr: string;
|
||||
isLeader?: boolean;
|
||||
alive: boolean;
|
||||
load: { memPct: number; netPct: number };
|
||||
load: { memPct: number; netPct: number; forwards?: number };
|
||||
version?: string;
|
||||
cache?: string[];
|
||||
lastSeen?: number;
|
||||
@ -211,6 +240,7 @@ export interface RingLogEntry {
|
||||
node: string;
|
||||
kind: string;
|
||||
at?: number;
|
||||
data?: Record<string, any> | null;
|
||||
}
|
||||
|
||||
export interface RingSnapshot {
|
||||
@ -223,4 +253,88 @@ export interface RingSnapshot {
|
||||
pending: RingTaskInfo[];
|
||||
topology: RingTopoEntry[];
|
||||
log: RingLogEntry[];
|
||||
// nodeKey: this node's cluster admission key. Newcomers must present it to
|
||||
// join via this node (handleClusterJoin verifies). Shown on the cluster page
|
||||
// for the operator to copy; omitted for non-cluster (standalone) nodes.
|
||||
nodeKey?: string;
|
||||
}
|
||||
|
||||
// ---- Auth / accounts / API keys (M7) ----
|
||||
|
||||
export type AccessLevel = 'read' | 'write' | 'admin';
|
||||
|
||||
// MeResp is the authenticated principal (GET /me). The frontend uses level to
|
||||
// gate the UI: viewer (= auditors) sees read + export controls only.
|
||||
export interface MeResp {
|
||||
type: 'user' | 'service' | 'key';
|
||||
name: string;
|
||||
level: AccessLevel;
|
||||
userId?: number;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
role: 'admin' | 'viewer';
|
||||
enabled: boolean;
|
||||
system?: boolean; // flag-synced built-in account (UI read-only)
|
||||
createdAt: number;
|
||||
lastLoginAt: number;
|
||||
}
|
||||
|
||||
export interface ApiKey {
|
||||
id: number;
|
||||
userId: number;
|
||||
prefix: string;
|
||||
label: string;
|
||||
scope: AccessLevel;
|
||||
createdAt: number;
|
||||
lastUsedAt: number;
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
// ApiKeyCreated is the one-time response from POST /apikeys: the plaintext key
|
||||
// is shown exactly once and never retrievable again.
|
||||
export interface ApiKeyCreated {
|
||||
id: number;
|
||||
userId: number;
|
||||
label: string;
|
||||
scope: AccessLevel;
|
||||
prefix: string;
|
||||
createdAt: number;
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface CanvasExportEnvelope {
|
||||
_type: 'webui4frpc-canvas';
|
||||
version: number;
|
||||
exportedAt: number;
|
||||
exporter: string;
|
||||
canvas: CanvasData;
|
||||
}
|
||||
|
||||
// Worker-log bundle (GET /cluster/logs/export): one entry per alive ring node.
|
||||
export interface WorkerLogEntry {
|
||||
name: string;
|
||||
remote: string;
|
||||
state: string;
|
||||
lines: string;
|
||||
}
|
||||
export interface NodeLogsResp {
|
||||
node: string;
|
||||
workers: WorkerLogEntry[];
|
||||
}
|
||||
export interface ClusterWorkerLogNode {
|
||||
id: string;
|
||||
addr: string;
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
logs?: NodeLogsResp;
|
||||
}
|
||||
export interface WorkerLogBundle {
|
||||
_type: 'webui4frpc-worker-logs';
|
||||
version: number;
|
||||
exportedAt: number;
|
||||
requester: string;
|
||||
nodes: ClusterWorkerLogNode[];
|
||||
}
|
||||
|
||||
@ -10,6 +10,11 @@
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<span class="save-hint">{{ dirty ? '● 未保存' : '已保存' }}</span>
|
||||
<button class="btn ghost" @click="exportCanvas">导出</button>
|
||||
<button class="btn ghost" :disabled="!canWrite" @click="pickImport" title="导入转发表(覆盖当前)">
|
||||
导入
|
||||
</button>
|
||||
<input ref="importInput" type="file" accept=".json,application/json" class="hidden-input" @change="onImportFile" />
|
||||
<button class="btn save" :disabled="saving" @click="save">
|
||||
保存配置
|
||||
</button>
|
||||
@ -32,7 +37,7 @@
|
||||
@pane-click="deselectAll"
|
||||
@edge-click="onEdgeClick"
|
||||
>
|
||||
<Background pattern-color="#cfd8dc" :gap="20" />
|
||||
<Background :pattern-color="patternColor" :gap="20" />
|
||||
|
||||
<template #node-local="slotProps">
|
||||
<LocalNode
|
||||
@ -58,6 +63,8 @@
|
||||
:conflicted="isEdgeConflicted((edgeProps as any).id)"
|
||||
@label-click="onEdgeLabelClick"
|
||||
@label-drag="onLabelDrag"
|
||||
@toggle-disabled="onToggleDisabled"
|
||||
@edit-group="onEditGroup"
|
||||
/>
|
||||
</template>
|
||||
</VueFlow>
|
||||
@ -87,6 +94,14 @@
|
||||
@keyup.enter="confirmPort"
|
||||
/>
|
||||
</div>
|
||||
<div class="dlg-row">
|
||||
<label>分组</label>
|
||||
<el-input
|
||||
v-model="groupInput"
|
||||
placeholder="可选,用于转发页一键启停整组"
|
||||
@keyup.enter="confirmPort"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<button class="btn" @click="portDlg.visible = false">取消</button>
|
||||
<button class="btn save" @click="confirmPort">确定</button>
|
||||
@ -98,21 +113,35 @@
|
||||
<script setup lang="ts">
|
||||
import '@vue-flow/core/dist/style.css'
|
||||
import '@vue-flow/core/dist/theme-default.css'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { VueFlow } from '@vue-flow/core'
|
||||
import { Background } from '@vue-flow/background'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { Edge, Connection } from '@vue-flow/core'
|
||||
import type { CanvasLocal, CanvasRemote, CanvasLink } from '../types'
|
||||
import type { CanvasLocal, CanvasRemote, CanvasLink, CanvasData, CanvasExportEnvelope } from '../types'
|
||||
import LocalNode from '../components/LocalNode.vue'
|
||||
import RemoteNode from '../components/RemoteNode.vue'
|
||||
import PortEdge from '../components/PortEdge.vue'
|
||||
import { api } from '../api'
|
||||
import { canWrite } from '../auth'
|
||||
|
||||
const nodes = ref<any[]>([])
|
||||
const edges = ref<Edge[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
// Background dot-pattern color, resolved from the live theme var so the grid
|
||||
// recolors on theme switch (vue-flow Background renders pattern-color as an
|
||||
// SVG fill attribute, which can't resolve var() directly — read the computed
|
||||
// value and refresh it when <html data-theme> changes).
|
||||
const patternColor = ref('rgba(120,130,150,0.22)')
|
||||
let themeObserver: MutationObserver | null = null
|
||||
const resolvePatternColor = () => {
|
||||
const v = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--w4f-line-strong')
|
||||
.trim()
|
||||
patternColor.value = v || 'rgba(120,130,150,0.22)'
|
||||
}
|
||||
|
||||
// ---- canvas conflict validation ----
|
||||
interface CanvasConflict {
|
||||
type: 'port' | 'domain'
|
||||
@ -140,11 +169,14 @@ const portDlg = ref({
|
||||
local: '',
|
||||
remote: '',
|
||||
remotePort: 0,
|
||||
group: '',
|
||||
pendingEdge: null as Edge | null,
|
||||
})
|
||||
|
||||
// portInput is the plain text control for the remote port in the dialog.
|
||||
const portInput = ref('')
|
||||
// groupInput is the management-group text control (forwards page grouping).
|
||||
const groupInput = ref('')
|
||||
|
||||
// openPortDlg opens the port editor dialog, seeding the input.
|
||||
const openPortDlg = (
|
||||
@ -153,6 +185,7 @@ const openPortDlg = (
|
||||
port: number,
|
||||
isNew: boolean,
|
||||
pending?: Edge | null,
|
||||
group = '',
|
||||
) => {
|
||||
portDlg.value = {
|
||||
visible: true,
|
||||
@ -160,9 +193,43 @@ const openPortDlg = (
|
||||
local,
|
||||
remote,
|
||||
remotePort: port,
|
||||
group,
|
||||
pendingEdge: pending ?? null,
|
||||
}
|
||||
portInput.value = String(port)
|
||||
groupInput.value = group
|
||||
}
|
||||
|
||||
// ---- auto-layout ----
|
||||
// Collapsed cards render ~200px tall (the localOnly hint wraps to several
|
||||
// lines), so the old 140/160 vertical step made neighbours overlap. EST_* is
|
||||
// the fallback used before the DOM has measured a node (initial load / add);
|
||||
// autoLayout reads the real offsetHeight so expanded advanced sections stack
|
||||
// correctly too. offsetHeight ignores the viewport zoom transform, so the
|
||||
// measurement is zoom-independent.
|
||||
const LAYOUT = { localX: 60, remoteX: 760, startY: 90, gap: 24, estLocal: 210, estRemote: 200 }
|
||||
|
||||
const measuredHeightOf = (id: string): number => {
|
||||
const el = document.querySelector<HTMLElement>(`.vue-flow__node[data-id="${id}"]`)
|
||||
return el ? el.offsetHeight : 0
|
||||
}
|
||||
|
||||
// layoutColumns stacks local nodes in the left column and remote nodes in the
|
||||
// right column, each offset by its height + gap so cards never overlap.
|
||||
const layoutColumns = (useMeasured: boolean) => {
|
||||
let ly = LAYOUT.startY
|
||||
let ry = LAYOUT.startY
|
||||
for (const n of nodes.value) {
|
||||
if (n.type === 'local') {
|
||||
const h = (useMeasured && measuredHeightOf(n.id)) || LAYOUT.estLocal
|
||||
n.position = { x: LAYOUT.localX, y: ly }
|
||||
ly += h + LAYOUT.gap
|
||||
} else {
|
||||
const h = (useMeasured && measuredHeightOf(n.id)) || LAYOUT.estRemote
|
||||
n.position = { x: LAYOUT.remoteX, y: ry }
|
||||
ry += h + LAYOUT.gap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const localOf = (name: string) =>
|
||||
@ -259,8 +326,8 @@ const addLocal = () => {
|
||||
id: nodeId('local', name),
|
||||
type: 'local',
|
||||
position: {
|
||||
x: 60,
|
||||
y: 90 + nodes.value.filter((n) => n.type === 'local').length * 140,
|
||||
x: LAYOUT.localX,
|
||||
y: LAYOUT.startY + nodes.value.filter((n) => n.type === 'local').length * (LAYOUT.estLocal + LAYOUT.gap),
|
||||
},
|
||||
data,
|
||||
})
|
||||
@ -281,8 +348,8 @@ const addRemote = () => {
|
||||
id: nodeId('remote', name),
|
||||
type: 'remote',
|
||||
position: {
|
||||
x: 760,
|
||||
y: 90 + nodes.value.filter((n) => n.type === 'remote').length * 160,
|
||||
x: LAYOUT.remoteX,
|
||||
y: LAYOUT.startY + nodes.value.filter((n) => n.type === 'remote').length * (LAYOUT.estRemote + LAYOUT.gap),
|
||||
},
|
||||
data,
|
||||
})
|
||||
@ -338,6 +405,7 @@ const confirmPort = () => {
|
||||
return
|
||||
}
|
||||
d.remotePort = parsed
|
||||
d.group = groupInput.value.trim()
|
||||
if (d.isNew && d.pendingEdge) {
|
||||
const newId = edgeId(
|
||||
d.pendingEdge.source,
|
||||
@ -347,6 +415,7 @@ const confirmPort = () => {
|
||||
const existing = edges.value.find((e) => e.id === newId)
|
||||
if (existing) {
|
||||
existing.label = String(d.remotePort)
|
||||
existing.data = { ...(existing.data as object), group: d.group }
|
||||
} else {
|
||||
edges.value.push({
|
||||
id: newId,
|
||||
@ -355,7 +424,7 @@ const confirmPort = () => {
|
||||
label: String(d.remotePort),
|
||||
type: 'portedge',
|
||||
animated: false,
|
||||
data: {},
|
||||
data: { group: d.group },
|
||||
})
|
||||
assignLayers()
|
||||
}
|
||||
@ -373,6 +442,7 @@ const confirmPort = () => {
|
||||
old.id = newId
|
||||
}
|
||||
old.label = String(d.remotePort)
|
||||
old.data = { ...(old.data as object), group: d.group }
|
||||
}
|
||||
portDlg.value.visible = false
|
||||
dirty.value = true
|
||||
@ -392,6 +462,7 @@ const editEdge = (edge: Edge) => {
|
||||
Number(edge.label) || (loc ? loc.port : 0),
|
||||
false,
|
||||
edge,
|
||||
(edge.data as any)?.group ?? '',
|
||||
)
|
||||
}
|
||||
|
||||
@ -417,6 +488,49 @@ const onLabelDrag = ({
|
||||
}
|
||||
}
|
||||
|
||||
// onToggleDisabled flips the edge's per-forward stop flag from the canvas
|
||||
// edge badge. A disabled forward is omitted from the frpc config (local) /
|
||||
// left out of the ring topology (cluster). Marks the canvas dirty so save
|
||||
// persists the toggle. The "禁/通" badge on the edge is the quick entry;
|
||||
// the full port editor also carries a disabled checkbox.
|
||||
const onToggleDisabled = ({ edgeId }: { edgeId: string }) => {
|
||||
const e = edges.value.find((x) => x.id === edgeId)
|
||||
if (!e) return
|
||||
e.data = { ...(e.data as object), disabled: !((e.data as any)?.disabled) }
|
||||
dirty.value = true
|
||||
}
|
||||
|
||||
// onEditGroup opens a prompt to set the edge's group label (a management
|
||||
// label for one-click group start/stop on the status page; unrelated to
|
||||
// frps load-balancing lbGroup). Lists existing group names for quick
|
||||
// selection; empty input clears the group (moves the forward to 未分组).
|
||||
const onEditGroup = async ({ edgeId }: { edgeId: string }) => {
|
||||
const e = edges.value.find((x) => x.id === edgeId)
|
||||
if (!e) return
|
||||
const existing = new Set<string>()
|
||||
for (const ed of edges.value) {
|
||||
const g = (ed.data as any)?.group ?? ''
|
||||
if (g) existing.add(g)
|
||||
}
|
||||
const hint = existing.size
|
||||
? `现有分组:${[...existing].join('、')}(留空=移出分组)`
|
||||
: '输入分组名称(留空=移出分组)'
|
||||
const cur = (e.data as any)?.group ?? ''
|
||||
try {
|
||||
const { value } = await ElMessageBox.prompt(hint, '编辑分组', {
|
||||
inputValue: cur,
|
||||
inputPlaceholder: '分组名称',
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
const g = (value || '').trim()
|
||||
e.data = { ...(e.data as object), group: g }
|
||||
dirty.value = true
|
||||
} catch {
|
||||
// user cancelled — no change
|
||||
}
|
||||
}
|
||||
|
||||
// assignLayers re-computes the layer (per-pair index) of every edge so custom
|
||||
// port edges bend away from their siblings. Returns the same array mutated in
|
||||
// place; also used to keep ordering consistent.
|
||||
@ -491,19 +605,7 @@ const remoteNodes = computed(() =>
|
||||
nodes.value.filter((n) => n.type === 'remote'),
|
||||
)
|
||||
|
||||
const autoLayout = () => {
|
||||
let li = 0
|
||||
let ri = 0
|
||||
for (const n of nodes.value) {
|
||||
if (n.type === 'local') {
|
||||
n.position = { x: 60, y: 90 + li * 140 }
|
||||
li++
|
||||
} else {
|
||||
n.position = { x: 760, y: 90 + ri * 160 }
|
||||
ri++
|
||||
}
|
||||
}
|
||||
}
|
||||
const autoLayout = () => layoutColumns(true)
|
||||
|
||||
const load = async () => {
|
||||
loading.value = true
|
||||
@ -511,26 +613,23 @@ const load = async () => {
|
||||
const data = await api.canvas()
|
||||
nodes.value = []
|
||||
edges.value = []
|
||||
let li = 0
|
||||
let ri = 0
|
||||
for (const l of data.locals || []) {
|
||||
nodes.value.push({
|
||||
id: nodeId('local', l.name),
|
||||
type: 'local',
|
||||
position: { x: 60, y: 90 + li * 140 },
|
||||
position: { x: LAYOUT.localX, y: LAYOUT.startY },
|
||||
data: { ...l },
|
||||
})
|
||||
li++
|
||||
}
|
||||
for (const r of data.remotes || []) {
|
||||
nodes.value.push({
|
||||
id: nodeId('remote', r.name),
|
||||
type: 'remote',
|
||||
position: { x: 760, y: 90 + ri * 160 },
|
||||
position: { x: LAYOUT.remoteX, y: LAYOUT.startY },
|
||||
data: { ...r },
|
||||
})
|
||||
ri++
|
||||
}
|
||||
layoutColumns(false)
|
||||
for (const link of data.links || []) {
|
||||
const s = nodeId('local', link.local)
|
||||
const t = nodeId('remote', link.remote)
|
||||
@ -547,7 +646,12 @@ const load = async () => {
|
||||
label: String(rp),
|
||||
type: 'portedge',
|
||||
animated: false,
|
||||
data: { offsetX: link.offsetX ?? 0, offsetY: link.offsetY ?? 0 },
|
||||
data: {
|
||||
offsetX: link.offsetX ?? 0,
|
||||
offsetY: link.offsetY ?? 0,
|
||||
group: link.group ?? '',
|
||||
disabled: link.disabled ?? false,
|
||||
},
|
||||
})
|
||||
}
|
||||
assignLayers()
|
||||
@ -575,6 +679,8 @@ const buildPayload = () => {
|
||||
remotePort: isNaN(rp) || rp <= 0 ? 0 : rp,
|
||||
offsetX: (e.data as any)?.offsetX ?? 0,
|
||||
offsetY: (e.data as any)?.offsetY ?? 0,
|
||||
group: (e.data as any)?.group ?? '',
|
||||
disabled: (e.data as any)?.disabled ?? false,
|
||||
})
|
||||
}
|
||||
return { locals, remotes, links }
|
||||
@ -597,7 +703,7 @@ const save = async () => {
|
||||
nodes.value.push({
|
||||
id,
|
||||
type: 'local',
|
||||
position: posOf.get(id) || { x: 60, y: 90 + li * 140 },
|
||||
position: posOf.get(id) || { x: LAYOUT.localX, y: LAYOUT.startY + li * (LAYOUT.estLocal + LAYOUT.gap) },
|
||||
data: { ...l },
|
||||
})
|
||||
li++
|
||||
@ -607,7 +713,7 @@ const save = async () => {
|
||||
nodes.value.push({
|
||||
id,
|
||||
type: 'remote',
|
||||
position: posOf.get(id) || { x: 760, y: 90 + ri * 160 },
|
||||
position: posOf.get(id) || { x: LAYOUT.remoteX, y: LAYOUT.startY + ri * (LAYOUT.estRemote + LAYOUT.gap) },
|
||||
data: { ...r },
|
||||
})
|
||||
ri++
|
||||
@ -623,7 +729,12 @@ const save = async () => {
|
||||
target: t,
|
||||
label: String(rp),
|
||||
type: 'portedge',
|
||||
data: { offsetX: link.offsetX ?? 0, offsetY: link.offsetY ?? 0 },
|
||||
data: {
|
||||
offsetX: link.offsetX ?? 0,
|
||||
offsetY: link.offsetY ?? 0,
|
||||
group: link.group ?? '',
|
||||
disabled: link.disabled ?? false,
|
||||
},
|
||||
})
|
||||
}
|
||||
assignLayers()
|
||||
@ -636,7 +747,79 @@ const save = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
// ---- canvas export / import (转发表 备份/还原) ----
|
||||
const importInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// exportCanvas downloads the full canvas as a metadata-wrapped JSON bundle.
|
||||
// Read-level: auditors can export the current table at any time.
|
||||
const exportCanvas = async () => {
|
||||
try {
|
||||
const env = await api.exportCanvas()
|
||||
const blob = new Blob([JSON.stringify(env, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `webui4frpc-canvas-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '')}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
ElMessage.success('已导出转发表')
|
||||
} catch (e: any) {
|
||||
ElMessage.error('导出失败: ' + (e.message || e))
|
||||
}
|
||||
}
|
||||
|
||||
const pickImport = () => {
|
||||
importInput.value?.click()
|
||||
}
|
||||
|
||||
// onImportFile reads a chosen JSON file (envelope or bare canvas), confirms the
|
||||
// overwrite, then POSTs it to /canvas/import (full-replace) and reloads.
|
||||
const onImportFile = async (ev: Event) => {
|
||||
const input = ev.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
try {
|
||||
const text = await file.text()
|
||||
const parsed = JSON.parse(text)
|
||||
// Accept either the { _type, canvas } envelope or a bare { locals, remotes, links }.
|
||||
const canvas: CanvasData | undefined =
|
||||
parsed?._type === 'webui4frpc-canvas' ? parsed.canvas : parsed?.locals ? parsed : undefined
|
||||
if (!canvas) {
|
||||
ElMessage.warning('文件格式不符:需为导出的信封或裸画布 JSON')
|
||||
return
|
||||
}
|
||||
await ElMessageBox.confirm(
|
||||
`导入将覆盖当前连接配置(${canvas.locals?.length ?? 0} 本地 / ${canvas.remotes?.length ?? 0} 远程 / ${canvas.links?.length ?? 0} 连线)。继续?`,
|
||||
'导入转发表',
|
||||
{ type: 'warning' },
|
||||
)
|
||||
await api.importCanvas(parsed as CanvasData | CanvasExportEnvelope)
|
||||
await load()
|
||||
ElMessage.success('已导入并应用')
|
||||
} catch (e: any) {
|
||||
if (e === 'cancel' || e?.action === 'cancel') return
|
||||
ElMessage.error('导入失败: ' + (e.message || e))
|
||||
} finally {
|
||||
// reset so picking the same file again re-fires change
|
||||
if (input) input.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
resolvePatternColor()
|
||||
themeObserver = new MutationObserver(resolvePatternColor)
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-theme'],
|
||||
})
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
themeObserver?.disconnect()
|
||||
themeObserver = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@ -692,24 +875,24 @@ onMounted(load)
|
||||
}
|
||||
|
||||
.btn.local-add {
|
||||
background: #4caf50;
|
||||
background: var(--w4f-ok);
|
||||
color: #fff;
|
||||
&:hover {
|
||||
background: #43a047;
|
||||
background: color-mix(in srgb, var(--w4f-ok) 82%, var(--w4f-fg));
|
||||
}
|
||||
}
|
||||
.btn.remote-add {
|
||||
background: #ff9800;
|
||||
background: var(--w4f-warning);
|
||||
color: #fff;
|
||||
&:hover {
|
||||
background: #f57c00;
|
||||
background: color-mix(in srgb, var(--w4f-warning) 82%, var(--w4f-fg));
|
||||
}
|
||||
}
|
||||
.btn.warn {
|
||||
background: #78909c;
|
||||
background: var(--w4f-secondary);
|
||||
color: #fff;
|
||||
&:hover {
|
||||
background: #607d8b;
|
||||
background: var(--w4f-secondary-h);
|
||||
}
|
||||
}
|
||||
.btn.save {
|
||||
@ -720,6 +903,29 @@ onMounted(load)
|
||||
}
|
||||
}
|
||||
|
||||
.btn.ghost {
|
||||
background: transparent;
|
||||
color: $color-text-muted;
|
||||
border: 1px solid $color-border;
|
||||
&:hover:not(:disabled) {
|
||||
background: $color-glass-bg-2;
|
||||
color: $color-text-primary;
|
||||
}
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.hidden-input {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.save-hint {
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-muted;
|
||||
@ -734,7 +940,7 @@ onMounted(load)
|
||||
.flow-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #f5f7fa;
|
||||
background: var(--w4f-bg-muted);
|
||||
}
|
||||
|
||||
|
||||
@ -761,8 +967,8 @@ onMounted(load)
|
||||
}
|
||||
|
||||
:deep(.vue-flow__edge-label) {
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
color: #fff;
|
||||
background: color-mix(in srgb, var(--w4f-fg) 75%, transparent);
|
||||
color: var(--w4f-bg-2);
|
||||
border-radius: 10px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
@ -773,7 +979,7 @@ onMounted(load)
|
||||
:deep(.vue-flow__handle) {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid #fff;
|
||||
border: 2px solid var(--w4f-card-solid);
|
||||
}
|
||||
|
||||
:deep(.vue-flow__node) {
|
||||
|
||||
@ -20,7 +20,7 @@
|
||||
<div class="hero w4f-card">
|
||||
<div class="hero-left">
|
||||
<h2 class="page-title">集群令牌环</h2>
|
||||
<span class="page-sub">令牌环网 · 单轮一周期 · 画布差异命令进令牌 · 增量日志同步</span>
|
||||
<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>
|
||||
@ -28,6 +28,14 @@
|
||||
<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>
|
||||
<!-- nodeKey: this node's cluster admission key. Other nodes must present it
|
||||
to join via this node. Shown here for the operator to copy. -->
|
||||
<div class="nodekey-bar" v-if="ring?.nodeKey">
|
||||
<span class="nk-label">本节点加入密钥</span>
|
||||
<code class="nk-value">{{ ring.nodeKey }}</code>
|
||||
<button class="w4f-btn ghost small" @click="copyNodeKey">复制</button>
|
||||
<span class="nk-hint">其他节点通过本节点加入集群时需提供此密钥</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPI row -->
|
||||
@ -40,17 +48,14 @@
|
||||
<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>
|
||||
|
||||
@ -72,7 +77,7 @@
|
||||
<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>
|
||||
<h3 class="section-title"><span class="k-ic">○</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 }">
|
||||
@ -97,7 +102,7 @@
|
||||
|
||||
<!-- 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>
|
||||
<h3 class="section-title"><span class="k-ic">⚙</span>令牌命令(待执行)</h3>
|
||||
<div class="task-list">
|
||||
<div v-for="t in ring.pending" :key="t.id" class="task-row" :class="taskClass(t)">
|
||||
<span class="tk-id">{{ t.id }}</span>
|
||||
@ -118,7 +123,7 @@
|
||||
|
||||
<!-- Active forward topology -->
|
||||
<section class="section w4f-card">
|
||||
<h3 class="section-title"><span class="k-ic">→</span>活跃转发拓扑<span class="hint">每个转发由 owner 节点持有 · localOnly 转发只在本节点可见</span></h3>
|
||||
<h3 class="section-title"><span class="k-ic">→</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>
|
||||
@ -135,12 +140,31 @@
|
||||
|
||||
<!-- Cluster log -->
|
||||
<section class="section w4f-card">
|
||||
<h3 class="section-title"><span class="k-ic">≣</span>集群日志(增量同步)<span class="hint">追加式,随令牌 delta 重放到全网一致</span></h3>
|
||||
<h3 class="section-title">
|
||||
<span class="k-ic">≣</span>集群日志
|
||||
<span class="log-count" v-if="ring.log.length">{{ ring.log.length }} 条</span>
|
||||
<div class="title-actions">
|
||||
<el-dropdown trigger="click" @command="exportLog" :disabled="!ring.log.length">
|
||||
<button class="w4f-btn ghost small" :disabled="!ring.log.length">⤓ 导出</button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="json">JSON(完整结构)</el-dropdown-item>
|
||||
<el-dropdown-item command="txt">文本(可读 TSV)</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<button class="w4f-btn ghost small" :disabled="workerLogBusy" @click="exportWorkerLogs">
|
||||
⤓ 导出 worker 日志
|
||||
</button>
|
||||
</div>
|
||||
</h3>
|
||||
<div class="log-list">
|
||||
<div v-for="(e, i) in logView" :key="i" class="log-row" :class="logKindClass(e.kind)">
|
||||
<div v-for="e in logView" :key="e.seq" class="log-row" :class="logKindClass(e.kind)">
|
||||
<span class="lg-seq">#{{ e.seq }}</span>
|
||||
<span class="lg-time">{{ formatTs(e.at) }}</span>
|
||||
<span class="lg-node" :class="{ self: e.node === ring.selfId }">{{ shortId(e.node) }}</span>
|
||||
<span class="lg-kind">{{ kindLabel(e.kind) }}</span>
|
||||
<span class="lg-detail">{{ detailOf(e) }}</span>
|
||||
</div>
|
||||
<div v-if="!logView.length" class="empty">日志为空</div>
|
||||
</div>
|
||||
@ -152,12 +176,16 @@
|
||||
<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" />
|
||||
<el-input v-model="joinDialog.addr" placeholder="对端 IP:port(如 192.168.1.10:7500)" @keyup.enter="onJoin" />
|
||||
</div>
|
||||
<div class="form-hint">本节点将向对端请求加入并采纳其环状态,令牌到达即并入环。</div>
|
||||
<div class="form-row">
|
||||
<label>加入密钥</label>
|
||||
<el-input v-model="joinDialog.key" placeholder="对端节点的加入密钥(nodeKey)" @keyup.enter="onJoin" />
|
||||
</div>
|
||||
<div class="form-hint">填入对端节点可被本机路由到达的真实 IP:port(或域名:port),本节点将向对端请求加入并采纳其环状态,令牌到达即并入环。需提供对端节点的加入密钥以通过验证。</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>
|
||||
<button class="w4f-btn small" :disabled="busy || !joinDialog.addr.trim() || !joinDialog.key.trim()" @click="onJoin">加入</button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
@ -167,13 +195,14 @@
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { api } from '../api'
|
||||
import type { RingSnapshot, RingTaskInfo } from '../types'
|
||||
import type { RingSnapshot, RingTaskInfo, RingLogEntry } from '../types'
|
||||
|
||||
const ring = ref<RingSnapshot | null>(null)
|
||||
const busy = ref(false)
|
||||
let timer: number | null = null
|
||||
let visHandler: (() => void) | null = null
|
||||
|
||||
const joinDialog = reactive({ visible: false, addr: '' })
|
||||
const joinDialog = reactive({ visible: false, addr: '', key: '' })
|
||||
|
||||
const load = async () => {
|
||||
try { ring.value = await api.ring() } catch { /* keep last */ }
|
||||
@ -181,6 +210,20 @@ const load = async () => {
|
||||
|
||||
const shortId = (id: string) => (id.length > 22 ? '…' + id.slice(-20) : id)
|
||||
|
||||
// copyNodeKey copies this node's admission key to the clipboard so the operator
|
||||
// can paste it into another node's "加入集群" dialog. Falls back to a select+
|
||||
// execCommand on browsers without async clipboard (non-secure contexts).
|
||||
const copyNodeKey = async () => {
|
||||
const key = ring.value?.nodeKey
|
||||
if (!key) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(key)
|
||||
ElMessage.success('已复制加入密钥')
|
||||
} catch {
|
||||
ElMessage.error('复制失败,请手动选中复制')
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 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)
|
||||
@ -249,6 +292,90 @@ const logKindClass = (k: string) => {
|
||||
}
|
||||
const logView = computed(() => [...(ring.value?.log ?? [])].reverse())
|
||||
|
||||
// ---- log formatting + export ----
|
||||
// formatTs renders the entry timestamp (unix sec) as HH:MM:SS (local tz) for
|
||||
// a compact log feed; the raw epoch is preserved in the exported JSON.
|
||||
const formatTs = (at?: number): string => {
|
||||
if (!at) return '--:--:--'
|
||||
const dt = new Date(at * 1000)
|
||||
const p = (n: number) => String(n).padStart(2, '0')
|
||||
return `${p(dt.getHours())}:${p(dt.getMinutes())}:${p(dt.getSeconds())}`
|
||||
}
|
||||
// detailOf derives a one-line human summary from each kind's payload (Data).
|
||||
// forward.add/remove → "local → remote · taskId"; node.join → "node @ addr";
|
||||
// node.leave → "node"; leader.change → "→ leader".
|
||||
const detailOf = (e: RingLogEntry): string => {
|
||||
const d = (e.data || {}) as Record<string, any>
|
||||
switch (e.kind) {
|
||||
case 'forward.add':
|
||||
case 'forward.remove':
|
||||
return `${d.local || '?'} → ${d.remote || '?'}${d.taskId ? ' · ' + String(d.taskId).slice(-8) : ''}`
|
||||
case 'node.join':
|
||||
return `${d.node || '?'} @ ${d.addr || '?'}`
|
||||
case 'node.leave':
|
||||
return `${d.node || '?'}`
|
||||
case 'leader.change':
|
||||
return `→ ${d.leader || '?'}`
|
||||
default:
|
||||
return d && Object.keys(d).length ? JSON.stringify(d) : ''
|
||||
}
|
||||
}
|
||||
// exportLog downloads the FULL cluster log (snapshot carries every entry) as
|
||||
// either pretty JSON (lossless) or a readable TSV text file, sorted by seq.
|
||||
const exportLog = (fmt: string) => {
|
||||
const entries = [...(ring.value?.log ?? [])].sort((a, b) => a.seq - b.seq)
|
||||
if (!entries.length) return
|
||||
let content = ''
|
||||
let mime = 'text/plain'
|
||||
let ext = 'log'
|
||||
if (fmt === 'json') {
|
||||
content = JSON.stringify(entries, null, 2)
|
||||
mime = 'application/json'
|
||||
ext = 'json'
|
||||
} else {
|
||||
const header = '#seq\ttime\tnode\tkind\tdetail'
|
||||
const lines = entries.map((e) =>
|
||||
`#${e.seq}\t${formatTs(e.at)}\t${shortId(e.node)}\t${e.kind}\t${detailOf(e)}`,
|
||||
)
|
||||
content = [header, ...lines].join('\n')
|
||||
}
|
||||
const blob = new Blob([content], { type: mime })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `cluster-log-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '')}.${ext}`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
// exportWorkerLogs fans out across the ring to gather every node's frpc worker
|
||||
// logs (the actual forwards running on each node) into one downloadable JSON.
|
||||
// Read-level: auditors use this to inspect worker output cluster-wide.
|
||||
const workerLogBusy = ref(false)
|
||||
const exportWorkerLogs = async () => {
|
||||
workerLogBusy.value = true
|
||||
try {
|
||||
const bundle = await api.exportWorkerLogs()
|
||||
const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `worker-logs-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '')}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
const reachable = (bundle.nodes || []).filter((n) => n.ok).length
|
||||
ElMessage.success(`已导出 worker 日志(${reachable}/${bundle.nodes?.length ?? 0} 节点)`)
|
||||
} catch (e: any) {
|
||||
ElMessage.error('导出失败: ' + (e.message || e))
|
||||
} finally {
|
||||
workerLogBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const taskClass = (t: RingTaskInfo) => (t.removeNode ? 'remove' : t.revoke ? 'revoke' : 'pending')
|
||||
const taskCmdLabel = (t: RingTaskInfo) => (t.removeNode ? '移除节点' : t.revoke ? '撤销' : '新增')
|
||||
|
||||
@ -267,11 +394,14 @@ const onCreate = async () => {
|
||||
}
|
||||
const onJoin = async () => {
|
||||
const addr = joinDialog.addr.trim()
|
||||
if (!addr) return
|
||||
const key = joinDialog.key.trim()
|
||||
if (!addr || !key) return
|
||||
busy.value = true
|
||||
try {
|
||||
ring.value = await api.clusterJoinRing(addr)
|
||||
ring.value = await api.clusterJoinRing(addr, key)
|
||||
joinDialog.visible = false
|
||||
joinDialog.addr = ''
|
||||
joinDialog.key = ''
|
||||
ElMessage.success(`已加入 ${addr}`)
|
||||
} catch (e: any) {
|
||||
ElMessage.error('加入失败: ' + (e.message || e))
|
||||
@ -310,12 +440,19 @@ const onRemoveNode = async (id: string) => {
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
timer = window.setInterval(load, 3000)
|
||||
// 2s cadence so the cluster ring (pending→topology transitions, worker
|
||||
// spawns) reflects near-real-time; visibilitychange reloads on tab refocus.
|
||||
timer = window.setInterval(load, 2000)
|
||||
tick = window.setInterval(() => { now.value = Date.now() }, 1000)
|
||||
visHandler = () => {
|
||||
if (!document.hidden) load()
|
||||
}
|
||||
document.addEventListener('visibilitychange', visHandler)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
if (timer !== null) { clearInterval(timer); timer = null }
|
||||
if (tick !== null) { clearInterval(tick); tick = null }
|
||||
if (visHandler) { document.removeEventListener('visibilitychange', visHandler); visHandler = null }
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -331,6 +468,12 @@ onBeforeUnmount(() => {
|
||||
.hero-stats .w4f-tag b { color: $color-text-primary; font-weight: 800; }
|
||||
.crown { color: var(--w4f-primary-h); }
|
||||
|
||||
/* nodeKey admission-key bar */
|
||||
.nodekey-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 12px; padding-top: 12px; border-top: 1px dashed var(--w4f-line); width: 100%; }
|
||||
.nk-label { font-size: 12px; color: $color-text-muted; white-space: nowrap; }
|
||||
.nk-value { font-family: var(--w4f-mono); font-size: 12px; background: var(--w4f-card-2); border: 1px solid var(--w4f-line); border-radius: $radius-xs; padding: 3px 8px; user-select: all; letter-spacing: 0.02em; }
|
||||
.nk-hint { font-size: 11px; color: $color-text-light; }
|
||||
|
||||
/* KPI */
|
||||
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(168px, 1fr)); gap: 14px; }
|
||||
|
||||
@ -344,7 +487,6 @@ onBeforeUnmount(() => {
|
||||
.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 */
|
||||
@ -366,6 +508,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
/* tasks / topology / log lists */
|
||||
.task-list, .topo-list, .log-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.log-list { max-height: 340px; overflow-y: auto; padding-right: 2px; }
|
||||
.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); }
|
||||
@ -374,15 +517,19 @@ onBeforeUnmount(() => {
|
||||
.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 { display: flex; align-items: center; gap: 10px; font-size: 12px; font-family: var(--w4f-mono); border-bottom: 1px dashed var(--w4f-line); padding: 4px 2px; flex-wrap: wrap; }
|
||||
.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: $color-text-light; &.self { color: var(--w4f-primary-h); font-weight: 700; } }
|
||||
.lg-kind { color: $color-text-secondary; }
|
||||
.lg-seq { color: $color-text-muted; flex: 0 0 auto; }
|
||||
.lg-time { color: $color-text-light; font-size: 11.5px; flex: 0 0 auto; }
|
||||
.lg-node { color: $color-text-light; flex: 0 0 auto; &.self { color: var(--w4f-primary-h); font-weight: 700; } }
|
||||
.lg-kind { color: $color-text-secondary; flex: 0 0 auto; font-weight: 600; }
|
||||
.lg-detail { margin-left: auto; color: $color-text-secondary; font-size: 11.5px; text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 60%; }
|
||||
.log-count { font-size: 11px; font-weight: 600; color: $color-text-muted; }
|
||||
.title-actions { margin-left: auto; display: flex; align-items: center; gap: 6px; }
|
||||
.empty { color: $color-text-muted; font-size: 13px; padding: 10px; }
|
||||
.empty-page { color: $color-text-muted; font-size: 14px; padding: 40px; text-align: center; }
|
||||
|
||||
|
||||
@ -281,8 +281,8 @@ onMounted(load)
|
||||
}
|
||||
|
||||
.info-version {
|
||||
background: rgba(64, 158, 255, 0.12);
|
||||
color: $color-primary;
|
||||
background: $color-primary-50;
|
||||
color: $color-primary-h;
|
||||
border-radius: 10px;
|
||||
padding: 2px 8px;
|
||||
font-size: $font-size-sm;
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
<div class="topbar w4f-card">
|
||||
<div class="tb-left">
|
||||
<h2 class="page-title">状态总览</h2>
|
||||
<span class="page-sub">每 5 秒自动刷新 · frpc worker 进程态 + frps 连接态</span>
|
||||
<span class="page-sub">每 2.5 秒自动刷新</span>
|
||||
</div>
|
||||
<div class="tb-right">
|
||||
<button class="w4f-btn ghost small" @click="loadStatus">⟳ 刷新</button>
|
||||
@ -37,9 +37,57 @@
|
||||
</div>
|
||||
|
||||
<div class="status-body" v-if="status">
|
||||
<!-- 远程节点状态 -->
|
||||
<!-- 转发 (forward-centric): 本地/远程 by localOnly, per-card 启停, 分组一键 -->
|
||||
<section v-for="g in forwardsByGroup" :key="g.key || '__none__'" class="section">
|
||||
<h3 class="section-title">
|
||||
<span class="k-ic">→</span>{{ g.label }}
|
||||
<span class="grp-count">{{ g.items.length }} 条</span>
|
||||
<span class="grp-actions">
|
||||
<button class="w4f-btn small" @click="groupStart(g.key)">一键启动整组</button>
|
||||
<button class="w4f-btn ghost small" @click="groupStop(g.key)">一键停止整组</button>
|
||||
<button v-if="g.key" class="w4f-btn ghost small danger" @click="deleteGroup(g.key)">删除分组</button>
|
||||
</span>
|
||||
</h3>
|
||||
<div class="card-grid">
|
||||
<div
|
||||
v-for="f in g.items"
|
||||
:key="f.local + ':' + f.remote + ':' + f.remotePort"
|
||||
class="fwd-card w4f-card"
|
||||
:class="{ active: f.active, stopped: f.disabled }"
|
||||
>
|
||||
<div class="fwd-head">
|
||||
<span class="fwd-kind" :class="f.kind">{{ f.kind === 'local' ? '本地' : '远程' }}</span>
|
||||
<span
|
||||
class="fwd-dot"
|
||||
:class="{ on: f.active }"
|
||||
:title="f.active ? '运行中' : f.disabled ? '已停止' : '未运行'"
|
||||
/>
|
||||
<span class="fwd-route">
|
||||
<b>{{ f.local }}</b><span class="fwd-ip" v-if="f.localIp"> {{ f.localIp }}:{{ f.localPort }}</span>
|
||||
<span class="fwd-arrow">→</span>
|
||||
<b>{{ f.remote }}</b>:<span class="fwd-port">{{ f.remotePort }}</span>
|
||||
</span>
|
||||
<span v-if="f.localProto" class="w4f-tag w4f-tag-primary">{{ f.localProto }}</span>
|
||||
<span
|
||||
class="w4f-tag w4f-tag-info group-chip"
|
||||
@click="editGroup(f)"
|
||||
:title="'点击修改分组(当前:' + (f.group || '未分组') + ')'"
|
||||
>{{ f.group || '未分组' }}</span>
|
||||
<span v-if="f.kind === 'remote'" class="fwd-owner" :title="f.ownerId || ''">{{ ownerLabel(f) }}</span>
|
||||
<span class="fwd-actions">
|
||||
<button v-if="!f.active" class="w4f-btn small" @click="startForward(f)">启动转发</button>
|
||||
<button v-else class="w4f-btn ghost small" @click="stopForward(f)">停止转发</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!g.items.length" class="empty">该组暂无转发</div>
|
||||
</div>
|
||||
</section>
|
||||
<div v-if="!status.forwards.length" class="empty">尚未配置转发 — 前往「连接配置」画布编排 local→remote 连线</div>
|
||||
|
||||
<!-- 远程 frps 节点 (只读连接态; 启停已移至转发卡) -->
|
||||
<section class="section">
|
||||
<h3 class="section-title"><span class="k-ic">⊕</span>远程节点状态<span class="hint">管理本地 frpc worker 进程(不管理远程 frps)</span></h3>
|
||||
<h3 class="section-title"><span class="k-ic">⊕</span>远程节点状态</h3>
|
||||
<div class="card-grid">
|
||||
<div
|
||||
v-for="p in status.profiles"
|
||||
@ -53,8 +101,6 @@
|
||||
<span class="w4f-dot" :class="connDotClass(p.connState)" />{{ connStateLabel(p.connState) }}
|
||||
</span>
|
||||
<span class="nc-actions">
|
||||
<button v-if="p.process.state === 'running'" class="w4f-btn ghost small" @click="stopRemote(p.name)">停 worker</button>
|
||||
<button v-else class="w4f-btn small" @click="startRemote(p.name)">启 worker</button>
|
||||
<button class="w4f-btn ghost small" @click="openEdit(p)">编辑</button>
|
||||
<button class="w4f-btn danger small" @click="removeRemote(p.name)">删除</button>
|
||||
</span>
|
||||
@ -95,15 +141,16 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 本地服务转发状态 -->
|
||||
<!-- 本地服务 (overview) -->
|
||||
<section class="section">
|
||||
<h3 class="section-title"><span class="k-ic">◳</span>本地服务转发<span class="hint">本地服务经 frpc worker 转发至远程 frps</span></h3>
|
||||
<h3 class="section-title"><span class="k-ic">◳</span>本地服务</h3>
|
||||
<div class="card-grid">
|
||||
<div v-for="ls in status.localStatus" :key="ls.local.name" class="local-card w4f-card">
|
||||
<div class="lc-head">
|
||||
<span class="lc-name">{{ ls.local.name }}</span>
|
||||
<span class="lc-addr">{{ ls.local.ip }}:{{ ls.local.port }}</span>
|
||||
<span class="w4f-tag w4f-tag-primary">{{ ls.local.protocol }}</span>
|
||||
<span v-if="ls.local.localOnly" class="w4f-tag w4f-tag-ok">本地</span>
|
||||
</div>
|
||||
<div class="lc-targets">
|
||||
<div v-for="t in ls.targets" :key="t.remote + ':' + t.remotePort" class="lc-target">
|
||||
@ -157,12 +204,13 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { api } from '../api'
|
||||
import type { Remote, StatusResp } from '../types'
|
||||
import type { ForwardStatus, Remote, StatusResp } from '../types'
|
||||
|
||||
const status = ref<StatusResp | null>(null)
|
||||
let timer: number | null = null
|
||||
let visHandler: (() => void) | null = null
|
||||
|
||||
interface RemoteDialog {
|
||||
visible: boolean
|
||||
@ -216,24 +264,93 @@ const saveRemote = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const startRemote = async (name: string) => {
|
||||
// ---- Per-forward start/stop (forwards page) ----
|
||||
// local-only → toggle local frpc worker (render skips disabled proxies);
|
||||
// cluster → ring submit/revoke. Idempotent on the backend.
|
||||
const startForward = async (f: ForwardStatus) => {
|
||||
try {
|
||||
await api.profileStart(name)
|
||||
await api.forwardStart(f.local, f.remote, f.remotePort)
|
||||
await loadStatus()
|
||||
} catch (e: any) {
|
||||
ElMessage.error('启动失败: ' + (e.message || e))
|
||||
ElMessage.error('启动转发失败: ' + (e.message || e))
|
||||
}
|
||||
}
|
||||
const stopForward = async (f: ForwardStatus) => {
|
||||
try {
|
||||
await api.forwardStop(f.local, f.remote, f.remotePort)
|
||||
await loadStatus()
|
||||
} catch (e: any) {
|
||||
ElMessage.error('停止转发失败: ' + (e.message || e))
|
||||
}
|
||||
}
|
||||
const groupStart = async (group: string) => {
|
||||
try {
|
||||
await api.groupStart(group)
|
||||
await loadStatus()
|
||||
} catch (e: any) {
|
||||
ElMessage.error('启动分组失败: ' + (e.message || e))
|
||||
}
|
||||
}
|
||||
const groupStop = async (group: string) => {
|
||||
try {
|
||||
await api.groupStop(group)
|
||||
await loadStatus()
|
||||
} catch (e: any) {
|
||||
ElMessage.error('停止分组失败: ' + (e.message || e))
|
||||
}
|
||||
}
|
||||
|
||||
const stopRemote = async (name: string) => {
|
||||
// editGroup opens a prompt to change a single forward's group label from the
|
||||
// card's group chip. Lists existing group names for quick selection; empty
|
||||
// input moves the forward to 未分组. Pure DB update — no worker/ring action.
|
||||
const editGroup = async (f: ForwardStatus) => {
|
||||
const existing = new Set<string>()
|
||||
for (const fw of status.value?.forwards ?? []) {
|
||||
if (fw.group) existing.add(fw.group)
|
||||
}
|
||||
const hint = existing.size
|
||||
? `现有分组:${[...existing].join('、')}(留空=移出分组)`
|
||||
: '输入分组名称(留空=移出分组)'
|
||||
try {
|
||||
await api.profileStop(name)
|
||||
const { value } = await ElMessageBox.prompt(hint, '修改分组', {
|
||||
inputValue: f.group || '',
|
||||
inputPlaceholder: '分组名称',
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
const g = (value || '').trim()
|
||||
await api.assignGroup(f.local, f.remote, f.remotePort, g)
|
||||
await loadStatus()
|
||||
} catch (e: any) {
|
||||
ElMessage.error('停止失败: ' + (e.message || e))
|
||||
// user cancelled (reject) → no action; real errors show a toast
|
||||
if (e !== 'cancel' && e?.message) {
|
||||
ElMessage.error('修改分组失败: ' + (e.message || e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// deleteGroup dissolves a named group: all members are moved to 未分组. The
|
||||
// group itself is just a label on links, so clearing all members is the
|
||||
// complete delete. Confirms first since it affects every forward in the group.
|
||||
const deleteGroup = async (group: string) => {
|
||||
if (!window.confirm(`删除分组「${group}」?组内所有转发将移至未分组(转发本身不受影响)。`)) return
|
||||
try {
|
||||
await api.deleteGroup(group)
|
||||
await loadStatus()
|
||||
ElMessage.success(`已删除分组「${group}」`)
|
||||
} catch (e: any) {
|
||||
ElMessage.error('删除分组失败: ' + (e.message || e))
|
||||
}
|
||||
}
|
||||
|
||||
// ownerLabel: human-readable topology owner for a remote (cluster) forward.
|
||||
const ownerLabel = (f: ForwardStatus) => {
|
||||
if (f.kind !== 'remote') return ''
|
||||
if (!f.ownerId) return '待分配'
|
||||
if (f.ownerId === status.value?.selfId) return '本机持有'
|
||||
return '持有 ' + f.ownerId
|
||||
}
|
||||
|
||||
const removeRemote = async (name: string) => {
|
||||
if (!window.confirm(`删除远程节点 ${name}?`)) return
|
||||
try {
|
||||
@ -257,7 +374,29 @@ const workerHealthy = (s?: string) => s === 'running'
|
||||
const runningCount = computed(() => status.value?.profiles.filter((p) => p.process.state === 'running').length ?? 0)
|
||||
const onlineCount = computed(() => status.value?.profiles.filter((p) => p.connState === 'connected').length ?? 0)
|
||||
const allConnected = computed(() => (status.value?.profiles.length ?? 0) > 0 && onlineCount.value === status.value?.profiles.length)
|
||||
const activeForwards = computed(() => status.value?.profiles.reduce((s, p) => s + p.forwards.length, 0) ?? 0)
|
||||
const activeForwards = computed(() => status.value?.forwards.filter((f) => f.active).length ?? 0)
|
||||
|
||||
// forwardsByGroup buckets the forward-centric list by Link.group; the empty
|
||||
// group is labelled 未分组 and sorted last so named groups surface first.
|
||||
const forwardsByGroup = computed(() => {
|
||||
const map = new Map<string, ForwardStatus[]>()
|
||||
for (const f of status.value?.forwards ?? []) {
|
||||
const key = f.group || ''
|
||||
let arr = map.get(key)
|
||||
if (!arr) {
|
||||
arr = []
|
||||
map.set(key, arr)
|
||||
}
|
||||
arr.push(f)
|
||||
}
|
||||
return [...map.entries()]
|
||||
.map(([key, items]) => ({ key, label: key || '未分组', items }))
|
||||
.sort((a, b) => {
|
||||
if (!a.key) return 1
|
||||
if (!b.key) return -1
|
||||
return a.label.localeCompare(b.label, 'zh')
|
||||
})
|
||||
})
|
||||
|
||||
// connState refers to the frps connection from the local frpc worker.
|
||||
// We never control the remote frps itself.
|
||||
@ -332,7 +471,14 @@ const remoteVhostPort = (name: string): number =>
|
||||
|
||||
onMounted(() => {
|
||||
loadStatus()
|
||||
timer = window.setInterval(loadStatus, 5000)
|
||||
// 2.5s cadence keeps the status page live against cluster dispatch +
|
||||
// worker state changes; visibilitychange reloads instantly on tab refocus
|
||||
// so a stale tab never lingers on pre-action data.
|
||||
timer = window.setInterval(loadStatus, 2500)
|
||||
visHandler = () => {
|
||||
if (!document.hidden) loadStatus()
|
||||
}
|
||||
document.addEventListener('visibilitychange', visHandler)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@ -340,6 +486,10 @@ onBeforeUnmount(() => {
|
||||
window.clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
if (visHandler) {
|
||||
document.removeEventListener('visibilitychange', visHandler)
|
||||
visHandler = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -395,7 +545,6 @@ onBeforeUnmount(() => {
|
||||
gap: 10px;
|
||||
.k-ic { width: 26px; height: 26px; border-radius: 9px; display: grid; place-items: center; font-size: 13px;
|
||||
background: linear-gradient(135deg, var(--w4f-primary-50), var(--w4f-secondary-50)); color: var(--w4f-primary-h); }
|
||||
.hint { font-size: 11px; font-weight: 400; color: $color-text-muted; margin-left: auto; }
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
@ -413,6 +562,8 @@ onBeforeUnmount(() => {
|
||||
.nc-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.nc-name { font-weight: 800; font-size: 15px; margin-right: auto; }
|
||||
.nc-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.nc-cluster-note { font-size: 11.5px; color: $color-text-muted; padding: 3px 9px; border-radius: $radius-pill;
|
||||
background: var(--w4f-card-2); cursor: help; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.nc-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||
.meta-pill { font-size: 11.5px; padding: 2px 9px; border-radius: $radius-pill; background: var(--w4f-card-2); color: $color-text-muted;
|
||||
font-family: var(--w4f-mono); max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
@ -436,6 +587,37 @@ onBeforeUnmount(() => {
|
||||
.np-err { margin-left: auto; font-size: 11px; color: $color-danger; max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.nc-proxies-empty { margin-top: 8px; font-size: 12px; color: $color-text-muted; }
|
||||
|
||||
/* forward card (转发为单元) */
|
||||
.fwd-card {
|
||||
padding: 12px 14px;
|
||||
border-left: 4px solid var(--w4f-muted);
|
||||
transition: border-color 0.18s var(--w4f-ease-spring);
|
||||
&.active { border-left-color: var(--w4f-ok); }
|
||||
&.stopped { border-left-color: var(--w4f-danger); opacity: 0.78; }
|
||||
}
|
||||
.fwd-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.fwd-kind {
|
||||
font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: $radius-pill;
|
||||
color: var(--w4f-muted); background: var(--w4f-card-2);
|
||||
&.local { color: var(--w4f-ok-h, #fff); background: var(--w4f-ok-50, var(--w4f-primary-50)); }
|
||||
&.remote { color: var(--w4f-secondary-h); background: var(--w4f-secondary-50); }
|
||||
}
|
||||
.fwd-dot {
|
||||
width: 9px; height: 9px; border-radius: 50%; flex: 0 0 auto;
|
||||
background: var(--w4f-muted); box-shadow: 0 0 0 3px var(--w4f-card-2);
|
||||
&.on { background: var(--w4f-ok); box-shadow: 0 0 0 3px var(--w4f-ok-50, var(--w4f-primary-50)); }
|
||||
}
|
||||
.fwd-route { font-size: 13.5px; display: flex; align-items: center; gap: 5px; margin-right: auto; }
|
||||
.fwd-ip { font-size: 11.5px; color: $color-text-muted; font-family: var(--w4f-mono); }
|
||||
.fwd-arrow { color: var(--w4f-primary); font-weight: 800; }
|
||||
.fwd-port { font-family: var(--w4f-mono); color: $color-text-secondary; }
|
||||
.fwd-owner { font-size: 11.5px; color: $color-text-muted; padding: 2px 8px; border-radius: $radius-pill;
|
||||
background: var(--w4f-card-2); max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.fwd-actions { display: flex; gap: 6px; }
|
||||
.grp-count { font-size: 12px; color: $color-text-muted; font-weight: 600; }
|
||||
.grp-actions { margin-left: auto; display: flex; gap: 6px; }
|
||||
.group-chip { cursor: pointer; transition: background $transition-fast; &:hover { background: color-mix(in srgb, var(--w4f-info) 30%, transparent); } }
|
||||
|
||||
/* local service card */
|
||||
.local-card { padding: 14px 16px; }
|
||||
.lc-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
347
web/src/views/UsersView.vue
Normal file
347
web/src/views/UsersView.vue
Normal file
@ -0,0 +1,347 @@
|
||||
<!--
|
||||
UsersView — account + API key management (admin only).
|
||||
Two tables: built-in/manual users (system rows are read-only, managed by
|
||||
-user/-password flags) and scoped API keys (plaintext shown once at create).
|
||||
Reached only when isAdmin; App.vue hides the nav entry for non-admins.
|
||||
-->
|
||||
<template>
|
||||
<div class="users-view">
|
||||
<!-- ---- accounts ---- -->
|
||||
<section class="card">
|
||||
<header class="card-h">
|
||||
<h3>账号</h3>
|
||||
<el-button type="primary" size="small" @click="openUserCreate">+ 新建账号</el-button>
|
||||
</header>
|
||||
<el-table :data="users" size="small" stripe empty-text="暂无账号">
|
||||
<el-table-column prop="username" label="用户名" min-width="140" />
|
||||
<el-table-column label="角色" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.role === 'admin' ? 'danger' : 'info'">
|
||||
{{ row.role === 'admin' ? '管理员' : '审计(viewer)' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.enabled ? 'success' : 'warning'">
|
||||
{{ row.enabled ? '启用' : '停用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="来源" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.system" size="small" type="warning">内置(flag)</el-tag>
|
||||
<span v-else class="muted">手动</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="170">
|
||||
<template #default="{ row }">{{ fmt(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最后登录" width="170">
|
||||
<template #default="{ row }">{{ row.lastLoginAt ? fmt(row.lastLoginAt) : '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openUserEdit(row)" :disabled="row.system">编辑</el-button>
|
||||
<el-button size="small" type="danger" plain @click="onDeleteUser(row)" :disabled="row.system">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<!-- ---- api keys ---- -->
|
||||
<section class="card">
|
||||
<header class="card-h">
|
||||
<h3>API 密钥</h3>
|
||||
<el-button type="primary" size="small" @click="openKeyCreate">+ 新建密钥</el-button>
|
||||
</header>
|
||||
<el-table :data="apiKeys" size="small" stripe empty-text="暂无密钥">
|
||||
<el-table-column prop="label" label="标签" min-width="140" />
|
||||
<el-table-column label="作用域" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="scopeType(row.scope)">{{ row.scope }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="归属用户" width="140">
|
||||
<template #default="{ row }">{{ userName(row.userId) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="prefix" label="前缀" width="120" />
|
||||
<el-table-column label="创建时间" width="170">
|
||||
<template #default="{ row }">{{ fmt(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最近使用" width="170">
|
||||
<template #default="{ row }">{{ row.lastUsedAt ? fmt(row.lastUsedAt) : '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="110" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="danger" plain @click="onDeleteKey(row)">吊销</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<!-- create / edit user dialog -->
|
||||
<el-dialog v-model="userDialog" :title="editing ? '编辑账号' : '新建账号'" width="420px">
|
||||
<el-form label-width="86px" @submit.prevent>
|
||||
<el-form-item label="用户名" v-if="!editing">
|
||||
<el-input v-model="userForm.username" autocomplete="off" />
|
||||
</el-form-item>
|
||||
<el-form-item label="用户名" v-else>
|
||||
<span class="muted">{{ userForm.username }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item :label="editing ? '新密码' : '密码'">
|
||||
<el-input v-model="userForm.password" type="password" show-password
|
||||
:placeholder="editing ? '留空则不改' : ''" autocomplete="new-password" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<el-select v-model="userForm.role" :disabled="editing && editing?.system">
|
||||
<el-option label="管理员 (admin)" value="admin" />
|
||||
<el-option label="审计 (viewer)" value="viewer" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="启用" v-if="editing">
|
||||
<el-switch v-model="userForm.enabled" :disabled="editing?.system" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="userDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitUser" :loading="userSaving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- create key dialog -->
|
||||
<el-dialog v-model="keyDialog" title="新建 API 密钥" width="440px">
|
||||
<el-form label-width="86px" @submit.prevent v-if="!createdKey">
|
||||
<el-form-item label="归属用户">
|
||||
<el-select v-model="keyForm.userId" placeholder="选择用户">
|
||||
<el-option v-for="u in users" :key="u.id" :label="u.username" :value="u.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="标签">
|
||||
<el-input v-model="keyForm.label" placeholder="如 ci-deploy" />
|
||||
</el-form-item>
|
||||
<el-form-item label="作用域">
|
||||
<el-select v-model="keyForm.scope">
|
||||
<el-option label="read — 只读 + 导出" value="read" />
|
||||
<el-option label="write — 改转发/worker/设置" value="write" />
|
||||
<el-option label="admin — 全量 (含账号管理)" value="admin" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div v-else class="key-result">
|
||||
<p class="warn">密钥仅显示一次,请立即复制保存:</p>
|
||||
<el-input v-model="createdKey" readonly>
|
||||
<template #append>
|
||||
<el-button @click="copyKey">复制</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
<p class="hint">使用方式:请求头 <code>Authorization: Bearer <key></code></p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="keyDialog = false">{{ createdKey ? '关闭' : '取消' }}</el-button>
|
||||
<el-button v-if="!createdKey" type="primary" @click="submitKey" :loading="keySaving">生成</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { api } from '../api'
|
||||
import type { ApiKey, ApiKeyCreated, User } from '../types'
|
||||
|
||||
const users = ref<User[]>([])
|
||||
const apiKeys = ref<ApiKey[]>([])
|
||||
|
||||
const loadUsers = async () => {
|
||||
const r = await api.listUsers()
|
||||
users.value = r.users
|
||||
}
|
||||
const loadKeys = async () => {
|
||||
const r = await api.listApiKeys()
|
||||
apiKeys.value = r.apiKeys
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadUsers(), loadKeys()])
|
||||
})
|
||||
|
||||
const fmt = (ts: number) => {
|
||||
if (!ts) return '—'
|
||||
return new Date(ts * 1000).toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
const userName = (id: number) => users.value.find((u) => u.id === id)?.username ?? `#${id}`
|
||||
const scopeType = (s: string) => (s === 'admin' ? 'danger' : s === 'write' ? 'warning' : 'info')
|
||||
|
||||
// ---- user create/edit ----
|
||||
const userDialog = ref(false)
|
||||
const editing = ref<User | null>(null)
|
||||
const userSaving = ref(false)
|
||||
const userForm = ref({ username: '', password: '', role: 'viewer' as 'admin' | 'viewer', enabled: true })
|
||||
|
||||
const openUserCreate = () => {
|
||||
editing.value = null
|
||||
userForm.value = { username: '', password: '', role: 'viewer', enabled: true }
|
||||
userDialog.value = true
|
||||
}
|
||||
const openUserEdit = (u: User) => {
|
||||
editing.value = u
|
||||
userForm.value = { username: u.username, password: '', role: u.role, enabled: u.enabled }
|
||||
userDialog.value = true
|
||||
}
|
||||
|
||||
const submitUser = async () => {
|
||||
userSaving.value = true
|
||||
try {
|
||||
if (editing.value) {
|
||||
const patch: { password?: string; role: 'admin' | 'viewer'; enabled: boolean } = {
|
||||
role: userForm.value.role,
|
||||
enabled: userForm.value.enabled,
|
||||
}
|
||||
if (userForm.value.password) patch.password = userForm.value.password
|
||||
await api.updateUser(editing.value.username, patch)
|
||||
ElMessage.success('已更新')
|
||||
} else {
|
||||
if (!userForm.value.username || !userForm.value.password) {
|
||||
ElMessage.warning('用户名和密码必填')
|
||||
userSaving.value = false
|
||||
return
|
||||
}
|
||||
await api.createUser(userForm.value.username, userForm.value.password, userForm.value.role)
|
||||
ElMessage.success('已创建')
|
||||
}
|
||||
userDialog.value = false
|
||||
await loadUsers()
|
||||
} catch (e: any) {
|
||||
ElMessage.error('失败: ' + (e.message || e))
|
||||
} finally {
|
||||
userSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onDeleteUser = async (u: User) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除账号 ${u.username}?`, '删除账号', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.deleteUser(u.username)
|
||||
ElMessage.success('已删除')
|
||||
await loadUsers()
|
||||
} catch (e: any) {
|
||||
ElMessage.error('删除失败: ' + (e.message || e))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- api key create ----
|
||||
const keyDialog = ref(false)
|
||||
const keySaving = ref(false)
|
||||
const createdKey = ref('')
|
||||
const keyForm = ref({ userId: 0 as number, label: '', scope: 'read' as 'read' | 'write' | 'admin' })
|
||||
|
||||
const openKeyCreate = () => {
|
||||
// default to the first non-system user (or first user)
|
||||
keyForm.value = {
|
||||
userId: users.value.find((u) => !u.system)?.id ?? users.value[0]?.id ?? 0,
|
||||
label: '',
|
||||
scope: 'read',
|
||||
}
|
||||
createdKey.value = ''
|
||||
keyDialog.value = true
|
||||
}
|
||||
|
||||
const submitKey = async () => {
|
||||
if (!keyForm.value.userId) {
|
||||
ElMessage.warning('请选择归属用户')
|
||||
return
|
||||
}
|
||||
keySaving.value = true
|
||||
try {
|
||||
const r: ApiKeyCreated = await api.createApiKey(keyForm.value.userId, keyForm.value.label, keyForm.value.scope)
|
||||
createdKey.value = r.key
|
||||
await loadKeys()
|
||||
} catch (e: any) {
|
||||
ElMessage.error('创建失败: ' + (e.message || e))
|
||||
} finally {
|
||||
keySaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const copyKey = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(createdKey.value)
|
||||
ElMessage.success('已复制')
|
||||
} catch {
|
||||
ElMessage.warning('复制失败,请手动选择')
|
||||
}
|
||||
}
|
||||
|
||||
const onDeleteKey = async (k: ApiKey) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认吊销密钥 ${k.label} (${k.prefix}…)?`, '吊销密钥', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.deleteApiKey(k.id)
|
||||
ElMessage.success('已吊销')
|
||||
await loadKeys()
|
||||
} catch (e: any) {
|
||||
ElMessage.error('吊销失败: ' + (e.message || e))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.users-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
.card {
|
||||
background: var(--w4f-card);
|
||||
border: 1px solid var(--w4f-line);
|
||||
border-radius: 16px;
|
||||
padding: 16px 18px;
|
||||
box-shadow: var(--w4f-sh-sm);
|
||||
}
|
||||
.card-h {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.card-h h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.muted {
|
||||
color: var(--w4f-muted);
|
||||
}
|
||||
.key-result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.key-result .warn {
|
||||
color: var(--w4f-primary-h, #d97706);
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
.key-result .hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--w4f-muted);
|
||||
}
|
||||
.key-result code {
|
||||
background: var(--w4f-card-2);
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user