feat: 重建 MediaMTX 生命周期与状态对账 (#67)

This commit is contained in:
QiuSW
2026-08-14 17:42:26 +08:00
parent 24cf4bbd65
commit 19f9bfa1a0
26 changed files with 1560 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
import request from '@/utils/request'
export function listMediaRoutes() { return request({ url: '/api/v1/media/routes', method: 'get' }) }
export function getMediaProcess() { return request({ url: '/api/v1/media/process', method: 'get' }) }
export function reconcileAllMedia() { return request({ url: '/api/v1/media/reconcile', method: 'post' }) }
export function reconcileMediaRoute(id) { return request({ url: `/api/v1/media/routes/${encodeURIComponent(id)}/reconcile`, method: 'post' }) }
export function stopMediaRoute(id) { return request({ url: `/api/v1/media/routes/${encodeURIComponent(id)}/stop`, method: 'post' }) }
+55
View File
@@ -0,0 +1,55 @@
<template>
<BasicLayout>
<template #wrapper>
<el-card class="box-card">
<div class="page-header">
<div><h3>视频服务</h3><p>查看 MediaMTX 进程、摄像头拉流路径和自动重试状态。</p></div>
<el-button v-permisaction="['sense:media:reconcile']" type="primary" :loading="reconciling" @click="handleReconcileAll">立即对账</el-button>
</div>
<el-alert title="视频服务故障不会删除已验证的设备和码流;外部进程受孤儿安全闸保护,不会被 Sense 停止。" type="info" :closable="false" show-icon />
<el-descriptions :column="3" border class="process-state">
<el-descriptions-item label="进程状态"><el-tag :type="mediaStatusType(process.phase)">{{ processLabel(process.phase) }}</el-tag></el-descriptions-item>
<el-descriptions-item label="进程归属">{{ process.owned ? 'Sense 启动' : process.external ? '外部启动(受保护)' : '未运行' }}</el-descriptions-item>
<el-descriptions-item label="进程号">{{ process.pid || '—' }}</el-descriptions-item>
<el-descriptions-item label="说明" :span="3">{{ process.detail || '尚未取得状态' }}</el-descriptions-item>
</el-descriptions>
<el-table v-loading="loading" :data="routes" border stripe class="route-table">
<el-table-column prop="path" label="媒体路径" min-width="190" show-overflow-tooltip />
<el-table-column prop="profileToken" label="Profile" min-width="120" show-overflow-tooltip />
<el-table-column label="期望状态" width="100"><template #default="scope"><el-tag size="small" type="info">{{ scope.row.desired === 'running' ? '运行' : '停止' }}</el-tag></template></el-table-column>
<el-table-column label="实际状态" width="120"><template #default="scope"><el-tag :type="mediaStatusType(scope.row.actual)" size="small">{{ mediaStatusLabel(scope.row.actual) }}</el-tag></template></el-table-column>
<el-table-column prop="readers" label="观看数" width="90" />
<el-table-column label="下次重试" min-width="165"><template #default="scope">{{ scope.row.nextRetryAt ? parseTime(scope.row.nextRetryAt) : '—' }}</template></el-table-column>
<el-table-column prop="detail" label="说明" min-width="210" show-overflow-tooltip />
<el-table-column label="操作" width="150" fixed="right">
<template #default="scope">
<el-button v-permisaction="['sense:media:reconcile']" type="primary" link @click="handleRoute(scope.row)">对账</el-button>
<el-button v-if="scope.row.desired === 'running'" v-permisaction="['sense:media:stop']" type="danger" link @click="handleStop(scope.row)">停止</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && routes.length === 0" description="暂无媒体路径;请先完成摄像头接入验证" />
</el-card>
</template>
</BasicLayout>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getMediaProcess, listMediaRoutes, reconcileAllMedia, reconcileMediaRoute, stopMediaRoute } from '@/api/sense/media'
import { mediaStatusLabel, mediaStatusType } from './mediaStatus'
const routes = ref([]); const process = ref({}); const loading = ref(false); const reconciling = ref(false)
function unwrap(response) { return response?.data?.data ?? response?.data ?? response }
function processLabel(value) { return ({ running: '运行中', starting: '启动中', stopped: '已停止', not_configured: '未配置', configuration_failed: '配置错误', failed: '启动失败' })[value] || value || '未知' }
async function load() { loading.value = true; try { const [routeResponse, processResponse] = await Promise.all([listMediaRoutes(), getMediaProcess()]); const routePayload = unwrap(routeResponse); routes.value = routePayload?.list || []; process.value = unwrap(processResponse) || {} } catch (error) { ElMessage.error(error.message || '视频服务状态加载失败') } finally { loading.value = false } }
async function handleReconcileAll() { reconciling.value = true; try { await reconcileAllMedia(); ElMessage.success('对账完成'); await load() } catch (error) { ElMessage.warning(error.message || '对账未完成,请查看路径状态') } finally { reconciling.value = false } }
async function handleRoute(route) { try { await reconcileMediaRoute(route.id); await load() } catch (error) { ElMessage.warning(error.message || '该路径对账失败') } }
async function handleStop(route) { try { await ElMessageBox.confirm('停止后该路径将不再拉取摄像头视频,设备和 Profile 不会被删除。', '停止媒体路径', { type: 'warning' }); await stopMediaRoute(route.id); ElMessage.success('路径已停止'); await load() } catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.warning(error.message || '停止失败') } }
onMounted(load)
</script>
<style scoped>
.page-header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:16px}.page-header h3{margin:0 0 6px}.page-header p{margin:0;color:#909399}.process-state{margin-top:18px}.route-table{margin-top:18px}
</style>
@@ -0,0 +1,10 @@
export function mediaStatusLabel(value) {
return ({ ready: '拉流正常', waiting: '等待拉流', pending: '等待对账', stopped: '已停止', process_unavailable: '进程不可用', apply_failed: '配置失败', status_unavailable: '状态未知', path_missing: '路径缺失', profile_unavailable: 'Profile 不可用', credential_unavailable: '凭据不可用' })[value] || value || '未知'
}
export function mediaStatusType(value) {
if (value === 'ready' || value === 'running') return 'success'
if (value === 'waiting' || value === 'pending' || value === 'starting') return 'warning'
if (value === 'stopped' || value === 'not_configured') return 'info'
return 'danger'
}