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
loadand 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/isDronecomputeds
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
- Open 实时监控 → DevTools Network:约每 5s 出现
/v1/docks、/v1/drones及详情请求;列表/右侧状态会变。 - Open 某台设备详情 → 仅见该 id 的
/v1/docks/:id或/v1/drones/:id约 5s 一次(在首次 load 之后)。 - Open 设备管理 → 无定时详情轮询。
- 切到其它浏览器标签再回来:监控/详情应先补一次请求再继续 interval。
- 开播/停播/指令浮层仍可用。
- 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 |