You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

15 KiB

Monitor Device Polling Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Make Monitor and open DeviceDetail pages refresh device status about every 5s so “实时监控” actually stays live.

Architecture: Page-local timers (no store-global poller). MonitorView reuses devices.load() every 5s with an in-flight gate + visibility pause. DeviceDetailView polls only the current route id via GET /docks|:drones/:id and merges through new applyDockDetail / applyDroneDetail. DevicesView stays manual-refresh only.

Tech Stack: Vue 3, Pinia devicesStore, existing axios request + urls.DOCK / urls.DRONE

Spec: docs/superpowers/specs/2026-09-03-monitor-device-polling-design.md


Task 1: Store incremental detail apply helpers

Files:

  • Modify: src/stores/modules/devicesStore.js

  • Step 1: Add dock/drone status helpers used by both load and apply

Above export const useDeviceStore, add (keep existing DOCK_STATUS / alarmCodes / environmentValues / modeText / controlModeText / coordinate / displayValue / gpsText / fmtTime):

function dockStatusFromDetail(registerStatus, online, rt) {
  const pending = registerStatus === 'pending' && !online
  if (pending) return { status: '待连接', statusClass: 'pending' }
  if (!online) return DOCK_STATUS.offline
  if (alarmCodes(rt).length) return { status: '告警', statusClass: 'alarm' }
  return DOCK_STATUS.online
}

function droneStatusFromDetail(online, rt) {
  const armed = ['1', 'true', 1, true].includes(rt.armed)
  const altitude = coordinate(rt.altitude, -1000, 100000)
  const flying = armed || (altitude != null && altitude > 0)
  if (!online) return { status: '离线', statusClass: 'offline', altitude, flying }
  if (flying) return { status: '飞行中', statusClass: 'mission', altitude, flying }
  return { status: '在巢', statusClass: 'online', altitude, flying }
}
  • Step 2: Refactor load() dock/drone status blocks to call the helpers

In the dock map callback, replace the local pending / st computation with:

      const st = dockStatusFromDetail(registerStatus, online, rt)

In the drone map callback, replace armed / altitude / flying / st with:

      const st = droneStatusFromDetail(online, rt)
      const altitude = st.altitude

(Keep the rest of each mapped object identical.)

  • Step 3: Implement applyDockDetail / applyDroneDetail

Inside the store factory, before return, add:

  function applyDockDetail(id, detail) {
    const key = String(id)
    const dock = docks.value.find((d) => d.id === key)
    if (!dock) {
      console.warn('[devices] applyDockDetail missing dock', key)
      return false
    }
    const rt = detail?.realtime || {}
    const online = Boolean(detail?.online)
    const st = dockStatusFromDetail(dock.registerStatus || 'registered', online, rt)
    const environment = environmentValues(rt)
    const alarms = alarmCodes(rt)
    Object.assign(dock, {
      status: st.status,
      statusClass: st.statusClass,
      mode: controlModeText(rt.controlMode),
      realtime: rt,
      environment,
      alarms,
      online,
    })
    const assetRow = assets[key]
    if (assetRow) {
      Object.assign(assetRow, {
        status: dock.status,
        statusClass: dock.statusClass,
        realtime: rt,
        environment,
        alarms,
        online,
      })
    }
    return true
  }

  function applyDroneDetail(id, detail) {
    const key = String(id)
    const drone = drones.value.find((d) => d.id === key)
    if (!drone) {
      console.warn('[devices] applyDroneDetail missing drone', key)
      return false
    }
    const rt = detail?.realtime || {}
    const online = Boolean(detail?.online)
    const st = droneStatusFromDetail(online, rt)
    const altitude = st.altitude
    const battery = rt.batteryPercent ?? rt.batteryPct
    const alarms = alarmCodes(rt)
    const dock = docks.value.find((d) => d.dockId === drone.dockId)
    Object.assign(drone, {
      status: st.status,
      statusClass: st.statusClass,
      battery: battery != null && battery !== '' ? `${battery}%` : drone.battery,
      mode: modeText(rt.flightMode),
      mission: displayValue(rt.missionName || rt.mission),
      sysid: displayValue(rt.currentSysId),
      gps: gpsText(rt.gpsQuality),
      realtime: rt,
      alarms,
      online,
      _lat: coordinate(rt.latitude, -90, 90),
      _lon: coordinate(rt.longitude, -180, 180),
      _alt: altitude,
      _speed: coordinate(rt.groundSpeed, 0, 1000),
      _sats: coordinate(rt.satellites, 0, 1000),
    })
    const assetRow = assets[key]
    if (assetRow) {
      const lon = drone._lon != null ? drone._lon : dock?.longitude
      const lat = drone._lat != null ? drone._lat : dock?.latitude
      const hasCoordinates = lon != null && lat != null
      Object.assign(assetRow, {
        status: drone.status,
        statusClass: drone.statusClass,
        location: drone.statusClass === 'mission'
          ? `${dock?.location || '--'} · 飞行中`
          : `停放于${dock?.name || '--'}`,
        hasCoordinates,
        longitude: hasCoordinates ? lon : null,
        latitude: hasCoordinates ? lat : null,
        inDock: drone.statusClass !== 'mission',
        altitude: drone._alt,
        speed: drone._speed,
        satellites: drone._sats,
        realtime: rt,
        alarms,
        online,
      })
    }
    return true
  }

