feat: 实现 Sense 运维中心 (#74)

This commit is contained in:
QiuSW
2026-08-28 11:02:34 +08:00
parent 4157d90e9f
commit 184f101a86
14 changed files with 1218 additions and 0 deletions
@@ -0,0 +1,170 @@
<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 type="info" :closable="false" show-icon class="boundary-alert">
<template #title>本地推理是可选能力</template>
Brain 未安装时显示“不可用(未安装)”,不会导致页面失败,也不会阻断设备和媒体运维。运维异常不会发送到 Bell 作为业务预警。
</el-alert>
<el-row :gutter="12" class="summary-row" aria-label="运维概览">
<el-col :xs="24" :sm="8">
<div class="summary-item"><span>设备与媒体已收敛</span><strong>{{ summary.convergedCount }} / {{ summary.managedCount }}</strong></div>
</el-col>
<el-col :xs="24" :sm="8">
<div class="summary-item"><span>待处理问题</span><strong class="warning-number">{{ summary.actionableProblems }}</strong></div>
</el-col>
<el-col :xs="24" :sm="8">
<div class="summary-item"><span>本地推理适配器</span><strong class="adapter-state">{{ inferenceState }}</strong></div>
</el-col>
</el-row>
<div class="section-header">
<div><h4>问题队列</h4><p>默认只读诊断;只有明确可重试的项目才显示受控重试。</p></div>
</div>
<el-form :model="query" label-width="76px" class="filter-form" @submit.prevent="search">
<el-form-item label="对象类型">
<el-select v-model="query.objectType" clearable placeholder="全部">
<el-option label="设备" value="device" /><el-option label="媒体" value="media" /><el-option label="本地推理" value="local_inference" />
</el-select>
</el-form-item>
<el-form-item label="问题类型">
<el-select v-model="query.problemType" clearable placeholder="全部">
<el-option label="认证失败" value="authentication_failed" /><el-option label="退避等待" value="backoff_wait" />
<el-option label="时间漂移" value="clock_drift" /><el-option label="孤儿安全闸" value="orphan_safety_gate" />
<el-option label="能力未安装" value="capability_unavailable" /><el-option label="尚未收敛" value="not_converged" />
</el-select>
</el-form-item>
<el-form-item label="级别">
<el-select v-model="query.severity" clearable placeholder="全部">
<el-option label="高" value="high" /><el-option label="中" value="medium" /><el-option label="提示" value="info" />
</el-select>
</el-form-item>
<el-form-item label="关键词"><el-input v-model="query.keyword" clearable placeholder="设备、位置或差异" @keyup.enter="search" /></el-form-item>
<el-form-item class="filter-actions">
<el-button type="primary" :icon="Search" native-type="submit">查询</el-button>
<el-button :icon="RefreshLeft" @click="reset">重置</el-button>
</el-form-item>
</el-form>
<el-table v-loading="loading" :data="problems" border stripe empty-text="当前筛选条件下没有未收敛问题">
<el-table-column label="对象" min-width="170">
<template #default="scope"><strong>{{ scope.row.objectName }}</strong><div class="muted">{{ objectLabel(scope.row.objectType) }} · {{ scope.row.location || scope.row.objectId }}</div></template>
</el-table-column>
<el-table-column label="问题" min-width="138"><template #default="scope"><div>{{ problemLabel(scope.row.problemType) }}</div><small>{{ scope.row.id }}</small></template></el-table-column>
<el-table-column label="期望态" min-width="110"><template #default="scope">{{ stateLabel(scope.row.expected) }}</template></el-table-column>
<el-table-column label="实际态" min-width="120"><template #default="scope"><el-tag size="small" :type="severityType(scope.row.severity)">{{ stateLabel(scope.row.actual) }}</el-tag></template></el-table-column>
<el-table-column prop="difference" label="未收敛差异" min-width="210" show-overflow-tooltip />
<el-table-column label="下次动作" min-width="190"><template #default="scope"><div>{{ scope.row.nextAction }}</div><small v-if="scope.row.nextRetryAt">自动重试:{{ formatTime(scope.row.nextRetryAt) }}</small></template></el-table-column>
<el-table-column label="级别" width="78" align="center"><template #default="scope"><el-tag size="small" :type="severityType(scope.row.severity)">{{ severityLabel(scope.row.severity) }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="156" fixed="right">
<template #default="scope">
<el-button v-permisaction="['sense:operations:detail']" type="primary" link @click="openDetail(scope.row.id)">详情</el-button>
<el-button v-if="canRetry(scope.row)" v-permisaction="['sense:operations:retry']" type="warning" link @click="confirmRetry(scope.row)">受控重试</el-button>
<span v-else-if="scope.row.retryInProgress" class="muted">重试已排队</span>
</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" />
<p class="safety-help">受控重试会校验权限、当前版本和并发占用,并写入操作审计。孤儿资源仅隔离和提示,本页没有删除操作。</p>
</el-card>
<el-dialog v-model="detailOpen" title="状态详情" width="min(760px, calc(100vw - 24px))" :close-on-click-modal="false">
<el-descriptions v-if="selected" :column="2" border>
<el-descriptions-item label="问题编号">{{ selected.id }}</el-descriptions-item>
<el-descriptions-item label="对象">{{ selected.objectName }}</el-descriptions-item>
<el-descriptions-item label="期望态">{{ stateLabel(selected.expected) }}</el-descriptions-item>
<el-descriptions-item label="实际态">{{ stateLabel(selected.actual) }}</el-descriptions-item>
<el-descriptions-item label="未收敛差异" :span="2">{{ selected.difference }}</el-descriptions-item>
<el-descriptions-item label="退避 / 下次重试">{{ selected.nextRetryAt ? formatTime(selected.nextRetryAt) : '无退避' }}</el-descriptions-item>
<el-descriptions-item label="尝试次数">{{ selected.attemptCount }}</el-descriptions-item>
<el-descriptions-item label="建议处理" :span="2">{{ selected.nextAction }}</el-descriptions-item>
</el-descriptions>
<el-alert v-if="selected" title="安全闸已启用" type="warning" :closable="false" show-icon class="safety-alert">{{ selected.safetyGate }}</el-alert>
<p class="boundary-text">Brain/Bell 状态不会参与本地设备和媒体重试判定。</p>
<template #footer>
<el-button @click="detailOpen = false">关闭</el-button>
<el-button v-if="canRetry(selected)" v-permisaction="['sense:operations:retry']" type="warning" @click="confirmRetry(selected)">受控重试</el-button>
</template>
</el-dialog>
</template>
</BasicLayout>
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { Refresh, RefreshLeft, Search } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getOperationProblem, listOperationProblems, retryOperationProblem } from '@/api/sense/operations'
import { buildOperationsQuery, canRetry, objectLabel, problemLabel, severityLabel, severityType, stateLabel } from './operationsState'
defineOptions({ name: 'SenseOperations' })
const loading = ref(false)
const problems = ref([])
const total = ref(0)
const summary = reactive({ managedCount: 0, convergedCount: 0, actionableProblems: 0, localInference: 'unavailable' })
const query = reactive({ pageIndex: 1, pageSize: 10, objectType: '', problemType: '', severity: '', keyword: '' })
const detailOpen = ref(false)
const selected = ref(null)
const inferenceState = ref('不可用(未安装)')
function unwrap(response) { return response?.data?.data ?? response?.data ?? response }
function formatTime(value) { return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—' }
async function load() {
loading.value = true
try {
const payload = unwrap(await listOperationProblems(buildOperationsQuery(query))) || {}
problems.value = payload.list || []
total.value = payload.count || 0
Object.assign(summary, payload.summary || { managedCount: 0, convergedCount: 0, actionableProblems: 0 })
inferenceState.value = summary.localInference === 'configured' ? '已配置' : '不可用(未安装)'
} catch (error) {
ElMessage.error(error.message || '运维状态加载失败')
} finally {
loading.value = false
}
}
function search() { query.pageIndex = 1; load() }
function reset() { Object.assign(query, { pageIndex: 1, objectType: '', problemType: '', severity: '', keyword: '' }); load() }
async function openDetail(id) {
try {
selected.value = unwrap(await getOperationProblem(id))
detailOpen.value = true
} catch (error) {
ElMessage.error(error.message || '状态详情加载失败')
}
}
async function confirmRetry(item) {
try {
await ElMessageBox.confirm(`将对“${item.objectName}”发起一次重试。此操作不会修改凭据,也不会删除媒体资源。`, '确认受控重试', {
type: 'warning', confirmButtonText: '确认重试', cancelButtonText: '取消'
})
await retryOperationProblem(item.id, item.version)
ElMessage.success('重试任务已排队')
detailOpen.value = false
await load()
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.warning(error.message || '重试未排队,请刷新状态后再试')
}
}
onMounted(load)
</script>
<style scoped>
.page-header,.section-header{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}.page-header h3,.section-header h4{margin:0 0 6px}.page-header p,.section-header p{margin:0;color:#909399}.boundary-alert{margin:16px 0}.summary-row{margin-bottom:20px}.summary-item{display:flex;align-items:center;justify-content:space-between;min-height:72px;padding:12px 16px;border:1px solid #ebeef5;border-radius:4px}.summary-item span{color:#606266}.summary-item strong{font-size:22px}.summary-item .warning-number{color:#e6a23c}.summary-item .adapter-state{font-size:15px;color:#909399}.filter-form{display:flex;align-items:flex-end;flex-wrap:wrap;gap:0 12px;margin:16px 0 2px}.filter-form .el-form-item{width:210px}.filter-form .filter-actions{width:auto}.filter-form :deep(.el-select){width:100%}.muted,small{color:#909399;font-size:12px}.safety-help,.boundary-text{margin:12px 0 0;color:#909399;font-size:12px}.safety-alert{margin-top:16px}@media(max-width:768px){.filter-form .el-form-item{width:100%}.page-header{align-items:stretch;flex-direction:column}.summary-item{margin-bottom:8px}}
</style>
@@ -0,0 +1,50 @@
const objectLabels = {
device: '设备',
media: '媒体',
local_inference: '本地推理'
}
const problemLabels = {
authentication_failed: '认证失败',
backoff_wait: '退避等待',
clock_drift: '时间漂移',
orphan_safety_gate: '孤儿安全闸',
capability_unavailable: '能力未安装',
not_converged: '尚未收敛'
}
const stateLabels = {
active: '已启用',
ready: '就绪',
running: '运行',
stopped: '已停止',
waiting: '等待拉流',
pending: '等待处理',
retry_pending: '重试已排队',
authentication_failed: '认证失败',
clock_drift: '时间漂移',
apply_failed: '配置失败',
process_unavailable: '进程不可用',
status_unavailable: '状态不可用',
unavailable: '不可用(未安装)'
}
export function objectLabel(value) { return objectLabels[value] || value || '未知' }
export function problemLabel(value) { return problemLabels[value] || value || '未知问题' }
export function stateLabel(value) {
return String(value || '').split(' / ').map(item => stateLabels[item] || item).join(' / ') || '未知'
}
export function severityLabel(value) { return ({ high: '高', medium: '中', info: '提示' })[value] || value || '未知' }
export function severityType(value) { return ({ high: 'danger', medium: 'warning', info: 'info' })[value] || 'info' }
export function canRetry(item) { return Boolean(item?.retryable && !item?.retryInProgress && item?.version > 0) }
export function buildOperationsQuery(query) {
return {
pageIndex: Number(query.pageIndex) || 1,
pageSize: Number(query.pageSize) || 10,
objectType: String(query.objectType || '').trim(),
problemType: String(query.problemType || '').trim(),
severity: String(query.severity || '').trim(),
keyword: String(query.keyword || '').trim()
}
}