feat(api): 新增节点流量悬浮详情与即时自动上线同步
为 server/manage/getNodes 返回节点级今日、本月与累计流量统计, 并在节点管理页名称悬浮层展示上行、下行和合计流量。 同时为自动上线补齐单节点同步入口,在管理端保存、 批量更新以及 REST/WS 心跳后立即同步 show 状态, 避免复制节点后开启自动上线仍需等待定时任务。 另优化管理端前端 Docker 发布流程,默认仅构建 amd64, 并收敛 BuildKit 缓存导出以缩短发布时间
This commit is contained in:
Vendored
+10
@@ -862,6 +862,12 @@ export interface AdminNodeMetrics {
|
||||
updated_at?: number
|
||||
}
|
||||
|
||||
export interface AdminNodeTrafficStats {
|
||||
today: TrafficAmount
|
||||
month: TrafficAmount
|
||||
total: TrafficAmount
|
||||
}
|
||||
|
||||
export interface AdminNodeRateTimeRange {
|
||||
start: string
|
||||
end: string
|
||||
@@ -887,6 +893,9 @@ export interface AdminNodeItem {
|
||||
enabled?: boolean
|
||||
parent_id?: number | null
|
||||
rate?: number | null
|
||||
transfer_enable?: number | null
|
||||
u?: number | null
|
||||
d?: number | null
|
||||
rate_time_enable?: boolean
|
||||
rate_time_ranges?: AdminNodeRateTimeRange[] | null
|
||||
sort?: number | null
|
||||
@@ -898,6 +907,7 @@ export interface AdminNodeItem {
|
||||
last_check_at?: number | null
|
||||
last_push_at?: number | null
|
||||
metrics?: AdminNodeMetrics | null
|
||||
traffic_stats?: AdminNodeTrafficStats | null
|
||||
groups?: AdminServerGroupItem[]
|
||||
parent?: AdminNodeParentRef | null
|
||||
gfw_check?: AdminNodeGfwCheck | null
|
||||
|
||||
+1
@@ -35,6 +35,7 @@ declare module 'vue' {
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||
ElPopover: typeof import('element-plus/es')['ElPopover']
|
||||
ElProgress: typeof import('element-plus/es')['ElProgress']
|
||||
ElSegmented: typeof import('element-plus/es')['ElSegmented']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { AdminNodeItem } from '@/types/api'
|
||||
import type { AdminNodeItem, TrafficAmount } from '@/types/api'
|
||||
|
||||
export type NodeRelationFilter = 'all' | 'parent' | 'child'
|
||||
export type NodeGfwFilter = 'all' | 'normal' | 'blocked' | 'partial' | 'failed' | 'unchecked' | 'checking' | 'inherited'
|
||||
export type NodeStatusFilter = 'all' | 'online' | 'offline'
|
||||
export type NodeVisibilityFilter = 'all' | 'visible' | 'hidden'
|
||||
|
||||
export interface NodeStatusMeta {
|
||||
label: string
|
||||
@@ -20,6 +21,20 @@ export interface NodeGfwMeta {
|
||||
inherited: boolean
|
||||
}
|
||||
|
||||
export interface NodeTrafficDetail {
|
||||
key: 'today' | 'month' | 'total'
|
||||
label: string
|
||||
upload: string
|
||||
download: string
|
||||
total: string
|
||||
}
|
||||
|
||||
type TrafficAmountLike = {
|
||||
upload?: number | string | null
|
||||
download?: number | string | null
|
||||
total?: number | string | null
|
||||
}
|
||||
|
||||
const NODE_TYPE_LABELS: Record<string, string> = {
|
||||
shadowsocks: 'Shadowsocks',
|
||||
trojan: 'Trojan',
|
||||
@@ -34,10 +49,41 @@ const NODE_TYPE_LABELS: Record<string, string> = {
|
||||
mieru: 'Mieru',
|
||||
}
|
||||
|
||||
const TRAFFIC_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
|
||||
|
||||
function normalizeText(value: unknown): string {
|
||||
return String(value ?? '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
function normalizeTrafficValue(value: unknown): number {
|
||||
const normalized = Number(value)
|
||||
return Number.isFinite(normalized) && normalized > 0 ? normalized : 0
|
||||
}
|
||||
|
||||
function normalizeTrafficAmount(amount?: TrafficAmountLike | null): TrafficAmount {
|
||||
const upload = normalizeTrafficValue(amount?.upload)
|
||||
const download = normalizeTrafficValue(amount?.download)
|
||||
const total = normalizeTrafficValue(amount?.total) || upload + download
|
||||
|
||||
return { upload, download, total }
|
||||
}
|
||||
|
||||
export function formatTrafficBytes(value?: number | string | null): string {
|
||||
let amount = normalizeTrafficValue(value)
|
||||
let unitIndex = 0
|
||||
|
||||
while (amount >= 1024 && unitIndex < TRAFFIC_UNITS.length - 1) {
|
||||
amount /= 1024
|
||||
unitIndex += 1
|
||||
}
|
||||
|
||||
if (unitIndex === 0) {
|
||||
return `${Math.round(amount)} B`
|
||||
}
|
||||
|
||||
return `${amount >= 100 ? amount.toFixed(0) : amount.toFixed(2)} ${TRAFFIC_UNITS[unitIndex]}`
|
||||
}
|
||||
|
||||
export function getNodeTypeLabel(type: string): string {
|
||||
const normalized = normalizeText(type)
|
||||
return NODE_TYPE_LABELS[normalized] ?? String(type || '未知协议').toUpperCase()
|
||||
@@ -190,6 +236,31 @@ export function formatNodeRate(rate?: number | null): string {
|
||||
return `${normalized.toFixed(2)} x`
|
||||
}
|
||||
|
||||
export function getNodeTrafficDetails(node: AdminNodeItem): NodeTrafficDetail[] {
|
||||
const stats = node.traffic_stats
|
||||
const totalFallback = normalizeTrafficAmount({
|
||||
upload: node.u ?? 0,
|
||||
download: node.d ?? 0,
|
||||
})
|
||||
|
||||
const rows: Array<{ key: NodeTrafficDetail['key']; label: string; source?: TrafficAmountLike | null }> = [
|
||||
{ key: 'today', label: '今日', source: stats?.today },
|
||||
{ key: 'month', label: '本月', source: stats?.month },
|
||||
{ key: 'total', label: '累计', source: stats?.total ?? totalFallback },
|
||||
]
|
||||
|
||||
return rows.map((row) => {
|
||||
const amount = normalizeTrafficAmount(row.source)
|
||||
return {
|
||||
key: row.key,
|
||||
label: row.label,
|
||||
upload: formatTrafficBytes(amount.upload),
|
||||
download: formatTrafficBytes(amount.download),
|
||||
total: formatTrafficBytes(amount.total),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function getNodeGroupNames(node: AdminNodeItem): string[] {
|
||||
return (node.groups ?? [])
|
||||
.map((group) => group.name)
|
||||
@@ -231,6 +302,7 @@ export function filterNodes(
|
||||
typeFilter: string,
|
||||
groupFilter: string,
|
||||
statusFilter: NodeStatusFilter = 'all',
|
||||
visibilityFilter: NodeVisibilityFilter = 'all',
|
||||
relationFilter: NodeRelationFilter = 'all',
|
||||
gfwFilter: NodeGfwFilter = 'all',
|
||||
): AdminNodeItem[] {
|
||||
@@ -238,6 +310,7 @@ export function filterNodes(
|
||||
const normalizedType = normalizeText(typeFilter)
|
||||
const normalizedGroup = normalizeText(groupFilter)
|
||||
const normalizedStatus = normalizeText(statusFilter)
|
||||
const normalizedVisibility = normalizeText(visibilityFilter)
|
||||
const normalizedRelation = normalizeText(relationFilter)
|
||||
const normalizedGfw = normalizeText(gfwFilter)
|
||||
|
||||
@@ -266,6 +339,14 @@ export function filterNodes(
|
||||
return false
|
||||
}
|
||||
|
||||
if (normalizedVisibility === 'visible' && !Boolean(node.show)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (normalizedVisibility === 'hidden' && Boolean(node.show)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (normalizedRelation === 'parent' && node.parent_id) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -47,10 +47,12 @@ import {
|
||||
getNodeGroupNames,
|
||||
getNodeIdLabel,
|
||||
getNodeStatusMeta,
|
||||
getNodeTrafficDetails,
|
||||
getNodeTypeLabel,
|
||||
type NodeRelationFilter,
|
||||
type NodeGfwFilter,
|
||||
type NodeStatusFilter,
|
||||
type NodeVisibilityFilter,
|
||||
} from '@/utils/nodes'
|
||||
import { sortNodesByOrder } from '@/utils/nodeEditor'
|
||||
|
||||
@@ -70,6 +72,7 @@ const keyword = ref('')
|
||||
const typeFilter = ref('all')
|
||||
const groupFilter = ref('all')
|
||||
const statusFilter = ref<NodeStatusFilter>('all')
|
||||
const visibilityFilter = ref<NodeVisibilityFilter>('all')
|
||||
const relationFilter = ref<NodeRelationFilter>('all')
|
||||
const gfwFilter = ref<NodeGfwFilter>('all')
|
||||
const currentPage = ref(1)
|
||||
@@ -99,6 +102,7 @@ const filteredNodes = computed(() => sortNodesByOrder(filterNodes(
|
||||
typeFilter.value,
|
||||
groupFilter.value,
|
||||
statusFilter.value,
|
||||
visibilityFilter.value,
|
||||
relationFilter.value,
|
||||
gfwFilter.value,
|
||||
)))
|
||||
@@ -116,6 +120,7 @@ const hasActiveFilters = computed(() => (
|
||||
|| typeFilter.value !== 'all'
|
||||
|| groupFilter.value !== 'all'
|
||||
|| statusFilter.value !== 'all'
|
||||
|| visibilityFilter.value !== 'all'
|
||||
|| relationFilter.value !== 'all'
|
||||
|| gfwFilter.value !== 'all'
|
||||
))
|
||||
@@ -304,6 +309,7 @@ function handleReset() {
|
||||
typeFilter.value = 'all'
|
||||
groupFilter.value = 'all'
|
||||
statusFilter.value = 'all'
|
||||
visibilityFilter.value = 'all'
|
||||
relationFilter.value = 'all'
|
||||
gfwFilter.value = 'all'
|
||||
currentPage.value = 1
|
||||
@@ -647,7 +653,7 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
watch([keyword, typeFilter, groupFilter, statusFilter, relationFilter, gfwFilter], () => {
|
||||
watch([keyword, typeFilter, groupFilter, statusFilter, visibilityFilter, relationFilter, gfwFilter], () => {
|
||||
currentPage.value = 1
|
||||
})
|
||||
|
||||
@@ -735,6 +741,12 @@ watch(
|
||||
<ElOption label="离线节点" value="offline" />
|
||||
</ElSelect>
|
||||
|
||||
<ElSelect v-model="visibilityFilter" class="toolbar-select" placeholder="显隐">
|
||||
<ElOption label="全部显隐" value="all" />
|
||||
<ElOption label="显示中" value="visible" />
|
||||
<ElOption label="已隐藏" value="hidden" />
|
||||
</ElSelect>
|
||||
|
||||
<ElSelect v-model="relationFilter" class="toolbar-select" placeholder="节点关系">
|
||||
<ElOption label="全部节点" value="all" />
|
||||
<ElOption label="父节点" value="parent" />
|
||||
@@ -878,10 +890,39 @@ watch(
|
||||
<ElTableColumn label="节点" min-width="280">
|
||||
<template #default="{ row }">
|
||||
<div class="node-cell">
|
||||
<div class="node-cell__main">
|
||||
<span class="node-dot" :class="getNodeStatusMeta(row).dotClass" />
|
||||
<strong>{{ row.name }}</strong>
|
||||
</div>
|
||||
<ElPopover
|
||||
placement="right-start"
|
||||
trigger="hover"
|
||||
popper-class="node-traffic-popover"
|
||||
:width="360"
|
||||
>
|
||||
<template #reference>
|
||||
<button class="node-cell__main node-name-trigger" type="button">
|
||||
<span class="node-dot" :class="getNodeStatusMeta(row).dotClass" />
|
||||
<strong>{{ row.name }}</strong>
|
||||
</button>
|
||||
</template>
|
||||
<div class="node-traffic-card">
|
||||
<header class="node-traffic-card__header">
|
||||
<span>流量统计</span>
|
||||
<strong>{{ row.name }}</strong>
|
||||
</header>
|
||||
<article
|
||||
v-for="traffic in getNodeTrafficDetails(row)"
|
||||
:key="`${row.id}-${traffic.key}`"
|
||||
class="node-traffic-row"
|
||||
>
|
||||
<div class="node-traffic-row__summary">
|
||||
<span>{{ traffic.label }}</span>
|
||||
<strong>{{ traffic.total }}</strong>
|
||||
</div>
|
||||
<div class="node-traffic-row__split">
|
||||
<span>上行 {{ traffic.upload }}</span>
|
||||
<span>下行 {{ traffic.download }}</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</ElPopover>
|
||||
<div class="node-cell__sub">
|
||||
<ElTag round effect="plain" :type="getNodeStatusMeta(row).tagType">
|
||||
{{ getNodeStatusMeta(row).label }}
|
||||
@@ -1227,6 +1268,32 @@ watch(
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.node-name-trigger {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.node-name-trigger strong {
|
||||
transition: color 0.18s ease;
|
||||
}
|
||||
|
||||
.node-name-trigger:hover strong,
|
||||
.node-name-trigger:focus-visible strong {
|
||||
color: #0071e3;
|
||||
}
|
||||
|
||||
.node-name-trigger:focus-visible {
|
||||
outline: 2px solid #0071e3;
|
||||
outline-offset: 3px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.node-cell__sub {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@@ -1305,6 +1372,72 @@ watch(
|
||||
color: var(--xboard-text-muted);
|
||||
}
|
||||
|
||||
:global(.node-traffic-popover) {
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
border-radius: 18px !important;
|
||||
box-shadow: rgba(0, 0, 0, 0.18) 0 12px 36px 0 !important;
|
||||
}
|
||||
|
||||
:global(.node-traffic-popover .node-traffic-card) {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
color: #1d1d1f;
|
||||
}
|
||||
|
||||
:global(.node-traffic-card__header) {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
padding: 2px 2px 6px;
|
||||
}
|
||||
|
||||
:global(.node-traffic-card__header span) {
|
||||
color: rgba(0, 0, 0, 0.48);
|
||||
font-size: 12px;
|
||||
line-height: 1.33;
|
||||
}
|
||||
|
||||
:global(.node-traffic-card__header strong) {
|
||||
color: #1d1d1f;
|
||||
font-size: 15px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
:global(.node-traffic-row) {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
background: #f5f5f7;
|
||||
}
|
||||
|
||||
:global(.node-traffic-row__summary),
|
||||
:global(.node-traffic-row__split) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
:global(.node-traffic-row__summary span),
|
||||
:global(.node-traffic-row__split span) {
|
||||
color: rgba(0, 0, 0, 0.56);
|
||||
font-size: 12px;
|
||||
line-height: 1.33;
|
||||
}
|
||||
|
||||
:global(.node-traffic-row__summary strong) {
|
||||
color: #0071e3;
|
||||
font-size: 17px;
|
||||
line-height: 1.19;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
:global(.node-traffic-row__split span) {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.nodes-hero,
|
||||
.board-toolbar,
|
||||
|
||||
Reference in New Issue
Block a user