feat: add MediaMTX process and reconciliation control (#25)

This commit is contained in:
QiuSW
2026-08-12 17:21:14 +08:00
parent 4f17cca91f
commit 398534aabe
18 changed files with 765 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
import request from '../../../bootstrap/request'
export const listRoutes = () => request.get('/media/routes')
export const configureRoute = (data) => request.post('/media/routes', data)
export const reconcileRoute = (id) => request.post(`/media/routes/${encodeURIComponent(id)}/reconcile`)
export const stopRoute = (id) => request.post(`/media/routes/${encodeURIComponent(id)}/stop`)
export const processState = () => request.get('/media/process')
@@ -0,0 +1,3 @@
import Media from '../../views/sense/media/Media.vue'
export default { path: 'media', name: 'sense-media', component: Media, meta: { title: '视频服务', icon: 'Monitor', order: 30, permission: 'media.read' } }
+38
View File
@@ -0,0 +1,38 @@
<template>
<section class="page-container">
<div class="page-heading"><div><h1>视频服务</h1><p>管理 MediaMTX 独立进程和已验证视频的期望状态。</p></div><el-tag :type="process.running ? 'success' : 'info'">{{ process.running ? `进程运行中 · PID ${process.pid}` : '进程未运行' }}</el-tag></div>
<el-alert v-if="process.last_exit" :title="`最近退出:${process.last_exit}`" type="warning" show-icon :closable="false" class="process-alert" />
<div class="table-container">
<div class="table-actions"><strong>媒体路径</strong><el-button v-if="canWrite" type="primary" :icon="Plus" @click="dialog=true">添加视频路径</el-button></div>
<el-table v-loading="loading" :data="routes" border>
<el-table-column prop="device_id" label="设备" min-width="190" />
<el-table-column prop="profile_token" label="Profile" min-width="120" />
<el-table-column prop="path" label="媒体路径" min-width="210" />
<el-table-column label="期望" width="100"><template #default="scope"><el-tag>{{ scope.row.desired === 'running' ? '运行' : '停止' }}</el-tag></template></el-table-column>
<el-table-column label="实际状态" width="130"><template #default="scope"><el-tag :type="scope.row.actual === 'ready' ? 'success' : scope.row.actual.includes('failed') ? 'danger' : 'warning'">{{ stateLabel(scope.row.actual) }}</el-tag></template></el-table-column>
<el-table-column prop="readers" label="观看数" width="90" />
<el-table-column prop="detail" label="说明" min-width="180" />
<el-table-column v-if="canWrite" label="操作" width="150" fixed="right"><template #default="scope"><el-button link type="primary" @click="reconcile(scope.row)">对账</el-button><el-button v-if="scope.row.desired === 'running'" link type="danger" @click="stop(scope.row)">停止</el-button></template></el-table-column>
</el-table>
</div>
<el-dialog v-model="dialog" title="添加视频路径" width="520px">
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
<el-form-item label="已完成接入检查的设备" prop="device_id"><el-select v-model="form.device_id" filterable style="width:100%" @change="loadProfiles"><el-option v-for="item in devices" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item>
<el-form-item label="视频 Profile" prop="profile_token"><el-select v-model="form.profile_token" style="width:100%"><el-option v-for="item in profiles" :key="item.token" :label="`${item.kind === 'main' ? '主码流' : '子码流'} · ${item.width}×${item.height}`" :value="item.token" /></el-select></el-form-item>
</el-form>
<template #footer><el-button @click="dialog=false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存并等待对账</el-button></template>
</el-dialog>
</section>
</template>
<script setup>
import{computed,onMounted,reactive,ref}from'vue';import{ElMessage}from'element-plus';import{Plus}from'@element-plus/icons-vue';import{useStore}from'vuex';import{listDevices}from'../../../api/sense/device';import{admissionResult}from'../../../api/sense/admission';import{configureRoute,listRoutes,processState,reconcileRoute,stopRoute}from'../../../api/sense/media'
const store=useStore(),loading=ref(false),saving=ref(false),dialog=ref(false),formRef=ref(),routes=ref([]),devices=ref([]),profiles=ref([]),process=reactive({running:false});const form=reactive({device_id:'',profile_token:''});const rules={device_id:[{required:true,message:'请选择设备',trigger:'change'}],profile_token:[{required:true,message:'请选择 Profile',trigger:'change'}]};const canWrite=computed(()=>store.getters['sense-identity/hasPermission']?.('media.write'))
function stateLabel(value){return({pending:'等待对账',ready:'拉流正常',waiting:'等待拉流',stopped:'已停止',process_failed:'进程启动失败',apply_failed:'配置失败',unconverged:'未收敛'})[value]||value}
async function load(){loading.value=true;try{const[data,state,devicePage]=await Promise.all([listRoutes(),processState(),listDevices({page:1,page_size:100})]);routes.value=data.items||[];Object.assign(process,state);devices.value=devicePage.items.filter(item=>item.modality==='video'&&item.status!=='disabled')}finally{loading.value=false}}
async function loadProfiles(){form.profile_token='';profiles.value=[];if(!form.device_id)return;try{profiles.value=(await admissionResult(form.device_id)).profiles.filter(item=>item.verification.status==='ready')}catch{ElMessage.warning('该设备尚未完成视频接入检查')}}
async function save(){const valid=await formRef.value?.validate().catch(()=>false);if(!valid)return;saving.value=true;try{await configureRoute(form);ElMessage.success('媒体路径已保存,请执行对账');dialog.value=false;await load()}catch(error){ElMessage.error(error.message||'保存失败')}finally{saving.value=false}}
async function reconcile(row){await reconcileRoute(row.id);ElMessage.success('已完成一次状态对账');await load()}
async function stop(row){await stopRoute(row.id);ElMessage.success('媒体路径已停止');await load()}
onMounted(load)
</script>
<style scoped>.process-alert{margin-bottom:16px}</style>