feat(api): 新增节点墙状态检测闭环
新增父节点墙状态检测任务、结果上报与节点列表状态装饰, 支持子节点继承父节点检测结果并通过 WS/REST 双链路执行 管理端补充墙状态筛选、搜索、单行与批量检测入口, 同时更新知识库归档并新增后续自动上线方案包
This commit is contained in:
@@ -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>)
|
||||
}
|
||||
|
||||
Vendored
+38
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user