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.
257 lines
10 KiB
JavaScript
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();
|
|
}
|