Bladeren bron

feat: 改造进出场通道监控墙

lq 2 weken geleden
bovenliggende
commit
8387a9d646
1 gewijzigde bestanden met toevoegingen van 343 en 34 verwijderingen
  1. 343 34
      frontend/src/view/parking/entryExit.vue

+ 343 - 34
frontend/src/view/parking/entryExit.vue

@@ -53,6 +53,92 @@
       @close="lastCompletion = null"
     />
 
+    <section class="monitor-wall" aria-labelledby="monitor-wall-title">
+      <div class="monitor-wall-heading">
+        <div>
+          <span class="section-kicker">{{ $t.value.workbenchCamera }}</span>
+          <h2 id="monitor-wall-title">通道监控</h2>
+        </div>
+        <div class="monitor-wall-summary">
+          <span>{{ channelMonitorCards.length }} 路通道</span>
+          <span class="summary-dot online" />
+          <span>{{ monitorOnlineCount }} 路在线</span>
+        </div>
+      </div>
+      <div v-if="channelMonitorCards.length" class="monitor-grid">
+        <article
+          v-for="card in channelMonitorCards"
+          :key="card.key"
+          class="monitor-card"
+          :class="{ selected: card.device?.device_code === selectedGateDeviceCode }"
+          @click="selectMonitorDevice(card.device)"
+        >
+          <div class="monitor-card-heading">
+            <div class="monitor-card-title">
+              <strong>{{ card.channelName }}</strong>
+              <span>{{ card.device?.device_name || card.device?.device_code || '未绑定道闸' }}</span>
+            </div>
+            <el-tag size="small" effect="plain" :type="monitorGateTagType(card.device)">
+              {{ monitorGateStatusLabel(card.device) }}
+            </el-tag>
+          </div>
+          <div class="monitor-stage">
+            <img
+              v-if="card.camera?.protocol === 'mjpeg' && card.camera?.play_url"
+              class="monitor-video"
+              :src="card.camera.play_url"
+              alt=""
+              @load="handleMonitorReady(card.key)"
+              @error="handleMonitorError(card.key)"
+            />
+            <video
+              v-else-if="card.camera?.play_url"
+              :ref="el => setMonitorVideoRef(card.key, el)"
+              class="monitor-video"
+              autoplay
+              muted
+              playsinline
+              @loadeddata="handleMonitorReady(card.key)"
+              @error="handleMonitorError(card.key)"
+            />
+            <div v-else class="monitor-empty">
+              <el-icon><VideoCamera /></el-icon>
+              <span>{{ card.camera ? $t.value.workbenchCameraNoStream : '未配置摄像头' }}</span>
+            </div>
+            <span class="monitor-status" :class="monitorStatusByCode[card.key] || 'idle'">
+              {{ monitorStatusLabel(card) }}
+            </span>
+          </div>
+          <div class="monitor-card-footer">
+            <span class="monitor-device-state">{{ card.camera?.device_name || '摄像头未配置' }}</span>
+            <div class="monitor-controls" @click.stop>
+              <el-button
+                type="success"
+                size="small"
+                :icon="Unlock"
+                :loading="monitorBusyByCode[card.device?.device_code] === 'open'"
+                :disabled="!card.device?.connected || !!monitorBusyByCode[card.device?.device_code]"
+                @click="handleMonitorGateControl(card.device, 'open')"
+              >
+                {{ $t.value.workbenchManualOpenGate }}
+              </el-button>
+              <el-button
+                type="warning"
+                size="small"
+                :icon="Lock"
+                :loading="monitorBusyByCode[card.device?.device_code] === 'close'"
+                :disabled="!card.device?.connected || !!monitorBusyByCode[card.device?.device_code]"
+                @click="handleMonitorGateControl(card.device, 'close')"
+              >
+                {{ $t.value.workbenchManualCloseGate }}
+              </el-button>
+            </div>
+          </div>
+        </article>
+      </div>
+      <el-empty v-else :image-size="64" description="暂无通道设备" />
+    </section>
+
     <main class="workbench-grid">
       <aside class="passage-panel">
         <div class="panel-heading">
