mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-20 00:47:57 +00:00
# Conflicts: # internal/httpapi/dist/assets/index-CL42Ur3_.css # internal/httpapi/dist/assets/index-CbqC9m-j.js # internal/httpapi/dist/assets/index-RFHYrCiX.css # internal/httpapi/dist/assets/index-jvouiCgh.css # internal/httpapi/dist/index.html # internal/store/store.go # web/src/api.ts # web/src/views/ClusterView.vue
642 lines
26 KiB
Vue
642 lines
26 KiB
Vue
<template>
|
||
<div class="status-page">
|
||
<!-- top bar -->
|
||
<div class="topbar w4f-card">
|
||
<div class="tb-left">
|
||
<h2 class="page-title">状态总览</h2>
|
||
<span class="page-sub">每 2.5 秒自动刷新</span>
|
||
</div>
|
||
<div class="tb-right">
|
||
<button class="w4f-btn ghost small" @click="loadStatus">⟳ 刷新</button>
|
||
<button v-if="canWrite" class="w4f-btn small" @click="openAdd">+ 添加远程节点</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- KPI grid -->
|
||
<div class="kpis" v-if="status">
|
||
<div class="w4f-kpi">
|
||
<div class="k-hdr"><span class="k-lab"><span class="w4f-dot" :class="{ err: !allConnected }" />远程节点</span><span class="k-ic">⊕︎</span></div>
|
||
<div class="k-val">{{ status.profiles.length }}</div>
|
||
<div class="k-sub">在线 {{ onlineCount }} · 离线 {{ status.profiles.length - onlineCount }}</div>
|
||
</div>
|
||
<div class="w4f-kpi">
|
||
<div class="k-hdr"><span class="k-lab"><span class="w4f-dot" :class="{ err: runningCount === 0 && status.profiles.length > 0 }" />worker 进程</span><span class="k-ic">⚙︎</span></div>
|
||
<div class="k-val">{{ runningCount }}</div>
|
||
<div class="k-sub">运行中 frpc 进程数</div>
|
||
</div>
|
||
<div class="w4f-kpi">
|
||
<div class="k-hdr"><span class="k-lab"><span class="w4f-dot" />本地服务</span><span class="k-ic">◳</span></div>
|
||
<div class="k-val">{{ status.localStatus.length }}</div>
|
||
<div class="k-sub">已配置本地服务</div>
|
||
</div>
|
||
<div class="w4f-kpi">
|
||
<div class="k-hdr"><span class="k-lab"><span class="w4f-dot" :class="{ pending: activeForwards > 0 }" />活跃转发</span><span class="k-ic">→</span></div>
|
||
<div class="k-val">{{ activeForwards }}</div>
|
||
<div class="k-sub">已编排转发链路</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="status-body" v-if="status">
|
||
<!-- 转发 (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 v-if="canWrite" class="w4f-btn small" @click="groupStart(g.key)">一键启动整组</button>
|
||
<button v-if="canWrite" class="w4f-btn ghost small" @click="groupStop(g.key)">一键停止整组</button>
|
||
<button v-if="canWrite && 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"
|
||
:class="{ readonly: !canWrite }"
|
||
@click="canWrite && editGroup(f)"
|
||
:title="canWrite ? '点击修改分组(当前:' + (f.group || '未分组') + ')' : '当前分组:' + (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="canWrite && !f.active" class="w4f-btn small" @click="startForward(f)">启动转发</button>
|
||
<button v-else-if="canWrite" 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>远程节点状态</h3>
|
||
<div class="card-grid">
|
||
<div
|
||
v-for="p in status.profiles"
|
||
:key="p.name"
|
||
class="node-card w4f-card"
|
||
:class="{ healthy: workerHealthy(p.process.state) }"
|
||
>
|
||
<div class="nc-head">
|
||
<span class="nc-name">{{ p.name }}</span>
|
||
<span class="w4f-tag" :class="connTagClass(p.connState)">
|
||
<span class="w4f-dot" :class="connDotClass(p.connState)" />{{ connStateLabel(p.connState) }}
|
||
</span>
|
||
<span class="nc-actions">
|
||
<button v-if="canWrite" class="w4f-btn ghost small" @click="openEdit(p)">编辑</button>
|
||
<button v-if="canWrite" class="w4f-btn danger small" @click="removeRemote(p.name)">删除</button>
|
||
</span>
|
||
</div>
|
||
<div class="nc-meta">
|
||
<span v-if="p.process.pid" class="meta-pill">pid {{ p.process.pid }}</span>
|
||
<span v-if="p.process.restartCount" class="meta-pill warn">重启 {{ p.process.restartCount }}</span>
|
||
<span v-if="p.process.err" class="meta-pill err" :title="p.process.err">{{ p.process.err }}</span>
|
||
<span v-if="remoteVhostPort(p.name)" class="meta-pill info">HTTP :{{ remoteVhostPort(p.name) }}</span>
|
||
</div>
|
||
<div class="nc-forwards" v-if="p.forwards.length">
|
||
<span v-for="f in p.forwards" :key="f.service" class="w4f-tag w4f-tag-primary">
|
||
{{ f.service }} → :{{ f.remotePort }}
|
||
</span>
|
||
</div>
|
||
<div v-if="p.groupCount" class="nc-groups">
|
||
<span class="w4f-tag w4f-tag-info">LB ×{{ p.groupCount }}</span>
|
||
</div>
|
||
<div v-if="p.proxyStates?.length" class="nc-proxies">
|
||
<div
|
||
v-for="ps in p.proxyStates"
|
||
:key="ps.name"
|
||
class="nc-proxy"
|
||
:class="proxyStateClass(ps.status)"
|
||
>
|
||
<span class="np-name">{{ ps.name }}</span>
|
||
<span class="np-status">{{ proxyStateLabel(ps.status) }}</span>
|
||
<div class="w4f-bar" :class="proxyBarClass(ps.status)"><i :style="{ width: proxyBarWidth(ps.status) }" /></div>
|
||
<span v-if="ps.remote_addr" class="np-addr">{{ ps.remote_addr }}</span>
|
||
<span v-if="ps.err" class="np-err" :title="ps.err">{{ ps.err }}</span>
|
||
</div>
|
||
</div>
|
||
<div v-else-if="p.adminEnabled" class="nc-proxies-empty">
|
||
管理 API 未返回状态(frpc 未连上远程 frps 或无代理)
|
||
</div>
|
||
</div>
|
||
<div v-if="!status.profiles.length" class="empty">尚未配置远程节点 — 点击右上「+ 添加远程节点」</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 本地服务 (overview) -->
|
||
<section class="section">
|
||
<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">
|
||
<span class="lc-arrow">→</span>
|
||
<span class="lc-remote">{{ t.remote }}</span>
|
||
<span class="lc-port">:{{ t.remotePort }}</span>
|
||
<span class="w4f-tag" :class="workerHealthy(t.workerState) ? 'w4f-tag-ok' : 'w4f-tag-danger'">
|
||
{{ localWorkerLabel(t.workerState) }}
|
||
</span>
|
||
</div>
|
||
<div v-if="!ls.targets.length" class="lc-none">未转发</div>
|
||
</div>
|
||
</div>
|
||
<div v-if="!status.localStatus.length" class="empty">尚未配置本地服务 — 前往「连接配置」画布编排</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<el-dialog v-model="dialog.visible" :title="dialog.isNew ? '添加远程节点' : '编辑远程节点'" width="440px">
|
||
<div class="form-row">
|
||
<label>名称</label>
|
||
<el-input v-model="dialog.remote.name" :disabled="!canWrite || !dialog.isNew" placeholder="如 aliyun-hz" />
|
||
</div>
|
||
<div class="form-row">
|
||
<label>服务器 IP</label>
|
||
<el-input v-model="dialog.remote.ip" :disabled="!canWrite" placeholder="frps 地址" />
|
||
</div>
|
||
<div class="form-row">
|
||
<label>端口</label>
|
||
<el-input-number v-model="dialog.remote.port" :disabled="!canWrite" :min="1" :max="65535" />
|
||
</div>
|
||
<div class="form-row">
|
||
<label>Token</label>
|
||
<el-input v-model="dialog.remote.token" :disabled="!canWrite" placeholder="可选" />
|
||
</div>
|
||
<div class="form-row">
|
||
<label>URL</label>
|
||
<el-input v-model="dialog.remote.url" :disabled="!canWrite" placeholder="可选" />
|
||
</div>
|
||
<div class="form-row">
|
||
<label>启用</label>
|
||
<el-switch v-model="dialog.remote.enabled" :disabled="!canWrite" />
|
||
</div>
|
||
<template #footer>
|
||
<button class="w4f-btn ghost small" @click="dialog.visible = false">取消</button>
|
||
<button class="w4f-btn small" :disabled="!canWrite" @click="saveRemote">保存</button>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { api } from '../api'
|
||
import { canWrite } from '../auth'
|
||
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
|
||
isNew: boolean
|
||
remote: Remote
|
||
}
|
||
|
||
const newRemote = (): Remote => ({
|
||
name: '',
|
||
ip: '',
|
||
port: 7000,
|
||
token: '',
|
||
url: '',
|
||
enabled: true,
|
||
})
|
||
|
||
const dialog = reactive<RemoteDialog>({
|
||
visible: false,
|
||
isNew: true,
|
||
remote: newRemote(),
|
||
})
|
||
|
||
const openAdd = () => {
|
||
dialog.isNew = true
|
||
dialog.remote = newRemote()
|
||
dialog.visible = true
|
||
}
|
||
|
||
const openEdit = (p: { name: string }) => {
|
||
const found = status.value?.remotes.find((r) => r.name === p.name)
|
||
dialog.isNew = false
|
||
dialog.remote = found
|
||
? { ...found }
|
||
: { name: p.name, ip: '', port: 7000, token: '', url: '', enabled: true }
|
||
dialog.visible = true
|
||
}
|
||
|
||
const saveRemote = async () => {
|
||
const d = dialog.remote
|
||
if (!d.name || !d.ip || d.port <= 0) {
|
||
ElMessage.warning('请填写名称、IP、端口')
|
||
return
|
||
}
|
||
try {
|
||
await api.saveRemote(d)
|
||
dialog.visible = false
|
||
ElMessage.success(dialog.isNew ? '节点已添加' : '节点已更新')
|
||
await loadStatus()
|
||
} catch (e: any) {
|
||
ElMessage.error('保存失败: ' + (e.message || e))
|
||
}
|
||
}
|
||
|
||
// ---- 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.forwardStart(f.local, f.remote, f.remotePort)
|
||
await loadStatus()
|
||
} catch (e: any) {
|
||
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))
|
||
}
|
||
}
|
||
|
||
// 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 {
|
||
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) {
|
||
// 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 {
|
||
await api.deleteRemote(name)
|
||
await loadStatus()
|
||
} catch (e: any) {
|
||
ElMessage.error('删除失败: ' + (e.message || e))
|
||
}
|
||
}
|
||
|
||
const loadStatus = async () => {
|
||
try {
|
||
status.value = await api.status()
|
||
} catch {
|
||
// keep last known status on transient errors
|
||
}
|
||
}
|
||
|
||
// ---- KPI derivations ----
|
||
const workerHealthy = (s?: string) => s === 'running'
|
||
const runningCount = computed(() => status.value?.profiles.filter((p) => p.process.state === 'running').length ?? 0)
|
||
const onlineCount = computed(() => status.value?.profiles.filter((p) => p.connState === 'connected').length ?? 0)
|
||
const allConnected = computed(() => (status.value?.profiles.length ?? 0) > 0 && onlineCount.value === status.value?.profiles.length)
|
||
const activeForwards = computed(() => status.value?.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.
|
||
const connStateLabel = (s?: string) => {
|
||
switch (s) {
|
||
case 'connected': return '已连接'
|
||
case 'connecting': return '连接中'
|
||
case 'failed': return '连接失败'
|
||
case 'disabled': return '已停用'
|
||
case 'not_started':
|
||
default: return '未启动'
|
||
}
|
||
}
|
||
const connTagClass = (s?: string) => {
|
||
switch (s) {
|
||
case 'connected': return 'w4f-tag-ok'
|
||
case 'connecting': return 'w4f-tag-info'
|
||
case 'failed': return 'w4f-tag-danger'
|
||
case 'disabled':
|
||
case 'not_started':
|
||
default: return 'w4f-tag-warn'
|
||
}
|
||
}
|
||
const connDotClass = (s?: string) => {
|
||
switch (s) {
|
||
case 'connected': return ''
|
||
case 'connecting': return 'pending'
|
||
case 'failed': return 'err'
|
||
default: return ''
|
||
}
|
||
}
|
||
|
||
// M3: per-proxy state from frpc admin API.
|
||
const proxyStateLabel = (s?: string) => {
|
||
switch (s) {
|
||
case 'running': return '运行中'
|
||
case 'wait start': return '等待启动'
|
||
case 'start error': return '启动错误'
|
||
case 'check failed': return '健康检查失败'
|
||
case 'closed': return '已关闭'
|
||
case 'new':
|
||
default: return '新建'
|
||
}
|
||
}
|
||
const proxyStateClass = (s?: string) => {
|
||
switch (s) {
|
||
case 'running': return 'ok'
|
||
case 'check failed':
|
||
case 'start error': return 'err'
|
||
case 'wait start': return 'pending'
|
||
default: return ''
|
||
}
|
||
}
|
||
const proxyBarClass = (s?: string) => (s === 'running' ? 'ok' : s === 'check failed' || s === 'start error' ? 'err' : '')
|
||
const proxyBarWidth = (s?: string) => (s === 'running' ? '100%' : s === 'wait start' ? '45%' : s === 'new' ? '15%' : '30%')
|
||
|
||
// localWorkerLabel: state of the LOCAL frpc worker driving this target
|
||
// (we do not control the remote frps).
|
||
const localWorkerLabel = (s?: string) => {
|
||
switch (s) {
|
||
case 'running': return '运行中'
|
||
case 'starting': return '启动中'
|
||
case 'restarting': return '重启中'
|
||
case 'crashed': return '异常'
|
||
default: return '未启动'
|
||
}
|
||
}
|
||
|
||
// remoteVhostPort returns the frps vhostHTTPPort for a remote (0 if unset).
|
||
const remoteVhostPort = (name: string): number =>
|
||
status.value?.remotes.find((r) => r.name === name)?.vhostHttpPort || 0
|
||
|
||
onMounted(() => {
|
||
loadStatus()
|
||
// 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(() => {
|
||
if (timer !== null) {
|
||
window.clearInterval(timer)
|
||
timer = null
|
||
}
|
||
if (visHandler) {
|
||
document.removeEventListener('visibilitychange', visHandler)
|
||
visHandler = null
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.status-page {
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
gap: 14px;
|
||
}
|
||
|
||
/* top bar */
|
||
.topbar {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
padding: 14px 18px;
|
||
}
|
||
.tb-left { display: flex; flex-direction: column; gap: 3px; }
|
||
.page-title { margin: 0; font-size: 17px; font-weight: 800; }
|
||
.page-sub { font-size: 12px; color: $color-text-muted; }
|
||
.tb-right { display: flex; gap: 8px; }
|
||
|
||
/* KPI grid */
|
||
.kpis {
|
||
flex-shrink: 0;
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(168px, 1fr));
|
||
gap: 14px;
|
||
}
|
||
|
||
.status-body {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 18px;
|
||
overflow-y: auto;
|
||
padding-right: 4px;
|
||
}
|
||
|
||
.section { display: flex; flex-direction: column; }
|
||
.section-title {
|
||
margin: 0 0 10px;
|
||
font-size: 13.5px;
|
||
font-weight: 700;
|
||
color: $color-text-muted;
|
||
letter-spacing: 0.02em;
|
||
display: flex;
|
||
align-items: center;
|
||
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); }
|
||
}
|
||
|
||
.card-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||
gap: 14px;
|
||
}
|
||
|
||
/* remote node card */
|
||
.node-card {
|
||
padding: 16px 18px;
|
||
border-left: 4px solid var(--w4f-danger);
|
||
&.healthy { border-left-color: var(--w4f-ok); }
|
||
}
|
||
.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;
|
||
&.warn { background: $color-warning-50; color: $color-warning; }
|
||
&.err { background: $color-danger-50; color: $color-danger; }
|
||
&.info { background: $color-secondary-50; color: $color-secondary-h; }
|
||
}
|
||
.nc-forwards { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
||
.nc-groups { margin-top: 6px; }
|
||
.nc-proxies { display: flex; flex-direction: column; gap: 6px; margin-top: 10px; }
|
||
.nc-proxy { display: flex; align-items: center; gap: 8px; font-size: 12px; padding: 6px 10px; border-radius: $radius-xs;
|
||
background: var(--w4f-card-2); border: 1px solid var(--w4f-line);
|
||
&.ok { border-left: 3px solid $color-success; }
|
||
&.pending { border-left: 3px solid $color-secondary; }
|
||
&.err { border-left: 3px solid $color-danger; }
|
||
.w4f-bar { width: 60px; }
|
||
}
|
||
.np-name { font-weight: 700; }
|
||
.np-status { font-size: 11px; color: $color-text-secondary; }
|
||
.np-addr { margin-left: auto; font-size: 11px; color: $color-text-muted; font-family: var(--w4f-mono); }
|
||
.np-err { margin-left: auto; font-size: 11px; color: $color-danger; max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.nc-proxies-empty { margin-top: 8px; font-size: 12px; color: $color-text-muted; }
|
||
|
||
/* 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); } &.readonly { cursor: default; &:hover { background: none; } } }
|
||
|
||
/* local service card */
|
||
.local-card { padding: 14px 16px; }
|
||
.lc-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||
.lc-name { font-weight: 800; font-size: 14px; }
|
||
.lc-addr { font-size: 12px; color: $color-text-secondary; font-family: var(--w4f-mono); }
|
||
.lc-targets { margin-top: 10px; display: flex; flex-direction: column; gap: 6px; }
|
||
.lc-target { display: flex; align-items: center; gap: 6px; font-size: 13px; }
|
||
.lc-arrow { color: var(--w4f-primary); font-weight: 700; }
|
||
.lc-remote { font-weight: 600; }
|
||
.lc-port { color: $color-text-secondary; font-family: var(--w4f-mono); margin-right: auto; }
|
||
.lc-none { font-size: 12px; color: $color-text-light; }
|
||
|
||
.empty { grid-column: 1 / -1; color: $color-text-muted; font-size: 13px; padding: 18px; text-align: center; }
|
||
|
||
/* dialog form */
|
||
.form-row { display: flex; align-items: center; gap: 10px; margin-bottom: 12px;
|
||
label { width: 72px; font-size: 13px; color: $color-text-secondary; flex-shrink: 0; }
|
||
}
|
||
</style>
|