Export them on the store return:

  return {
    assets, docks, drones, loaded,
    asset, record, commandType, sendCommand, load,
    applyDockDetail, applyDroneDetail,
  }
  • Step 4: Static check

Run:

rg -n "dockStatusFromDetail|droneStatusFromDetail|applyDockDetail|applyDroneDetail" src/stores/modules/devicesStore.js

Expected: helpers defined; both apply functions present; return exports both apply methods.

  • Step 5: Commit
git add src/stores/modules/devicesStore.js
git commit -m "feat: apply single dock/drone detail into devices store"

Task 2: MonitorView 5s polling + visibility

Files:

  • Modify: src/views/MonitorView/MonitorView.vue

  • Step 1: Add poll state near other page locals

Near selectedId / live setup (script top area after stores), add:

const DEVICE_POLL_MS = 5000
let devicePollTimer = null
let devicePollInflight = null
let devicePollGen = 0
let monitorDisposed = false

function clearDevicePollTimer() {
  window.clearInterval(devicePollTimer)
  devicePollTimer = null
}

async function pollDevices({ toastOnSuccess = false } = {}) {
  if (monitorDisposed) return
  if (devicePollInflight) return
  const gen = devicePollGen
  devicePollInflight = devices.load()
  try {
    await devicePollInflight
    if (monitorDisposed || gen !== devicePollGen) return
    if (selectedId.value && !devices.record(selectedId.value)) clearSelection()
    selectedDockIds.forEach((id) => {
      if (!devices.docks.some((dock) => dock.id === id)) selectedDockIds.delete(id)
    })
    syncMarkers()
    if (toastOnSuccess) ui.toast('设备状态已刷新')
  } catch (e) {
    if (monitorDisposed || gen !== devicePollGen) return
    if (toastOnSuccess) ui.toast(e.message || '刷新设备状态失败')
    else console.warn('[monitor] device poll failed', e)
  } finally {
    devicePollInflight = null
  }
}

function startDevicePolling() {
  clearDevicePollTimer()
  if (monitorDisposed || document.visibilityState === 'hidden') return
  devicePollTimer = window.setInterval(() => {
    void pollDevices()
  }, DEVICE_POLL_MS)
}

function stopDevicePolling() {
  clearDevicePollTimer()
}

function onDevicePollVisibility() {
  if (monitorDisposed) return
  if (document.visibilityState === 'hidden') {
    stopDevicePolling()
    return
  }
  void pollDevices()
  startDevicePolling()
}
  • Step 2: Wire lifecycle

Update onMounted so after the existing initial devices.load() try/catch (keep first-load toast on failure), always start polling if still mounted:

onMounted(async () => {
  monitorDisposed = false
  window.addEventListener('pagehide', onPageHide)
  window.addEventListener('keydown', onGlobalKeydown)
  document.addEventListener('visibilitychange', onDevicePollVisibility)
  try {
    await devices.load()
    if (route.query.deviceId && devices.record(route.query.deviceId)) selectedId.value = String(route.query.deviceId)
    const rec = selectedId.value ? devices.record(selectedId.value) : null
    controlExpanded.value = route.query.control === '1' && rec?.kind === 'dock'
  } catch (e) {
    ui.toast(e.message || '加载设备失败')
  }
  startDevicePolling()
  try {
    await setupMap()
  } catch (e) {
    ui.toast(e.message || '地图初始化失败')
  }
})

Update onUnmounted:

onUnmounted(() => {
  monitorDisposed = true
  devicePollGen += 1
  stopDevicePolling()
  document.removeEventListener('visibilitychange', onDevicePollVisibility)
  window.removeEventListener('pagehide', onPageHide)
  window.removeEventListener('keydown', onGlobalKeydown)
  closeCommandProgress()
  disposeLive()
  teardownMap()
})

Ensure onPageHide (existing) also stops device polling if it only handled live before — either call stopDevicePolling() inside onPageHide, or rely on unmount; prefer also stopping in onPageHide so bfcache hides don’t keep firing.

  • Step 3: Point reload() through the same in-flight gate

Replace reload body with:

async function reload() {
  await pollDevices({ toastOnSuccess: true })
  if (monitorDisposed) return
  if (selectedId.value) flyToSelected()
  else resetOverviewCamera()
}

(pollDevices already clears stale selection / selectedDockIds / syncMarkers.)

  • Step 4: Static check
rg -n "DEVICE_POLL_MS|pollDevices|startDevicePolling|visibilitychange|toastOnSuccess" src/views/MonitorView/MonitorView.vue

Expected: 5000 interval, visibility handler, reload uses pollDevices({ toastOnSuccess: true }).

  • Step 5: Commit
git add src/views/MonitorView/MonitorView.vue
git commit -m "feat: poll monitor device status every 5s"