@@ -152,9 +238,10 @@
             <el-image
               v-if="exitData?.entry_image"
               class="entry-image"
-              :src="exitData.entry_image"
+              :src="getImageUrl(exitData.entry_image)"
               fit="cover"
-              :preview-src-list="[exitData.entry_image]"
+              :preview-src-list="[getImageUrl(exitData.entry_image)]"
+              preview-teleported
               :alt="$t.value.entryImg"
             />
           </div>
@@ -165,18 +252,6 @@
               <h3>{{ $t.value.workbenchGateDeviceTitle }}</h3>
             </div>
             <div class="gate-controls">
-              <el-select
-                v-model="selectedGateDeviceCode"
-                :placeholder="$t.value.workbenchGatePlaceholder"
-                @change="handleGateDeviceChange"
-              >
-                <el-option
-                  v-for="device in availableGateDevices"
-                  :key="device.device_code"
-                  :label="gateDeviceLabel(device)"
-                  :value="device.device_code"
-                />
-              </el-select>
               <el-tag v-if="selectedGateDevice" :type="selectedGateDevice.simulated ? 'warning' : selectedGateDevice.connected ? 'success' : 'danger'" effect="plain">
                 {{ selectedGateDevice.simulated ? $t.value.workbenchGateSimulated : selectedGateDevice.connected ? $t.value.workbenchGateOnline : $t.value.workbenchGateOffline }}
               </el-tag>
@@ -336,7 +411,7 @@
 </template>
 
 <script setup>
