# 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`): ```js 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: ```js const st = dockStatusFromDetail(registerStatus, online, rt) ``` In the drone `map` callback, replace `armed` / `altitude` / `flying` / `st` with: ```js 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: ```js 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: ```js return { assets, docks, drones, loaded, asset, record, commandType, sendCommand, load, applyDockDetail, applyDroneDetail, } ``` - [ ] **Step 4: Static check** Run: ```bash 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** ```bash 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: ```js 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: ```js 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`: ```js 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: ```js 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** ```bash 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** ```bash 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: ```js import { computed, onMounted, ref } from 'vue' ``` to: ```js import { computed, onMounted, onUnmounted, ref, watch } from 'vue' ``` - [ ] **Step 2: Add poll helpers after `record` / `isDrone` computeds** ```js 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: ```js 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** ```bash 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** ```bash 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** ```bash npm run build ``` Expected: Vite build success. - [ ] **Step 2: Push + deploy** (same channel as recent frontend deploys) ```bash 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 |