feat(api): 新增节点墙状态检测闭环

新增父节点墙状态检测任务、结果上报与节点列表状态装饰,
支持子节点继承父节点检测结果并通过 WS/REST 双链路执行

管理端补充墙状态筛选、搜索、单行与批量检测入口,
同时更新知识库归档并新增后续自动上线方案包
This commit is contained in:
yinjianm
2026-04-27 23:45:44 +08:00
parent b3a8d504d1
commit 9af9dd0df7
23 changed files with 1365 additions and 7 deletions
+5
View File
@@ -25,6 +25,7 @@ import type {
AdminNoticeSavePayload,
AdminNodeItem,
AdminNodeBatchUpdatePayload,
AdminNodeGfwCheckResult,
AdminNodeSavePayload,
AdminNodeRouteItem,
AdminNodeRouteSavePayload,
@@ -518,6 +519,10 @@ export function batchDeleteNodes(ids: number[]): Promise<ApiResponse<boolean>> {
return unwrapPost<boolean>('/server/manage/batchDelete', { ids })
}
export function checkNodeGfw(ids: number[]): Promise<ApiResponse<AdminNodeGfwCheckResult>> {
return unwrapPost<AdminNodeGfwCheckResult>('/server/manage/checkGfw', { ids })
}
export function saveNode(payload: AdminNodeSavePayload): Promise<ApiResponse<boolean>> {
return unwrapPost<boolean>('/server/manage/save', payload as unknown as Record<string, unknown>)
}
+38
View File
@@ -896,6 +896,44 @@ export interface AdminNodeItem {
metrics?: AdminNodeMetrics | null
groups?: AdminServerGroupItem[]
parent?: AdminNodeParentRef | null
gfw_check?: AdminNodeGfwCheck | null
}
export type AdminNodeGfwStatus =
| 'unchecked'
| 'pending'
| 'checking'
| 'normal'
| 'blocked'
| 'partial'
| 'failed'
| 'skipped'
export interface AdminNodeGfwCheck {
id?: number
status: AdminNodeGfwStatus | string
inherited?: boolean
source_node_id?: number | null
summary?: Record<string, unknown> | null
operator_summary?: Record<string, unknown> | null
error_message?: string | null
checked_at?: number | null
updated_at?: number | null
}
export interface AdminNodeGfwCheckResult {
started: Array<{
id: number
check_id: number
status: AdminNodeGfwStatus | string
}>
skipped: Array<{
id: number
status: AdminNodeGfwStatus | string
reason?: string
source_node_id?: number
}>
total: number
}
export interface AdminNodeUpdatePayload {
+98
View File
@@ -1,6 +1,7 @@
import type { AdminNodeItem } 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 interface NodeStatusMeta {
@@ -11,6 +12,14 @@ export interface NodeStatusMeta {
type NodeStatusClass = NodeStatusMeta['dotClass']
export interface NodeGfwMeta {
label: string
searchText: string
tagType: 'success' | 'warning' | 'danger' | 'info' | 'primary'
tone: 'normal' | 'blocked' | 'partial' | 'failed' | 'unchecked' | 'checking'
inherited: boolean
}
const NODE_TYPE_LABELS: Record<string, string> = {
shadowsocks: 'Shadowsocks',
trojan: 'Trojan',
@@ -66,6 +75,83 @@ export function getNodeStatusMeta(node: AdminNodeItem): NodeStatusMeta {
}
}
export function getNodeGfwMeta(node: AdminNodeItem): NodeGfwMeta {
const status = normalizeText(node.gfw_check?.status || 'unchecked')
const inherited = Boolean(node.gfw_check?.inherited)
const inheritedPrefix = inherited ? '随父节点 · ' : ''
if (status === 'normal') {
return {
label: `${inheritedPrefix}正常`,
searchText: `${inherited ? '随父节点 继承 ' : ''}正常 未被墙 墙正常 gfw normal`,
tagType: 'success',
tone: 'normal',
inherited,
}
}
if (status === 'blocked') {
return {
label: `${inheritedPrefix}疑似被墙`,
searchText: `${inherited ? '随父节点 继承 ' : ''}被墙 疑似被墙 gfw blocked`,
tagType: 'danger',
tone: 'blocked',
inherited,
}
}
if (status === 'partial') {
return {
label: `${inheritedPrefix}部分异常`,
searchText: `${inherited ? '随父节点 继承 ' : ''}异常 部分异常 gfw partial`,
tagType: 'warning',
tone: 'partial',
inherited,
}
}
if (status === 'failed') {
return {
label: `${inheritedPrefix}检测失败`,
searchText: `${inherited ? '随父节点 继承 ' : ''}失败 检测失败 异常 gfw failed`,
tagType: 'danger',
tone: 'failed',
inherited,
}
}
if (status === 'pending' || status === 'checking') {
return {
label: `${inheritedPrefix}检测中`,
searchText: `${inherited ? '随父节点 继承 ' : ''}检测中 等待检测 gfw checking pending`,
tagType: 'primary',
tone: 'checking',
inherited,
}
}
return {
label: inherited ? '随父节点 · 未检测' : '未检测',
searchText: `${inherited ? '随父节点 继承 ' : ''}未检测 unchecked`,
tagType: 'info',
tone: 'unchecked',
inherited,
}
}
export function getNodeGfwTooltip(node: AdminNodeItem): string {
const meta = getNodeGfwMeta(node)
const source = node.gfw_check?.source_node_id
const checkedAt = node.gfw_check?.checked_at
? new Date(Number(node.gfw_check.checked_at) * 1000).toLocaleString()
: ''
const sourceText = meta.inherited && source ? `,来源父节点 #${source}` : ''
const timeText = checkedAt ? `,检测时间 ${checkedAt}` : ''
const errorText = node.gfw_check?.error_message ? `,错误:${node.gfw_check.error_message}` : ''
return `${meta.label}${sourceText}${timeText}${errorText}`
}
function isNodeOnlineStatus(status: NodeStatusClass): boolean {
return status === 'online' || status === 'pending'
}
@@ -113,6 +199,7 @@ function buildNodeSearchText(node: AdminNodeItem): string {
node.port,
node.server_port,
getNodeTypeLabel(node.type),
getNodeGfwMeta(node).searchText,
...getNodeGroupNames(node),
]
.map((item) => String(item ?? '').trim())
@@ -128,12 +215,14 @@ export function filterNodes(
groupFilter: string,
statusFilter: NodeStatusFilter = 'all',
relationFilter: NodeRelationFilter = 'all',
gfwFilter: NodeGfwFilter = 'all',
): AdminNodeItem[] {
const normalizedKeyword = normalizeText(keyword)
const normalizedType = normalizeText(typeFilter)
const normalizedGroup = normalizeText(groupFilter)
const normalizedStatus = normalizeText(statusFilter)
const normalizedRelation = normalizeText(relationFilter)
const normalizedGfw = normalizeText(gfwFilter)
return nodes.filter((node) => {
if (normalizedKeyword && !buildNodeSearchText(node).includes(normalizedKeyword)) {
@@ -168,6 +257,15 @@ export function filterNodes(
return false
}
const gfwMeta = getNodeGfwMeta(node)
if (normalizedGfw === 'inherited' && !gfwMeta.inherited) {
return false
}
if (normalizedGfw !== '' && normalizedGfw !== 'all' && normalizedGfw !== 'inherited' && gfwMeta.tone !== normalizedGfw) {
return false
}
return true
})
}
+112 -3
View File
@@ -15,6 +15,7 @@ import {
import {
batchDeleteNodes,
batchUpdateNodes,
checkNodeGfw,
copyNode,
deleteNode,
fetchNodes,
@@ -38,17 +39,20 @@ import {
countVisibleNodes,
filterNodes,
formatNodeRate,
getNodeGfwMeta,
getNodeGfwTooltip,
getNodeAddress,
getNodeGroupNames,
getNodeIdLabel,
getNodeStatusMeta,
getNodeTypeLabel,
type NodeRelationFilter,
type NodeGfwFilter,
type NodeStatusFilter,
} from '@/utils/nodes'
import { sortNodesByOrder } from '@/utils/nodeEditor'
type NodeAction = 'edit' | 'copy' | 'pin-top' | 'delete'
type NodeAction = 'edit' | 'copy' | 'pin-top' | 'delete' | 'check-gfw'
type NodeDialogMode = 'create' | 'edit'
type NodeBatchEditPayload = Omit<AdminNodeBatchUpdatePayload, 'ids'>
@@ -65,6 +69,7 @@ const typeFilter = ref('all')
const groupFilter = ref('all')
const statusFilter = ref<NodeStatusFilter>('all')
const relationFilter = ref<NodeRelationFilter>('all')
const gfwFilter = ref<NodeGfwFilter>('all')
const currentPage = ref(1)
const pageSize = ref(20)
const selectedNodeIds = ref<number[]>([])
@@ -78,6 +83,7 @@ const sortDialogVisible = ref(false)
const batchEditVisible = ref(false)
const batchSubmitting = ref(false)
const batchDeleting = ref(false)
const batchGfwChecking = ref(false)
const filteredNodes = computed(() => sortNodesByOrder(filterNodes(
nodes.value,
@@ -86,6 +92,7 @@ const filteredNodes = computed(() => sortNodesByOrder(filterNodes(
groupFilter.value,
statusFilter.value,
relationFilter.value,
gfwFilter.value,
)))
const paginatedNodes = computed(() => {
@@ -102,6 +109,7 @@ const hasActiveFilters = computed(() => (
|| groupFilter.value !== 'all'
|| statusFilter.value !== 'all'
|| relationFilter.value !== 'all'
|| gfwFilter.value !== 'all'
))
const summaryCards = computed(() => [
@@ -238,6 +246,7 @@ function handleReset() {
groupFilter.value = 'all'
statusFilter.value = 'all'
relationFilter.value = 'all'
gfwFilter.value = 'all'
currentPage.value = 1
}
@@ -337,6 +346,59 @@ async function handleBatchDelete() {
}
}
async function handleCheckGfw(ids: number[], label: string) {
if (ids.length === 0) {
ElMessage.warning('请先选择需要检测的节点')
return
}
try {
const response = await checkNodeGfw(ids)
const started = response.data?.started?.length ?? 0
const skipped = response.data?.skipped?.length ?? 0
if (started > 0) {
ElMessage.success(`${label}已发起墙状态检测,${started} 个父节点等待上报`)
} else if (skipped > 0) {
ElMessage.info('所选节点均为子节点,墙状态随父节点显示')
} else {
ElMessage.info('没有可检测的节点')
}
await loadNodeBoard()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '墙状态检测发起失败')
}
}
async function handleBatchCheckGfw() {
if (!hasSelectedNodes.value) {
ElMessage.warning('请先勾选需要检测的节点')
return
}
batchGfwChecking.value = true
try {
await handleCheckGfw([...selectedNodeIds.value], '批量')
} finally {
batchGfwChecking.value = false
}
}
async function handleNodeCheckGfw(node: AdminNodeItem) {
if (node.parent_id) {
ElMessage.info('子节点不单独检测,墙状态随父节点显示')
return
}
markPending(workingIds, node.id, true)
try {
await handleCheckGfw([node.id], '')
} finally {
markPending(workingIds, node.id, false)
}
}
async function handleToggleShow(node: AdminNodeItem, nextValue: boolean) {
const previous = Boolean(node.show)
if (previous === nextValue) {
@@ -396,6 +458,11 @@ async function handleAction(action: NodeAction, node: AdminNodeItem) {
return
}
if (action === 'check-gfw') {
await handleNodeCheckGfw(node)
return
}
markPending(workingIds, node.id, true)
try {
@@ -437,7 +504,7 @@ watch(
},
)
watch([keyword, typeFilter, groupFilter, statusFilter, relationFilter], () => {
watch([keyword, typeFilter, groupFilter, statusFilter, relationFilter, gfwFilter], () => {
currentPage.value = 1
})
@@ -530,11 +597,30 @@ watch(
<ElOption label="父节点" value="parent" />
<ElOption label="子节点" value="child" />
</ElSelect>
<ElSelect v-model="gfwFilter" class="toolbar-select" placeholder="墙状态">
<ElOption label="全部墙状态" value="all" />
<ElOption label="正常" value="normal" />
<ElOption label="疑似被墙" value="blocked" />
<ElOption label="部分异常" value="partial" />
<ElOption label="检测失败" value="failed" />
<ElOption label="检测中" value="checking" />
<ElOption label="未检测" value="unchecked" />
<ElOption label="随父节点" value="inherited" />
</ElSelect>
</div>
<div class="toolbar-actions">
<span class="scope-hint">{{ batchTargetLabel }}</span>
<ElButton :disabled="!hasSelectedNodes || batchDeleting" @click="openBatchEditor">批量修改</ElButton>
<ElButton
:disabled="!hasSelectedNodes || batchGfwChecking"
:loading="batchGfwChecking"
@click="handleBatchCheckGfw"
>
<ElIcon><Connection /></ElIcon>
检测墙状态
</ElButton>
<ElButton
type="danger"
plain
@@ -620,6 +706,17 @@ watch(
<ElTag round effect="plain" :type="getNodeStatusMeta(row).tagType">
{{ getNodeStatusMeta(row).label }}
</ElTag>
<ElTooltip :content="getNodeGfwTooltip(row)" placement="top">
<ElTag
round
effect="plain"
:type="getNodeGfwMeta(row).tagType"
class="gfw-tag"
:class="`gfw-tag--${getNodeGfwMeta(row).tone}`"
>
{{ getNodeGfwMeta(row).label }}
</ElTag>
</ElTooltip>
<span>{{ getNodeTypeLabel(row.type) }}</span>
</div>
</div>
@@ -687,6 +784,9 @@ watch(
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem command="edit">编辑节点</ElDropdownItem>
<ElDropdownItem command="check-gfw" :disabled="Boolean(row.parent_id)">
检测墙状态
</ElDropdownItem>
<ElDropdownItem command="pin-top">置顶节点</ElDropdownItem>
<ElDropdownItem command="copy">复制节点</ElDropdownItem>
<ElDropdownItem command="delete" divided>删除节点</ElDropdownItem>
@@ -907,6 +1007,10 @@ watch(
gap: 8px;
}
.node-cell__sub {
flex-wrap: wrap;
}
.node-cell__main strong,
.stack-cell strong {
color: var(--xboard-text-strong);
@@ -950,10 +1054,15 @@ watch(
}
.rate-tag,
.id-tag {
.id-tag,
.gfw-tag {
font-variant-numeric: tabular-nums;
}
.gfw-tag {
max-width: 150px;
}
.action-trigger {
font-size: 18px;
}