-import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
+import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import {
   BottomRight,
@@ -348,13 +423,16 @@ import {
   Tickets,
   TopRight,
   Unlock,
+  VideoCamera,
   WarningFilled
 } from '@element-plus/icons-vue'
 import { getChannelEvents, getVehicleOperationContext, handleVehiclePassage } from '@/api/vehicle'
 import { controlGate, getGateDevices } from '@/api/gate'
+import { getCameraStreams } from '@/api/camera'
 import { obtainPullOverList } from '@/api/pullOver'
 import { getEnabledPaymentMethods, getPaymentEntryOptions } from '@/api/payment'
 import { t } from '@/lang/index.js'
+import { getUrl } from '@/utils/image'
 
 const queryForm = reactive({ plate_number: '', rfid_tag: '' })
 const entryForm = reactive({ parking_lot_id: null })
@@ -365,6 +443,8 @@ const contextData = ref(null)
 const submitting = ref(false)
 const lastCompletion = ref(null)
 
+const getImageUrl = (url) => getUrl(url)
+
 const paymentEntry = 'counter'
 const paymentEntries = ref([])
 const enabledPaymentMethods = ref([])
@@ -373,6 +453,13 @@ const paidAmount = ref(0)
 const gateDevices = ref([])
 const selectedGateDeviceCode = ref('')
 const gateBusy = ref('')
+const cameras = ref([])
+const monitorStatusByCode = reactive({})
+const monitorErrorByCode = reactive({})
+const monitorBusyByCode = reactive({})
+const monitorVideoRefs = new Map()
+const monitorPeers = new Map()
+const monitorGenerations = new Map()
 
 const recentEvents = ref([])
 const lastEventID = ref(0)
@@ -425,6 +512,38 @@ const availableGateDevices = computed(() => gateDevices.value.filter(device => {
 const selectedGateDevice = computed(() => gateDevices.value.find(device => device.device_code === selectedGateDeviceCode.value) || null)
 const gateReady = computed(() => Boolean(selectedGateDevice.value?.connected))
 const hasOnlineGateDevice = computed(() => availableGateDevices.value.some(device => device.connected))
+const channelMonitorCards = computed(() => {
+  const cards = []
+  const cardsByChannel = new Map()
+  const usedCameraCodes = new Set()
+  gateDevices.value.forEach(device => {
+    const channelKey = String(device.channel_id || device.channel_code || device.device_code)
+    const camera = cameras.value.find(item => String(item.channel_id) === String(device.channel_id)) || null
+    if (camera) usedCameraCodes.add(camera.device_code)
+    const existing = cardsByChannel.get(channelKey)
+    if (!existing || (!existing.device?.connected && device.connected)) {
+      const card = {
+        key: `channel-${channelKey}`,
+        channelName: device.channel_name || device.channel_code || `通道 ${device.channel_id || '-'}`,
+        device,
+        camera
+      }
+      cardsByChannel.set(channelKey, card)
+      if (!existing) cards.push(card)
+      else cards[cards.indexOf(existing)] = card
+    }
+  })
+  cameras.value.filter(camera => !usedCameraCodes.has(camera.device_code)).forEach(camera => {
+    cards.push({
+      key: `camera-${camera.device_code || camera.channel_id}`,
+      channelName: camera.channel_name || `通道 ${camera.channel_id || '-'}`,
+      device: null,
+      camera
+    })
+  })
+  return cards
+})
+const monitorOnlineCount = computed(() => channelMonitorCards.value.filter(card => card.device?.connected || card.camera?.online).length)
 
 onMounted(async () => {
   await loadReferenceData()
@@ -437,25 +556,24 @@ onUnmounted(() => {
   clearInterval(eventTimer)
   clearTimeout(scanTimer)
   document.removeEventListener('keydown', onScanKey)
+  channelMonitorCards.value.forEach(card => disconnectMonitor(card.key))
 })
 
 async function loadReferenceData() {
-  try {
-    const [lotRes, entryRes, gateRes] = await Promise.all([
-      obtainPullOverList({ page: 1, page_size: 100 }),
-      getPaymentEntryOptions(),
-      getGateDevices()
-    ])
-    parkingLotList.value = lotRes.code === 0 ? (lotRes.data?.list || []) : []
-    paymentEntries.value = entryRes.code === 0 ? (entryRes.data || []) : []
-    gateDevices.value = gateRes.code === 0 && Array.isArray(gateRes.data) ? gateRes.data : []
-    await loadPaymentMethods()
-  } catch (error) {
-    parkingLotList.value = []
-    paymentEntries.value = []
-    enabledPaymentMethods.value = []
-    gateDevices.value = []
-  }
+  const [lotRes, entryRes, gateRes, cameraRes] = await Promise.all([
+    obtainPullOverList({ page: 1, page_size: 100 }).catch(() => ({ code: 1, data: {} })),
+    getPaymentEntryOptions().catch(() => ({ code: 1, data: [] })),
+    getGateDevices().catch(() => ({ code: 1, data: [] })),
+    getCameraStreams().catch(() => ({ code: 1, data: [] }))
+  ])
+  parkingLotList.value = lotRes.code === 0 ? (lotRes.data?.list || []) : []
+  paymentEntries.value = entryRes.code === 0 ? (entryRes.data || []) : []
+  gateDevices.value = gateRes.code === 0 && Array.isArray(gateRes.data) ? gateRes.data : []
+  cameras.value = cameraRes.code === 0 && Array.isArray(cameraRes.data) ? cameraRes.data : []
+  assignGateDevice()
+  await nextTick()
+  channelMonitorCards.value.forEach(card => connectMonitor(card))
+  await loadPaymentMethods()
 }
 
 async function loadPaymentMethods() {
@@ -611,7 +729,7 @@ function handlePaymentMethodChange() {
 function assignGateDevice(action = contextAction.value) {
   const direction = action === 'entry' ? 'in' : action === 'exit' ? 'out' : action
   const current = gateDevices.value.find(device => device.device_code === selectedGateDeviceCode.value)
-  const matches = device => device.connected && (device.direction === direction || device.direction === 'inout') &&
+  const matches = device => device.connected && (!direction || device.direction === direction || device.direction === 'inout') &&
     (action !== 'entry' || !entryForm.parking_lot_id || !device.parking_lot_id || device.parking_lot_id === entryForm.parking_lot_id)
   if (current && matches(current)) return
   const next = gateDevices.value.find(matches)
@@ -626,6 +744,8 @@ function handleGateDeviceChange(deviceCode) {
   if (contextAction.value === 'entry' && device?.parking_lot_id) {
     entryForm.parking_lot_id = device.parking_lot_id
   }
+  const card = channelMonitorCards.value.find(item => item.device?.device_code === deviceCode)
+  if (card) selectMonitorDevice(card.device)
 }
 
 function handleParkingLotChange() {
@@ -718,10 +838,137 @@ function clearCurrentVehicle() {
   contextError.value = ''
   entryForm.parking_lot_id = null
   selectedGateDeviceCode.value = ''
+  channelMonitorCards.value.forEach(card => disconnectMonitor(card.key))
   paymentMethod.value = enabledPaymentMethods.value[0]?.code || ''
   paidAmount.value = 0
 }
 
+function setMonitorVideoRef(key, element) {
+  if (element) monitorVideoRefs.set(key, element)
+  else monitorVideoRefs.delete(key)
+}
+
+function handleMonitorReady(key) {
+  monitorStatusByCode[key] = 'playing'
+  delete monitorErrorByCode[key]
+}
+
+function handleMonitorError(key) {
+  monitorStatusByCode[key] = 'error'
+  monitorErrorByCode[key] = t.value.workbenchCameraError
+}
+
+function monitorStatusLabel(card) {
+  const status = monitorStatusByCode[card.key]
+  if (!card.camera) return '无摄像头'
+  if (status === 'playing') return '视频在线'
+  if (status === 'error') return '视频异常'
+  if (!card.camera.online) return t.value.workbenchCameraOffline
+  return t.value.workbenchCameraConnecting
+}
+
+function monitorGateStatusLabel(device) {
+  if (!device) return '无道闸'
+  if (device.simulated) return t.value.workbenchGateSimulated
+  return device.connected ? t.value.workbenchGateOnline : t.value.workbenchGateOffline
+}
+
+function monitorGateTagType(device) {
+  if (!device?.connected) return 'danger'
+  return device.simulated ? 'warning' : 'success'
+}
+
+function selectMonitorDevice(device) {
+  if (!device?.device_code) return
+  selectedGateDeviceCode.value = device.device_code
+  if (contextAction.value === 'entry' && device.parking_lot_id) entryForm.parking_lot_id = device.parking_lot_id
+}
+
+function disconnectMonitor(key) {
+  monitorGenerations.set(key, (monitorGenerations.get(key) || 0) + 1)
+  const peer = monitorPeers.get(key)
+  if (peer) peer.close()
+  monitorPeers.delete(key)
+  const video = monitorVideoRefs.get(key)
+  if (video?.srcObject) video.srcObject = null
+  monitorStatusByCode[key] = 'idle'
+}
+
+async function connectMonitor(card) {
+  disconnectMonitor(card.key)
+  const generation = monitorGenerations.get(card.key)
+  const camera = card.camera
+  if (!camera?.play_url) return
+  monitorStatusByCode[card.key] = 'connecting'
+  if (camera.protocol !== 'webrtc') {
+    await nextTick()
+    const video = monitorVideoRefs.get(card.key)
+    if (video) {
+      video.src = camera.play_url
+      video.load()
+      video.play().catch(() => {})
+    }
+    return
+  }
+  if (!window.RTCPeerConnection) {
+    handleMonitorError(card.key)
+    return
+  }
+  try {
+    const peer = new RTCPeerConnection()
+    monitorPeers.set(card.key, peer)
+    peer.addTransceiver('video', { direction: 'recvonly' })
+    peer.ontrack = event => {
+      if (generation !== monitorGenerations.get(card.key)) return
+      const video = monitorVideoRefs.get(card.key)
+      if (video) {
+        video.srcObject = event.streams[0]
+        video.play().catch(() => {})
+      }
+    }
+    const offer = await peer.createOffer()
+    await peer.setLocalDescription(offer)
+    const answer = await fetch(camera.play_url, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/sdp' },
+      body: offer.sdp
+    })
+    if (!answer.ok) throw new Error(`WHEP ${answer.status}`)
+    if (generation !== monitorGenerations.get(card.key)) return
+    await peer.setRemoteDescription({ type: 'answer', sdp: await answer.text() })
+  } catch (error) {
+    if (generation === monitorGenerations.get(card.key)) handleMonitorError(card.key)
+  }
+}
+
+async function handleMonitorGateControl(device, action) {
+  if (!device?.connected || monitorBusyByCode[device.device_code]) return
+  const isOpen = action === 'open'
+  try {
+    await ElMessageBox.confirm(
+      isOpen ? t.value.workbenchOpenGateConfirm : t.value.workbenchCloseGateConfirm,
+      t.value.workbenchGateConfirmTitle,
+      {
+        confirmButtonText: isOpen ? t.value.workbenchManualOpenGate : t.value.workbenchManualCloseGate,
+        cancelButtonText: t.value.Cancel,
+        type: 'warning'
+      }
+    )
+  } catch (error) {
+    return
+  }
+  monitorBusyByCode[device.device_code] = action
+  try {
+    const res = await controlGate(action, { device_code: device.device_code, valid_time: 2 })
+    if (res.code !== 0) throw new Error(res.msg || t.value.workbenchGateOperationFailed)
+    ElMessage.success(isOpen ? t.value.workbenchGateOpenSuccess : t.value.workbenchGateCloseSuccess)
+  } catch (error) {
+    ElMessage.error(error?.message || t.value.workbenchGateOperationFailed)
+  } finally {
+    delete monitorBusyByCode[device.device_code]
+  }
+}
+
 function shortRFID(value) {
   if (!value) return ''
   return value.length > 12 ? `${value.slice(0, 6)}…${value.slice(-4)}` : value
@@ -869,6 +1116,47 @@ function formatDuration(minutes) {
 .identity-actions :deep(.el-button + .el-button) { margin-left: 0; }
 .completion-alert { margin-top: 14px; }
 
+.monitor-wall {
+  margin-top: 16px;
+  border: 1px solid var(--line);
+  background: #172029;
+  color: #f5f8fa;
+}
+.monitor-wall-heading {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+  min-height: 66px;
+  padding: 0 18px;
+  border-bottom: 1px solid #33414c;
+}
+.monitor-wall-heading h2 { margin: 4px 0 0; color: #fff; font-size: 17px; }
+.monitor-wall-heading .section-kicker { color: #9eacb7; }
+.monitor-wall-summary { display: flex; align-items: center; gap: 8px; color: #b8c5cd; font-size: 12px; }
+.summary-dot { width: 7px; height: 7px; border-radius: 50%; background: #75818a; }
+.summary-dot.online { background: #3fc18a; box-shadow: 0 0 0 3px rgba(63, 193, 138, .15); }
+.monitor-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 12px; padding: 12px; }
+.monitor-card { min-width: 0; border: 1px solid #34434e; background: #202c35; transition: border-color .18s ease, transform .18s ease; }
+.monitor-card:hover, .monitor-card.selected { border-color: #55b893; }
+.monitor-card.selected { box-shadow: inset 3px 0 0 #55b893; }
+.monitor-card-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 11px 12px 9px; }
+.monitor-card-title { min-width: 0; }
+.monitor-card-title strong { display: block; overflow: hidden; color: #fff; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
+.monitor-card-title span { display: block; margin-top: 3px; overflow: hidden; color: #9eacb7; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
+.monitor-stage { position: relative; aspect-ratio: 16 / 9; overflow: hidden; background: #080d11; }
+.monitor-video { display: block; width: 100%; height: 100%; object-fit: contain; background: #080d11; }
+.monitor-empty { display: grid; place-items: center; align-content: center; height: 100%; gap: 6px; color: #8797a3; font-size: 12px; }
+.monitor-empty .el-icon { font-size: 27px; }
+.monitor-status { position: absolute; left: 9px; bottom: 8px; padding: 3px 7px; color: #d4dde2; background: rgba(10, 16, 20, .78); font-size: 11px; }
+.monitor-status.playing { color: #8ce0b8; }
+.monitor-status.error { color: #ffb0b0; }
+.monitor-card-footer { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 12px; border-top: 1px solid #34434e; }
+.monitor-device-state { min-width: 0; overflow: hidden; color: #aab8c0; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
+.monitor-controls { display: flex; flex: 0 0 auto; gap: 6px; }
+.monitor-controls :deep(.el-button + .el-button) { margin-left: 0; }
+.monitor-wall > .el-empty { margin: 0; padding: 36px 0; color: #aab8c0; }
+
 .workbench-grid {
   display: grid;
   grid-template-columns: minmax(270px, 31%) minmax(0, 1fr);
@@ -926,6 +1214,18 @@ function formatDuration(minutes) {
 .detail-list dt { color: var(--muted); font-size: 12px; }
 .detail-list dd { margin: 4px 0 0; overflow: hidden; font-size: 14px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
 .entry-image { width: 180px; aspect-ratio: 16 / 9; border-radius: 4px; background: var(--soft); }
+.camera-monitor-panel { padding: 14px 22px; border-bottom: 1px solid var(--line); background: #111820; color: #fff; }
+.camera-monitor-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
+.camera-monitor-heading h3 { margin: 4px 0 0; color: #fff; font-size: 15px; }
+.camera-monitor-actions { display: flex; align-items: center; justify-content: flex-end; gap: 10px; min-width: 0; flex-wrap: wrap; }
+.camera-gate-picker { display: flex; align-items: center; gap: 7px; padding: 4px 6px 4px 9px; border: 1px solid #3a4652; border-radius: 4px; background: #1b2630; }
+.camera-gate-label { color: #d9e2ea; font-size: 12px; font-weight: 600; white-space: nowrap; }
+.camera-gate-select { width: 245px; }
+.camera-monitor-stage { position: relative; min-height: 190px; overflow: hidden; background: #05080b; }
+.camera-video { display: block; width: 100%; max-height: 360px; aspect-ratio: 16 / 9; object-fit: contain; background: #05080b; }
+.camera-monitor-empty { display: grid; place-items: center; min-height: 190px; gap: 8px; color: #a8b4c0; font-size: 13px; }
+.camera-monitor-empty .el-icon { font-size: 30px; }
+.camera-monitor-error { position: absolute; right: 10px; bottom: 10px; left: 10px; padding: 7px 10px; color: #ffd7d7; background: rgba(115, 20, 20, .86); font-size: 12px; }
 
 .gate-control-strip {
   display: flex;
@@ -937,8 +1237,7 @@ function formatDuration(minutes) {
   background: #fbfcfc;
 }
 .gate-control-copy h3 { margin: 4px 0 0; font-size: 15px; letter-spacing: 0; }
-.gate-controls { display: grid; grid-template-columns: minmax(230px, 1fr) auto auto auto; gap: 8px; align-items: center; min-width: 0; }
-.gate-controls :deep(.el-select) { min-width: 0; }
+.gate-controls { display: grid; grid-template-columns: auto auto auto; gap: 8px; align-items: center; min-width: 0; }
 .gate-controls :deep(.el-button + .el-button) { margin-left: 0; }
 .gate-alert { border-radius: 0; }
 
@@ -999,6 +1298,7 @@ function formatDuration(minutes) {
   .entry-zone { align-items: stretch; flex-direction: column; }
   .entry-controls { grid-template-columns: 1fr; }
   .gate-controls { grid-template-columns: minmax(0, 1fr) auto auto auto; }
+  .monitor-grid { grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }
 }
 
 @media (max-width: 560px) {
@@ -1018,5 +1318,14 @@ function formatDuration(minutes) {
   .confirm-bar .el-button { width: 100%; }
   .gate-controls { grid-template-columns: 1fr auto auto; }
   .gate-controls > .el-tag { grid-column: 1 / -1; grid-row: 2; justify-self: start; }
+  .camera-monitor-heading { align-items: stretch; flex-direction: column; }
+  .camera-monitor-actions { justify-content: flex-start; }
+  .camera-gate-picker { width: 100%; justify-content: space-between; }
+  .camera-gate-select { flex: 1; width: auto; min-width: 0; }
+  .monitor-wall-heading { align-items: flex-start; flex-direction: column; gap: 4px; padding-top: 12px; padding-bottom: 12px; }
+  .monitor-grid { grid-template-columns: 1fr; padding: 8px; }
+  .monitor-card-footer { align-items: stretch; flex-direction: column; }
+  .monitor-controls { display: grid; grid-template-columns: 1fr 1fr; }
+  .monitor-controls .el-button { width: 100%; }
 }
 </style>