Files
webui4frpc/web/src/views/StatusView.vue
JianFeeeee f90627f542 Merge remote-tracking branch 'origin/main'
# 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
2026-08-24 23:30:19 +08:00

642 lines
26 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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">尚未配置转发 前往连接配置画布编排 localremote 连线</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>