collabSupport/services/renderers/avStatusRenderer.js
Joseph McQueen 1117be40cc Format chat footers in DISPLAY_TIMEZONE instead of UTC.
Docker hosts default to UTC, so bare toLocaleTimeString() showed
wrong "Last checked" times in avstatus and other commands. Add
formatDisplayTime() (default America/New_York, overridable via
DISPLAY_TIMEZONE) and use it across renderers and command footers.
2026-07-21 14:41:26 -04:00

257 lines
10 KiB
JavaScript

// src/services/renderers/avStatusRenderer.js
//
// Extracted from commands/avStatus.js so the same output can drive
// BOTH the chat reply (`bot.say('markdown', md)`) AND the Jira poller
// comment (via utils/markdownToAdf). Byte-for-byte identical to what
// the chat command used to emit for a given input.
//
// Options
// storeNum (required) header string uses it
// detailed default true — historically the chat handler always ran
// detailed (the `Mode: detailed` string is hard-coded in
// the header). We keep the option in the signature for
// symmetry with the phone renderer and future compact-mode
// work.
// footer default true — appends `*Last checked: HH:MM*` italic
// line. Poller passes false because a Jira comment already
// carries an authoritative header timestamp.
//
// `appendMerakiClientLines` is preserved verbatim from the original
// handler — same wired-vs-wireless branch, same double-arrow indent
// convention, same fallback for `{client:...}` vs flat shapes.
import { simpleTimeAgo, formatDisplayTime } from '../../utils/time.js';
/**
* Render an AV device-status markdown snapshot from a
* `collectDeviceStatus` result.
*
* @param {object} data collectDeviceStatus() output
* @param {object} opts
* @param {string} opts.storeNum store id (header text)
* @param {boolean} [opts.detailed=true]
* @param {boolean} [opts.footer=true]
* @returns {string} markdown, whitespace-trimmed and ready to send.
*/
export function renderAvStatusMarkdown(data, opts = {}) {
const { storeNum, footer = true } = opts;
// `detailed` is accepted for signature symmetry but the existing AV
// renderer has always emitted detailed output — see file header note.
// Explicit reference prevents accidental prop-name typos going silent.
void opts.detailed;
const mdmDevices = data.mdm?.data || [];
const atlasData = data.atlas?.data || [];
let reply = `**Device Status - Store ${storeNum}** (Mode: detailed)\n\n`;
// Helper: append Meraki client info under the device name. Preserved
// verbatim from commands/avStatus.js so wired-vs-wireless output stays
// byte-identical.
function appendMerakiClientLines(currentReply, mInput) {
if (!mInput) return currentReply;
const m = mInput.client || mInput;
if (!m || Object.keys(m).length === 0) return currentReply;
const clientSeenHours = m.lastSeen ? (Date.now() - new Date(m.lastSeen).getTime()) / (1000 * 60 * 60) : 999;
const clientEmoji = clientSeenHours > 1 ? '⚠️' : '✅';
let clientLink = '';
const clientUrl = m.clientUrl || m.merakiClientUrl;
if (clientUrl) {
clientLink = ` [Meraki↗](${clientUrl})`;
} else if (m.clientId && m.networkBaseUrl) {
const base = m.networkBaseUrl.replace(/\/manage\/clients$/, '');
const merakiClientUrl = `${base}/manage/clients/${m.clientId}/overview`;
clientLink = ` [Meraki↗](${merakiClientUrl})`;
}
const ip = m.ip ? `IP: ${m.ip}` : '';
const mac = m.mac ? `MAC: ${m.mac}` : '';
const last = m.lastSeen ? `${simpleTimeAgo(m.lastSeen)}` : '';
const conn = m.recentDeviceConnection || 'Wired';
const cstatus = m.clientStatus || m.status || '—';
const devName = m.deviceName || m.name || '';
const isWired = conn === 'Wired' || !!m.portNumber || !!m.switchport || !!m.port;
if (isWired && devName) {
currentReply += `${clientEmoji} **${devName} (${conn} - ${cstatus})** • ${ip}${mac} ${last ? `${last}` : ''}${clientLink}\n`;
const portNum = m.portNumber || m.switchport || m.port || '—';
const vlan = m.dataVlan || m.vlan || '—';
const voiceVlan = m.voiceVlan ? `Voice VLAN: ${m.voiceVlan}` : '';
const portType = m.type ? m.type : '';
const portName = m.portName ? m.portName : '';
const status = m.status || (m.enabled ? 'Enabled' : 'Disabled');
let portLine = ` → → Port: **${portNum}**`;
if (portType) portLine += ` • Type: ${portType}`;
if (portName) portLine += ` • Name: ${portName}`;
portLine += ` • VLAN: ${vlan}`;
if (voiceVlan) portLine += `${voiceVlan}`;
portLine += `${status}\n`;
currentReply += portLine;
const policy = m.accessPolicy || '—';
const stickyCount = m.allowedMacs?.length || 0;
const stickyText = stickyCount > 0 ? `Sticky MAC (${stickyCount})` : '—';
const poeText = m.poeEnabled === true ? '✅ POE On' : (m.poeEnabled === false ? 'POE Off' : '—');
const errors = m.errors?.length > 0 ? `⚠️ Errors: ${m.errors.join(', ')}` : '';
currentReply += ` → → Policy: ${policy}${stickyText}${poeText} ${errors ? `${errors}` : ''}\n`;
} else {
currentReply += `${clientEmoji} **${devName || 'Wireless'} (${m.recentDeviceConnection || 'Wireless'} - ${cstatus})** IP: ${m.ip || '—'} • MAC: ${m.mac || '—'} ${m.lastSeen ? `${simpleTimeAgo(m.lastSeen)}` : ''}${clientLink}\n`;
currentReply += ` → → SSID: ${m.ssid || '—'} • VLAN: ${m.vlan || '—'}\n`;
}
return currentReply;
}
if (mdmDevices.length === 0) {
reply += 'No MDM devices found for this store.\n';
} else {
mdmDevices.forEach(dev => {
const mdmName = dev.friendlyName || dev.name || 'Unknown Device';
const mdmLastSeenRaw = dev.lastSeen || dev.LastSeen || dev.LastSystemSampleTime;
const mdmLastSeen = mdmLastSeenRaw ? simpleTimeAgo(mdmLastSeenRaw) : '—';
const m = dev.meraki || {};
const mdmSeenHours = mdmLastSeenRaw ? (Date.now() - new Date(mdmLastSeenRaw).getTime()) / (1000 * 60 * 60) : 999;
const mdmEmoji = mdmSeenHours > 24 ? '❌' : '✅';
reply += `${mdmEmoji} **${mdmName}** Last seen: ${mdmLastSeen}\n`;
reply = appendMerakiClientLines(reply, m);
if (dev.red) {
const red = dev.red;
const connectivity = red.Connectivity || '—';
const deployment = red.DeploymentStatusName || '—';
const stateTransition = red.StateTransitionStatus || '—';
const lastPing = red.LastPingTimeUTC ? simpleTimeAgo(red.LastPingTimeUTC) : '—';
const availability = red.AvailabilityStatus || '—';
const isLastPingOld = red.LastPingTimeUTC
? (Date.now() - new Date(red.LastPingTimeUTC).getTime()) / (1000 * 60 * 60) > 1
: true;
const hasProblem =
isLastPingOld ||
stateTransition !== 'Current' ||
availability !== 'Available' ||
deployment !== 'PROCESSED';
const redEmoji = hasProblem ? '⚠️' : '✅';
reply += `${redEmoji} **RED ${connectivity}** • Availability: ${availability} • Deployment: ${deployment} • State: ${stateTransition} • Last Ping: ${lastPing}\n`;
}
if (dev.optisigns) {
const o = dev.optisigns;
const optiEmoji = o.isOld ? '⚠️' : '✅';
reply += `${optiEmoji} **OptiSigns** ${o.content} • Last heartbeat: ${o.lastHeartBeat ? simpleTimeAgo(o.lastHeartBeat) : '—'}\n`;
}
reply += '\n';
});
}
if (atlasData && atlasData.length > 0) {
reply += `**Atlas Devices:**\n\n`;
atlasData.forEach(dev => {
const name = dev.name || dev.displayName || 'US002477AMP';
const status = (dev.status || dev.state?.status || 'unknown').toLowerCase();
const lastSeenTime = dev.last_seen_at || dev.lastSeen
? new Date(dev.last_seen_at || dev.lastSeen).getTime()
: 0;
const minutesAgo = (Date.now() - lastSeenTime) / (1000 * 60);
const voltage = parseFloat(dev.state?.voltageMonitor) || 120;
const voltageProblem = Math.abs(voltage - 120) > 8;
const faultStatus = dev.state?.faultStatus || 0;
const hasFault = faultStatus > 0;
const ampsNotReady = dev.state
? Object.keys(dev.state).some(k =>
(k.startsWith('ampStatus_') || k.startsWith('ampModuleStatus_')) &&
!['Ready', 'Active', 'Standby'].includes(dev.state[k])
)
: false;
const hasProblem = minutesAgo > 20 || status !== 'online' || voltageProblem || hasFault || ampsNotReady;
const emoji = hasProblem ? '⚠️' : '✅';
const lastSeenStr = dev.last_seen_at || dev.lastSeen
? simpleTimeAgo(dev.last_seen_at || dev.lastSeen)
: '—';
reply += `${emoji} **${name}** (${status}) • Last seen: ${lastSeenStr}\n`;
const m = dev.meraki || {};
reply = appendMerakiClientLines(reply, m);
const modelInfo = [];
if (dev.model?.name) modelInfo.push(dev.model.name);
if (dev.firmware?.version) modelInfo.push(`FW ${dev.firmware.version}`);
if (dev.sn || dev.serial) modelInfo.push(`SN ${dev.sn || dev.serial}`);
if (dev.state?.IpAddress) modelInfo.push(`IP ${dev.state.IpAddress}`);
if (modelInfo.length > 0) {
reply += `${modelInfo.join(' • ')}\n`;
}
if (dev.state) {
const s = dev.state;
const cpuF = s.tempCpu ? Math.round((parseFloat(s.tempCpu) * 9 / 5) + 32) : '—';
const psuF = s.tempPsu ? Math.round((parseFloat(s.tempPsu) * 9 / 5) + 32) : '—';
const ioF = s.tempIo ? Math.round((parseFloat(s.tempIo) * 9 / 5) + 32) : '—';
reply += ` → CPU: ${cpuF}°F • PSU: ${psuF}°F • Io: ${ioF}°F • Voltage: ${voltage}V • Fan: ${s.fanSpeed ? Math.round(s.fanSpeed) : '—'}%\n`;
}
if (dev.state) {
const s = dev.state;
let ampInfo = [];
for (let i = 1; i <= 8; i++) {
const statusKey = `ampStatus_${i}`;
const moduleKey = `ampModuleStatus_${i}`;
const ampStatus = s[statusKey] || s[moduleKey];
if (ampStatus) {
ampInfo.push(`Amp${i}: ${ampStatus}`);
}
}
if (ampInfo.length > 0) {
reply += ` → Amps: ${ampInfo.join(', ')}\n`;
}
}
if (hasFault) {
reply += ` → ⚠️ Fault Status: ${faultStatus}\n`;
}
if (dev.state?.lastLogEntry) {
let logMessage = dev.state.lastLogEntry;
if (logMessage.includes("System startup from power connection")) {
logMessage = "System startup (power cycle)";
}
const logTime = dev.state.lastLogEntry.includes("2025-Nov-04")
? simpleTimeAgo("2025-11-04T15:51:26Z")
: "recent";
reply += ` → Last Log: ${logMessage} (${logTime})\n`;
}
reply += '\n';
});
}
if (footer) {
reply += `*Last checked: ${formatDisplayTime()}*`;
}
return reply.trim();
}