@@ -0,0 +1,88 @@
< template >
< BasicLayout >
< template # wrapper >
< el-card class = "box-card" >
< div class = "page-header" >
< div > < h3 > 边缘节点 < / h3 > < p > 查看各现场节点的运行负载 、 连接状态和待回填数据 。 离线时保留最后一次已知状态 。 < / p > < / div >
< el-button :icon = "Refresh" :loading = "loading" @click ="load" > 刷新状态 < / el -button >
< / div >
< el-alert v-if = "summary.offline" type="warning" :closable="false" show-icon class="status-alert" >
< template # title > { { summary . offline } } 个节点离线 < / template >
离线阈值为 { { summary . heartbeatTimeoutSeconds } } 秒 。 页面中的隧道 、 视频和回填数据是节点离线前的最后已知状态 。
< / el-alert >
< el-row :gutter = "12" class = "summary-row" aria -label = " 边缘节点概览 " >
< el-col v-for = "item in summaryCards" :key="item.label" :xs="12" :sm="6" >
< div class = "summary-item" > < span > { { item . label } } < / span > < strong :class = "item.className" > { { item . value } } < / strong > < / div >
< / el-col >
< / el-row >
< el-table v-loading = "loading" :data="nodes" border stripe empty-text="暂无边缘节点" >
< el -table -column label = "节点" min -width = " 180 " >
< template # default = "scope" > < strong > { { scope . row . name } } < / strong > < div class = "muted" > { { scope . row . id } } · { { scope . row . location || '未填写位置' } } < / div > < / template >
< / el-table-column >
< el-table-column label = "状态" width = "106" align = "center" > < template # default = "scope" > < el-tag :type = "stateType(scope.row.status)" > { { stateLabel ( scope . row . status ) } } < / el-tag > < / template > < / el-table-column >
< el-table-column label = "最近心跳" min -width = " 150 " > < template # default = "scope" > < div > { { freshnessText ( scope . row ) } } < / div > < small > { { formatTime ( scope . row . lastHeartbeatAt ) } } < / small > < / template > < / el-table-column >
< el-table-column label = "负载" min -width = " 112 " > < template # default = "scope" > { { scope . row . loadUsed } } / { { scope . row . loadCapacity } } 路 < / template > < / el-table-column >
< el-table-column label = "控制隧道" min -width = " 142 " > < template # default = "scope" > < el-tag size = "small" :type = "stateType(scope.row.controlTunnelStatus)" > { { stateLabel ( scope . row . controlTunnelStatus ) } } < / el-tag > < div class = "muted" > { { scope . row . controlLatencyMs ? ` ${ scope . row . controlLatencyMs } ms ` : scope . row . controlTunnelDetail } } < / div > < / template > < / el-table-column >
< el-table-column label = "视频数据面" min -width = " 150 " > < template # default = "scope" > < el-tag size = "small" :type = "stateType(scope.row.videoPlaneStatus)" > { { stateLabel ( scope . row . videoPlaneStatus ) } } < / el-tag > < div class = "muted" > { { scope . row . activeStreamCount } } 路视频 · { { scope . row . videoPlaneDetail } } < / div > < / template > < / el-table-column >
< el-table-column label = "回填队列" min -width = " 128 " > < template # default = "scope" > < el-tag size = "small" :type = "stateType(scope.row.backfillStatus)" > { { stateLabel ( scope . row . backfillStatus ) } } < / el-tag > < div class = "muted" > { { scope . row . backfillQueueDepth } } 条待回填 < / div > < / template > < / el-table-column >
< el-table-column label = "版本" min -width = " 130 " > < template # default = "scope" > < div > { { scope . row . runtimeVersion || '—' } } < / div > < small > { { scope . row . modelVersion || '未部署模型' } } < / small > < / template > < / el-table-column >
< el-table-column label = "操作" width = "92" fixed = "right" > < template # default = "scope" > < el-button v-permisaction = "['sense:edge-node:detail']" type="primary" link @click="openDetail(scope.row.id)" > 详情 < / el -button > < / template > < / el-table-column >
< / el-table >
< pagination v-show = "total > 0" v-model:current-page="query.pageIndex" v-model:page-size="query.pageSize" :total="total" @pagination="load" / >
< / el-card >
< el-dialog v-model = "detailOpen" title="边缘节点详情" width="min(800px, calc(100vw - 24px))" :close-on-click-modal="false" >
< template v-if = "selected" >
< el -alert v-if = "selected.stale" title="当前显示最后已知状态" type="warning" :closable="false" show-icon class="detail-alert" > 最近心跳距今 {{ durationText ( selected.freshnessSeconds ) }} ; 连接恢复前 , 下列通道数据不会继续更新 。 < / el -alert >
< el-descriptions :column = "2" border >
< el-descriptions-item label = "节点" > { { selected . name } } ( { { selected . id } } ) < / el-descriptions-item > < el-descriptions-item label = "位置" > { { selected . location || '未填写' } } < / el-descriptions-item >
< el-descriptions-item label = "运行版本" > { { selected . runtimeVersion || '—' } } < / el-descriptions-item > < el-descriptions-item label = "模型版本" > { { selected . modelVersion || '未部署' } } < / el-descriptions-item >
< el-descriptions-item label = "运行时长" > { { durationText ( selected . uptimeSeconds ) } } < / el-descriptions-item > < el-descriptions-item label = "最近心跳" > { { formatTime ( selected . lastHeartbeatAt ) } } < / el-descriptions-item >
< el-descriptions-item label = "控制隧道" > { { stateLabel ( selected . controlTunnelStatus ) } } · { { selected . controlTunnelDetail } } < / el-descriptions-item > < el-descriptions-item label = "视频数据面" > { { stateLabel ( selected . videoPlaneStatus ) } } · { { selected . videoPlaneDetail } } < / el-descriptions-item >
< el-descriptions-item label = "回填队列" > { { stateLabel ( selected . backfillStatus ) } } · { { selected . backfillQueueDepth } } 条 < / el-descriptions-item > < el-descriptions-item label = "恢复阶段" > { { recoveryText ( selected ) } } < / el-descriptions-item >
< / el-descriptions >
< h4 class = "audit-title" > 状态审计 < / h4 >
< el-timeline v-if = "selected.events?.length" >
< el -timeline -item v-for = "event in selected.events" :key="event.id" :timestamp="formatTime(event.occurredAt)" placement="top" > < strong > { { eventLabel ( event . eventType ) } } < / strong > < div > { { stateLabel ( event . fromStatus ) } } → { { stateLabel ( event . toStatus ) } } < / div > < small > { { event . detail } } < / small > < / el-timeline-item >
< / el-timeline >
< el-empty v-else description = "暂无状态变化记录" :image-size = "72" / >
< / template >
< template # footer > < el-button @click ="detailOpen = false" > 关闭 < / el -button > < / template >
< / el-dialog >
< / template >
< / BasicLayout >
< / template >
< script setup >
import { computed , onMounted , reactive , ref } from 'vue'
import { Refresh } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getEdgeNode , listEdgeNodes } from '@/api/sense/edge-node'
import { buildEdgeNodeQuery , durationText , eventLabel , freshnessText , stateLabel , stateType } from './edgeNodeState'
defineOptions ( { name : 'SenseEdgeNode' } )
const loading = ref ( false )
const nodes = ref ( [ ] )
const total = ref ( 0 )
const detailOpen = ref ( false )
const selected = ref ( null )
const query = reactive ( { pageIndex : 1 , pageSize : 10 } )
const summary = reactive ( { total : 0 , online : 0 , offline : 0 , recovering : 0 , loadUsed : 0 , loadCapacity : 0 , backfillQueueDepth : 0 , heartbeatTimeoutSeconds : 90 } )
const summaryCards = computed ( ( ) => [
{ label : '节点总数' , value : summary . total } , { label : '在线 / 恢复中' , value : ` ${ summary . online } / ${ summary . recovering } ` , className : 'online-number' } ,
{ label : '总负载' , value : ` ${ summary . loadUsed } / ${ summary . loadCapacity } 路 ` } , { label : '待回填' , value : ` ${ summary . backfillQueueDepth } 条 ` , className : summary . backfillQueueDepth ? 'warning-number' : '' }
] )
function unwrap ( response ) { return response ? . data ? . data ? ? response ? . data ? ? response }
function formatTime ( value ) { return value ? new Date ( value ) . toLocaleString ( 'zh-CN' , { hour12 : false } ) : '—' }
function recoveryText ( item ) { return item . status === 'recovering' ? ( item . recoveryPhase || '通道收敛中' ) : '已收敛' }
async function load ( ) { loading . value = true ; try { const payload = unwrap ( await listEdgeNodes ( buildEdgeNodeQuery ( query ) ) ) || { } ; nodes . value = payload . list || [ ] ; total . value = payload . count || 0 ; Object . assign ( summary , payload . summary || { } ) } catch ( error ) { ElMessage . error ( error . message || '边缘节点加载失败' ) } finally { loading . value = false } }
async function openDetail ( id ) { try { selected . value = unwrap ( await getEdgeNode ( id ) ) ; detailOpen . value = true } catch ( error ) { ElMessage . error ( error . message || '节点详情加载失败' ) } }
onMounted ( load )
< / script >
< style scoped >
. page - header { display : flex ; align - items : flex - start ; justify - content : space - between ; gap : 16 px } . page - header h3 { margin : 0 0 6 px } . page - header p { margin : 0 ; color : # 909399 } . status - alert { margin : 16 px 0 } . summary - row { margin : 16 px 0 20 px } . summary - item { display : flex ; align - items : center ; justify - content : space - between ; min - height : 72 px ; padding : 12 px 16 px ; border : 1 px solid # ebeef5 ; border - radius : 4 px } . summary - item span { color : # 606266 } . summary - item strong { font - size : 20 px } . online - number { color : # 67 c23a } . warning - number { color : # e6a23c } . muted , small { color : # 909399 ; font - size : 12 px } . detail - alert { margin - bottom : 16 px } . audit - title { margin : 20 px 0 14 px } @ media ( max - width : 768 px ) { . page - header { align - items : stretch ; flex - direction : column } . summary - item { margin - bottom : 8 px ; min - height : 64 px } . page - header . el - button { min - height : 44 px } }
< / style >