collabSupport/services/voiceDiag/checks/phoneOnline.js
jmcqueen d12723d010 Voicediag: store voice standards + port-hygiene checks + apply-all card
Refactor every /voicediag check to declare a top-level `standards`
object so the desired state is legible without reading run() logic
and can drive a documented reference table. Upgrade callForwarding
to error severity, tighten voicemail with three send-to-VM error
paths + a `stop_sending_to_voicemail` remediation, and add a
`disable_hoteling` remediation.

Add a port-hygiene check bucket under services/voiceDiag/checks/port
(portType, portVlan, portPoe, portEnabled) that reuses the phone-
status snapshot to enforce switchport standards. Configurable via
VOICE_STANDARD_PHONE_VLAN (default 102) and VOICE_STANDARD_ENABLED
(kill-switch). Preserve Meraki `portType`/`voiceVlan`/`dataVlan`
through the enrichment chain so the checks have clean data to read.

Add an "apply all N fixes" combined card that shows up when 2+
remediations are available. New confirm_voicediag_all /
cancel_voicediag_all actions run each fix in sequence (readable
audit trail, no per-person write-throttle stacking), accumulate
individual failures into a summary rather than aborting.

Adds regression tests asserting every check exposes .standards,
plus coverage for port checks, kill-switch, and combined-card
iteration. 63 tests in the checks file, 188 total, all green.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 14:14:38 -04:00

138 lines
4.3 KiB
JavaScript

// src/services/voiceDiag/checks/phoneOnline.js
//
// Summarises how many of the store's registered devices are online
// right now, plus a per-device drill-down. Deliberately reuses the
// phone-status snapshot already fetched during buildContext() — this
// check makes ZERO new API calls, which keeps it fast and keeps the
// /voicediag output aligned with /phonestatus for the same store
// (same source, same view of "which phones are up right now").
//
// Signal shape:
// status: 'error' when at least one phone is offline (a store with
// a dead phone is a real ticket).
// status: 'warn' when the store has no registered devices at all
// (this is often "we didn't ship / provision yet" but still worth
// surfacing).
// status: 'ok' when every device reports `connected`.
//
// Device shape from collectPhoneStatus:
// data.phones.data[i] = { mac, name, status, lastSeen, firmware, model, ... }
// data.dectBasestations[i] = { mac, name, status, lastSeen, firmware, ... }
//
// Statuses observed in the wild: 'connected', 'disconnected',
// 'unknown', 'activating', 'offline'. We treat 'connected' as the
// only truly OK value; everything else counts as offline for the
// purposes of the summary.
const OK_STATUSES = new Set(['connected']);
function isOnline(dev) {
const s = String(dev?.status || '').toLowerCase();
return OK_STATUSES.has(s);
}
export const PHONE_ONLINE_STANDARDS = Object.freeze({
// Every registered device must report 'connected'. Anything else
// (disconnected, unknown, activating, offline) is treated as
// offline.
status: 'connected',
});
export const phoneOnlineCheck = {
id: 'phoneOnline',
label: 'Phone Online Status',
requires: ['phoneStatus'],
// No new API scope required — reuses collectPhoneStatus() output,
// which is already gated on the /phonestatus set of scopes.
scope: null,
standards: PHONE_ONLINE_STANDARDS,
async run(ctx) {
const data = ctx.phoneStatus;
if (!data) {
return {
status: 'skipped',
message: 'phoneStatus snapshot unavailable — cannot report device online counts.',
details: null,
remediation: null,
};
}
const phones = Array.isArray(data.phones?.data) ? data.phones.data : [];
const dectBases = Array.isArray(data.dectBasestations) ? data.dectBasestations : [];
const phonesOnline = phones.filter(isOnline);
const phonesOffline = phones.filter((p) => !isOnline(p));
const basesOnline = dectBases.filter(isOnline);
const basesOffline = dectBases.filter((b) => !isOnline(b));
const totalDevices = phones.length + dectBases.length;
if (totalDevices === 0) {
return {
status: 'warn',
message: 'No desk phones or DECT basestations are registered for this store.',
details: {
phonesTotal: 0,
phonesOnline: 0,
phonesOffline: 0,
basesTotal: 0,
basesOnline: 0,
basesOffline: 0,
},
remediation: null,
};
}
const offlineCount = phonesOffline.length + basesOffline.length;
const summary =
`Desk phones: ${phonesOnline.length}/${phones.length} online. ` +
`DECT bases: ${basesOnline.length}/${dectBases.length} online.`;
const offlineList = [
...phonesOffline.map((p) => ({
kind: 'phone',
name: p.name || 'Unknown phone',
mac: p.mac || null,
status: p.status || 'unknown',
lastSeen: p.lastSeen || null,
model: p.model || null,
})),
...basesOffline.map((b) => ({
kind: 'dect-base',
name: b.name || 'Unknown DECT base',
mac: b.mac || null,
status: b.status || 'unknown',
lastSeen: b.lastSeen || null,
model: b.model || null,
})),
];
const details = {
phonesTotal: phones.length,
phonesOnline: phonesOnline.length,
phonesOffline: phonesOffline.length,
basesTotal: dectBases.length,
basesOnline: basesOnline.length,
basesOffline: basesOffline.length,
offlineDevices: offlineList,
};
if (offlineCount === 0) {
return {
status: 'ok',
message: summary,
details,
remediation: null,
};
}
return {
status: 'error',
message:
`${offlineCount} device(s) offline for this store. ${summary}`,
details,
remediation: null,
};
},
};