Task 3: DeviceDetailView current-id detail polling

Files:

  • Modify: src/views/DeviceDetailView/DeviceDetailView.vue

  • Step 1: Extend imports

Change:

import { computed, onMounted, ref } from 'vue'

to:

import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
  • Step 2: Add poll helpers after record / isDrone computeds
const DETAIL_POLL_MS = 5000
let detailPollTimer = null
let detailPollInflight = null
let detailPollGen = 0
let detailDisposed = false

function clearDetailPollTimer() {
  window.clearInterval(detailPollTimer)
  detailPollTimer = null
}

async function pollCurrentDetail() {
  if (detailDisposed) return
  if (detailPollInflight) return
  const id = String(route.params.id || '')
  const rec = devices.record(id)
  if (!id || !rec) return
  const gen = detailPollGen
  const url = rec.kind === 'drone' ? urls.DRONE(id) : urls.DOCK(id)
  detailPollInflight = request.get(url)
  try {
    const detail = await detailPollInflight
    if (detailDisposed || gen !== detailPollGen) return
    if (String(route.params.id) !== id) return
    if (rec.kind === 'drone') devices.applyDroneDetail(id, detail || {})
    else devices.applyDockDetail(id, detail || {})
  } catch (e) {
    if (detailDisposed || gen !== detailPollGen) return
    console.warn('[device-detail] poll failed', e)
  } finally {
    detailPollInflight = null
  }
}

function startDetailPolling() {
  clearDetailPollTimer()
  if (detailDisposed || document.visibilityState === 'hidden') return
  detailPollTimer = window.setInterval(() => {
    void pollCurrentDetail()
  }, DETAIL_POLL_MS)
}

function stopDetailPolling() {
  clearDetailPollTimer()
}

function restartDetailPolling() {
  detailPollGen += 1
  stopDetailPolling()
  void pollCurrentDetail()
  startDetailPolling()
}

function onDetailVisibility() {
  if (detailDisposed) return
  if (document.visibilityState === 'hidden') {
    stopDetailPolling()
    return
  }
  void pollCurrentDetail()
  startDetailPolling()
}
  • Step 3: Lifecycle + id watch

Replace onMounted and add unmount/watch:

onMounted(async () => {
  detailDisposed = false
  document.addEventListener('visibilitychange', onDetailVisibility)
  try {
    await devices.load()
    if (!isAdmin.value) {
      await Promise.all([loadLogs(), loadAlarms()])
    }
  } catch (e) {
    ui.toast(e.message || '加载设备失败')
  }
  startDetailPolling()
})

onUnmounted(() => {
  detailDisposed = true
  detailPollGen += 1
  stopDetailPolling()
  document.removeEventListener('visibilitychange', onDetailVisibility)
})

watch(
  () => String(route.params.id || ''),
  (next, prev) => {
    if (!next || next === prev) return
    restartDetailPolling()
  }
)

Keep handleDockSaved full devices.load() as-is (edit path, not poll path).

  • Step 4: Static check
rg -n "DETAIL_POLL_MS|pollCurrentDetail|applyDockDetail|applyDroneDetail|visibilitychange" src/views/DeviceDetailView/DeviceDetailView.vue

Expected: 5s timer; apply* calls; visibility + id watch; no DevicesView changes.

  • Step 5: Commit
git add src/views/DeviceDetailView/DeviceDetailView.vue
git commit -m "feat: poll open device detail every 5s"

Task 4: Build, deploy, smoke

Files: none (verify only)

  • Step 1: Build
npm run build

Expected: Vite build success.

  • Step 2: Push + deploy (same channel as recent frontend deploys)
git push origin main
tar -C dist -czf - . | ssh -o BatchMode=yes jg-serv1 'rm -rf /usr/share/nginx/laic-frontend/dist/* && tar -C /usr/share/nginx/laic-frontend/dist -xzf -'
  • Step 3: Manual smoke
  1. Open 实时监控 → DevTools Network:约每 5s 出现 /v1/docks/v1/drones 及详情请求;列表/右侧状态会变。
  2. Open 某台设备详情 → 仅见该 id 的 /v1/docks/:id/v1/drones/:id 约 5s 一次(在首次 load 之后)。
  3. Open 设备管理 → 定时详情轮询。
  4. 切到其它浏览器标签再回来:监控/详情应先补一次请求再继续 interval。
  5. 开播/停播/指令浮层仍可用。
  • Step 4: Final commit only if smoke required doc tweak; otherwise done

No code commit required if Steps 1–3 pass.


Self-review vs spec

Spec requirement Task
Monitor 5s devices.load Task 2
in-flight skip + silent fail Task 2 pollDevices
visibility pause / resume + immediate refresh Task 2
unmount / pagehide stop Task 2
reload shares in-flight Task 2 Step 3
DeviceDetail current-id only Task 3
apply* merge + assets sync; missing id no-op Task 1
DevicesView no auto poll no task touches it
live / command progress unchanged Tasks avoid those modules
build/deploy/smoke Task 4