chore: Phase 0 initial hygiene
- Initialize git repository - Add comprehensive .gitignore (protects .env and secrets) - Fix package.json (correct main entry, add metadata) - Expand .env.example with all required variables and comments - Add README.md with architecture, setup, and commands - Clean up empty scaffolding directories (logs removed, agents/models marked) - Backup previous .env file locally This establishes a safe foundation before further development.
This commit is contained in:
commit
459301cc4d
19 changed files with 13547 additions and 0 deletions
33
.env.example
Normal file
33
.env.example
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# =============================================================================
|
||||||
|
# NetAnalyzer Environment Configuration
|
||||||
|
# Copy this file to .env and fill in your actual values.
|
||||||
|
# NEVER commit .env — it is gitignored.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# --- Webex Bot (required for bot functionality) ---
|
||||||
|
WEBEX_ACCESS_TOKEN=your_webex_bot_access_token_here
|
||||||
|
BOT_NAME=NetAnalyzer
|
||||||
|
|
||||||
|
# --- Meraki (required for network device discovery and client status) ---
|
||||||
|
MERAKI_API_KEY=your_meraki_api_key_here
|
||||||
|
MERAKI_ORG_ID=your_meraki_organization_id_here
|
||||||
|
|
||||||
|
# --- WebSocket Remote Agent ---
|
||||||
|
WS_PORT=8080
|
||||||
|
# Token used to authenticate the remoteAgent.js when connecting to this server
|
||||||
|
WS_TOKEN=generate_a_strong_random_token_here
|
||||||
|
|
||||||
|
# For the remote agent process (remoteAgent.js), point to the main server
|
||||||
|
WS_URL=ws://localhost:8080?token=generate_a_strong_random_token_here
|
||||||
|
|
||||||
|
# --- SIW / Store Information Warehouse (proxied via remote agent) ---
|
||||||
|
SIW_BASE_URL=https://your-siw-api.example.com/api
|
||||||
|
SIW_USERNAME=your_siw_username
|
||||||
|
SIW_PASSWORD=your_siw_password
|
||||||
|
|
||||||
|
# --- Workspace ONE (MDM / AirWatch) ---
|
||||||
|
WS1_BASE_URL=https://your-tenant.awmdm.com
|
||||||
|
WS1_TOKEN_URL=https://your-tenant.awmdm.com/api/mdm/token
|
||||||
|
WS1_CLIENT_ID=your_ws1_client_id
|
||||||
|
WS1_CLIENT_SECRET=your_ws1_client_secret
|
||||||
|
WS1_TENANT_CODE=your_ws1_tenant_code
|
||||||
30
.gitignore
vendored
Normal file
30
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Environment secrets - CRITICAL
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# IDE / Editor
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Test / Coverage
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.tgz
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
139
README.md
Normal file
139
README.md
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
# NetAnalyzer
|
||||||
|
|
||||||
|
Webex bot that provides store-level network and device health analysis by correlating data from Meraki, SIW (Store Information Warehouse), and Workspace ONE MDM.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
- `store <number>` — Full detailed report of a store's registers, printers, payment terminals, network devices, MDM inventory, and their online/offline status via Meraki clients.
|
||||||
|
- `analyze <number>` — Higher-level health summary with an overall score and prioritized issues (network, POS systems, peripherals).
|
||||||
|
|
||||||
|
The bot helps operations teams quickly understand the connectivity and device health state of a retail location.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐ ┌─────────────────────┐
|
||||||
|
│ Webex Bot │◄────────►│ NetAnalyzer Server │
|
||||||
|
│ (Webex rooms) │ Webex │ (server.js) │
|
||||||
|
└─────────────────┘ └─────────┬───────────┘
|
||||||
|
│
|
||||||
|
│ WebSocket (authenticated)
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ Remote Agent │◄──► SIW API (Basic Auth)
|
||||||
|
│ (remoteAgent.js) │◄──► Workspace ONE MDM
|
||||||
|
└─────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Meraki API (direct from server)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why the remote agent?**
|
||||||
|
SIW and some MDM systems are only reachable from specific internal networks. The remote agent runs in that environment and proxies requests back to the main NetAnalyzer server over an authenticated WebSocket.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js >= 18
|
||||||
|
- A Webex bot account with access token
|
||||||
|
- Meraki API key with organization access
|
||||||
|
- Access to your organization's SIW API and Workspace ONE (AirWatch) environment
|
||||||
|
- Ability to run the remote agent on a machine that can reach SIW/MDM
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. **Clone and install**
|
||||||
|
```bash
|
||||||
|
git clone <repo>
|
||||||
|
cd netanalyzer
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Configure environment**
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your real credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
Required values are documented in `.env.example`.
|
||||||
|
|
||||||
|
3. **(Optional but recommended) Rotate all secrets**
|
||||||
|
If you previously had credentials in the repository, rotate:
|
||||||
|
- Webex bot token
|
||||||
|
- Meraki API key
|
||||||
|
- SIW credentials
|
||||||
|
- Workspace ONE client secret + tenant code
|
||||||
|
- WS_TOKEN
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
### Main server + bot (where the Webex connection lives)
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
# or for development with auto-reload
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
This starts:
|
||||||
|
- The Webex bot framework (listens for messages in Webex spaces)
|
||||||
|
- The WebSocket server on the port defined in `WS_PORT` (default 8080)
|
||||||
|
|
||||||
|
### Remote agent (run on a machine that can reach internal systems)
|
||||||
|
```bash
|
||||||
|
# On the internal machine
|
||||||
|
node remoteAgent.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Make sure `WS_URL` in its environment points to the main server with the correct `WS_TOKEN`.
|
||||||
|
|
||||||
|
## Bot Commands
|
||||||
|
|
||||||
|
In any Webex space where the bot is a member:
|
||||||
|
|
||||||
|
- `store 782` — Detailed device inventory and connectivity for store 782
|
||||||
|
- `analyze 782` — Quick health summary with score for store 782
|
||||||
|
|
||||||
|
The bot also responds to variations containing "store" or "analyze".
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── bot/
|
||||||
|
│ └── handlers.js # Webex command handlers
|
||||||
|
├── config/
|
||||||
|
│ └── index.js # Centralized configuration from env
|
||||||
|
├── integrations/
|
||||||
|
│ ├── storeDetail.js # Full store report builder
|
||||||
|
│ └── storeHealth.js # Health score + summary
|
||||||
|
├── services/
|
||||||
|
│ ├── meraki.js # Meraki API client + caching
|
||||||
|
│ ├── siw.js # SIW calls (via remote proxy)
|
||||||
|
│ ├── mdm.js # Workspace ONE MDM client
|
||||||
|
│ └── websocket.js # WebSocket server + proxyRequest helper
|
||||||
|
├── utils/
|
||||||
|
│ ├── formatter.js
|
||||||
|
│ └── dataMerger.js # (currently unused)
|
||||||
|
├── remoteAgent.js # Lightweight proxy client
|
||||||
|
├── server.js # Main entry point (bot + ws server)
|
||||||
|
├── package.json
|
||||||
|
└── .env.example
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
- **Never commit `.env`** (it is gitignored).
|
||||||
|
- The WebSocket connection between server and remote agent is protected by a shared token (`WS_TOKEN`).
|
||||||
|
- All SIW communication uses Basic Auth and is only performed through the remote agent.
|
||||||
|
- Meraki and MDM calls use tokens that should be scoped to the minimum necessary permissions.
|
||||||
|
|
||||||
|
## Next Steps / Roadmap
|
||||||
|
|
||||||
|
See the phased improvement plan in the project history (or ask the maintainer for current priorities):
|
||||||
|
- Phase 0: Foundational hygiene (git, secrets, docs) ← **current**
|
||||||
|
- Phase 1: Input validation, shared logic extraction, graceful shutdown
|
||||||
|
- Phase 2: Linting, tests, structured logging, basic resilience
|
||||||
|
- Phase 3: Domain modeling and architectural cleanup
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
ISC
|
||||||
1
agents/.gitkeep
Normal file
1
agents/.gitkeep
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
# Reserved for future agent implementations
|
||||||
56
bot/handlers.js
Normal file
56
bot/handlers.js
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
const { getStoreDetail } = require('../integrations/storeDetail');
|
||||||
|
|
||||||
|
async function handleStoreCommand(bot, trigger) {
|
||||||
|
const words = trigger.message.text.trim().split(/\s+/);
|
||||||
|
const storeNumber = words[1];
|
||||||
|
|
||||||
|
if (!storeNumber || isNaN(storeNumber)) {
|
||||||
|
return bot.say('markdown', 'Usage: `store <number>`\nExample: `store 305`');
|
||||||
|
}
|
||||||
|
|
||||||
|
bot.say('markdown', `🔍 Analyzing Store **${storeNumber}**...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fullReport = await getStoreDetail(storeNumber);
|
||||||
|
const sections = fullReport.split(/\n\n(?=\*\*)/);
|
||||||
|
|
||||||
|
for (let i = 0; i < sections.length; i++) {
|
||||||
|
const section = sections[i].trim();
|
||||||
|
if (section) {
|
||||||
|
await bot.say('markdown', section);
|
||||||
|
if (i < sections.length - 1) await new Promise(r => setTimeout(r, 400));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Analysis error:', err);
|
||||||
|
bot.say('markdown', `❌ Error analyzing store **${storeNumber}**:\n${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { getStoreHealth } = require('../integrations/storeHealth');
|
||||||
|
|
||||||
|
async function handleAnalyzeCommand(bot, trigger) {
|
||||||
|
const words = trigger.message.text.trim().split(/\s+/);
|
||||||
|
const storeNumber = words[1];
|
||||||
|
|
||||||
|
if (!storeNumber || isNaN(storeNumber)) {
|
||||||
|
return bot.say('markdown', 'Usage: `analyze <number>`\nExample: `analyze 305`');
|
||||||
|
}
|
||||||
|
|
||||||
|
bot.say('markdown', `🔍 Running Health Analysis for Store **${storeNumber}**...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const health = await getStoreHealth(storeNumber);
|
||||||
|
|
||||||
|
if (health && health.summary) {
|
||||||
|
bot.say('markdown', health.summary); // ← Correct way
|
||||||
|
} else {
|
||||||
|
bot.say('markdown', '✅ Analysis completed, but no summary was generated.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Health analysis error:', err);
|
||||||
|
bot.say('markdown', `❌ Error analyzing store **${storeNumber}**:\n${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handleStoreCommand, handleAnalyzeCommand };
|
||||||
29
config/index.js
Normal file
29
config/index.js
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
webex: {
|
||||||
|
token: process.env.WEBEX_ACCESS_TOKEN,
|
||||||
|
name: process.env.BOT_NAME || 'NetAnalyzer'
|
||||||
|
},
|
||||||
|
meraki: {
|
||||||
|
baseUrl: 'https://api.meraki.com/api/v1',
|
||||||
|
apiKey: process.env.MERAKI_API_KEY,
|
||||||
|
orgId: process.env.MERAKI_ORG_ID
|
||||||
|
},
|
||||||
|
ws: {
|
||||||
|
port: parseInt(process.env.WS_PORT) || 8080,
|
||||||
|
token: process.env.WS_TOKEN
|
||||||
|
},
|
||||||
|
siw: {
|
||||||
|
baseUrl: process.env.SIW_BASE_URL,
|
||||||
|
username: process.env.SIW_USERNAME,
|
||||||
|
password: process.env.SIW_PASSWORD
|
||||||
|
},
|
||||||
|
mdm: {
|
||||||
|
baseUrl: process.env.WS1_BASE_URL,
|
||||||
|
tokenUrl: process.env.WS1_TOKEN_URL,
|
||||||
|
clientId: process.env.WS1_CLIENT_ID,
|
||||||
|
clientSecret: process.env.WS1_CLIENT_SECRET,
|
||||||
|
tenantCode: process.env.WS1_TENANT_CODE
|
||||||
|
}
|
||||||
|
};
|
||||||
344
integrations/storeDetail.js
Normal file
344
integrations/storeDetail.js
Normal file
|
|
@ -0,0 +1,344 @@
|
||||||
|
const { getStoreLocation, getStoreRegisters, getStorePrinters, getStorePaymentTerminals } = require('../services/siw');
|
||||||
|
const { findMerakiNetwork, getMerakiDeviceAvailabilities, getMerakiClients } = require('../services/meraki');
|
||||||
|
const { formatStoreReport } = require('../utils/formatter');
|
||||||
|
|
||||||
|
function formatLastSeen(lastSeen) {
|
||||||
|
if (!lastSeen) return 'N/A';
|
||||||
|
const now = new Date();
|
||||||
|
const seen = new Date(lastSeen);
|
||||||
|
const diffMs = now - seen;
|
||||||
|
const diffMin = Math.floor(diffMs / 60000);
|
||||||
|
|
||||||
|
if (diffMin < 1) return 'Just now';
|
||||||
|
if (diffMin < 60) return `${diffMin} min ago`;
|
||||||
|
const hours = Math.floor(diffMin / 60);
|
||||||
|
return `${hours} hr ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStoreDetail(storeNumber) {
|
||||||
|
console.log(`🔍 Starting full analysis for store ${storeNumber}`);
|
||||||
|
|
||||||
|
let locationData, merakiNetwork, registers, printers, paymentTerminals, merakiClients = [], mdmDevices = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Phase 1: Fast lookups
|
||||||
|
[locationData, merakiNetwork] = await Promise.all([
|
||||||
|
getStoreLocation(storeNumber),
|
||||||
|
findMerakiNetwork(storeNumber)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Phase 2: Heavy parallel lookups
|
||||||
|
const parallelPromises = [
|
||||||
|
getStoreRegisters(storeNumber),
|
||||||
|
getStorePrinters(storeNumber),
|
||||||
|
getStorePaymentTerminals(storeNumber)
|
||||||
|
];
|
||||||
|
|
||||||
|
if (merakiNetwork) {
|
||||||
|
parallelPromises.push(
|
||||||
|
getMerakiClients(merakiNetwork.id),
|
||||||
|
require('../services/mdm').getMDMDevices(storeNumber)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.all(parallelPromises);
|
||||||
|
|
||||||
|
registers = results[0];
|
||||||
|
printers = results[1];
|
||||||
|
paymentTerminals = results[2];
|
||||||
|
|
||||||
|
if (merakiNetwork) {
|
||||||
|
console.log(`Meraki Network:`)
|
||||||
|
console.log(JSON.stringify(merakiNetwork, null, 2))
|
||||||
|
merakiClients = results[3] || [];
|
||||||
|
mdmDevices = results[4] || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ Fetched: ${registers?.length || 0} registers, ${printers?.length || 0} printers, ${paymentTerminals?.length || 0} payment terminals`);
|
||||||
|
if (merakiNetwork) {
|
||||||
|
console.log(`✅ Fetched ${merakiClients.length} Meraki clients`);
|
||||||
|
console.log(`✅ Fetched ${mdmDevices.length} MDM devices`);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Error fetching data:', err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
let report = formatStoreReport(locationData, storeNumber);
|
||||||
|
|
||||||
|
// === Meraki Network Hardware ===
|
||||||
|
if (merakiNetwork) {
|
||||||
|
report += `**🌐 [${merakiNetwork.name}](${merakiNetwork.url})**\n\n`;
|
||||||
|
|
||||||
|
let activeDevices = [];
|
||||||
|
try {
|
||||||
|
const availabilities = await getMerakiDeviceAvailabilities(merakiNetwork.id);
|
||||||
|
activeDevices = availabilities.filter(d => d.status !== 'dormant');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Device availabilities failed:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: Rear Switch → Front Switch → APs
|
||||||
|
activeDevices.sort((a, b) => {
|
||||||
|
const nameA = (a.name || '').trim().toUpperCase();
|
||||||
|
const nameB = (b.name || '').trim().toUpperCase();
|
||||||
|
|
||||||
|
if (nameA.includes('SWR') || /SW.*R\d?$/.test(nameA)) return -1;
|
||||||
|
if (nameB.includes('SWR') || /SW.*R\d?$/.test(nameB)) return 1;
|
||||||
|
|
||||||
|
if (nameA.includes('SWF') || /SW.*F\d/.test(nameA)) return -1;
|
||||||
|
if (nameB.includes('SWF') || /SW.*F\d/.test(nameB)) return 1;
|
||||||
|
|
||||||
|
return nameA.localeCompare(nameB);
|
||||||
|
});
|
||||||
|
|
||||||
|
report += `**🛠️ Active Network Devices (${activeDevices.length})**\n`;
|
||||||
|
|
||||||
|
activeDevices.forEach(dev => {
|
||||||
|
const statusEmoji = dev.status === 'online' ? '✅' : '⚠️';
|
||||||
|
const deviceName = (dev.name || dev.serial || 'Unknown').trim();
|
||||||
|
|
||||||
|
report += `**${deviceName}** — ${statusEmoji} ${dev.status}`;
|
||||||
|
|
||||||
|
if (dev.serial && merakiNetwork.url) {
|
||||||
|
// Extract short network code from the URL (e.g. JCz-ucnd)
|
||||||
|
const urlParts = merakiNetwork.url.split('/n/');
|
||||||
|
const networkCode = urlParts[1] ? urlParts[1].split('/')[0] : merakiNetwork.id;
|
||||||
|
|
||||||
|
let link = `${urlParts[0]}/n/${networkCode}`;
|
||||||
|
|
||||||
|
if (dev.productType === 'switch') {
|
||||||
|
link += `/manage/switches/${dev.serial}/summary`;
|
||||||
|
} else if (dev.productType === 'wireless') {
|
||||||
|
link += `/manage/access_points/${dev.serial}/summary`;
|
||||||
|
} else {
|
||||||
|
link += `/manage/${dev.serial}/summary`;
|
||||||
|
}
|
||||||
|
|
||||||
|
report += ` → [Dashboard](${link})`;
|
||||||
|
}
|
||||||
|
report += `\n`;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
report += `\n⚠️ No matching Meraki network found.\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Store Registers ===
|
||||||
|
if (registers && registers.length > 0) {
|
||||||
|
report += `\n**🖥️ Store Registers (${registers.length})**\n`;
|
||||||
|
|
||||||
|
registers.forEach(reg => {
|
||||||
|
const regNum = reg.register_number ? `Register ${reg.register_number}` : 'Register';
|
||||||
|
const brand = reg.brand_display_name || 'Unknown';
|
||||||
|
const type = reg.register_type_name || 'Unknown';
|
||||||
|
const name = (reg.register_display_name || reg.printer_name || 'Unknown').trim();
|
||||||
|
|
||||||
|
const match = merakiClients.find(c =>
|
||||||
|
c?.description &&
|
||||||
|
(c.description.trim().toLowerCase() === name.toLowerCase() ||
|
||||||
|
c.description.includes(name))
|
||||||
|
);
|
||||||
|
|
||||||
|
const status = match
|
||||||
|
? (match.status === 'Online' ? '✅ Online' : '❌ Offline')
|
||||||
|
: '❓ Unknown';
|
||||||
|
|
||||||
|
const lastSeen = match ? formatLastSeen(match.lastSeen) : '';
|
||||||
|
const deviceName = match?.recentDeviceName ? ` via ${match.recentDeviceName}` : '';
|
||||||
|
const connection = match?.recentDeviceConnection ? ` (${match.recentDeviceConnection})` : '';
|
||||||
|
|
||||||
|
let link = '';
|
||||||
|
if (match && merakiNetwork?.url) {
|
||||||
|
const networkCode = merakiNetwork.url.split('/n/')[1]?.split('/')[0] || merakiNetwork.id;
|
||||||
|
link = ` → [Meraki Client](https://n976.dashboard.meraki.com/${merakiNetwork.name.replace(/ /g, '-')}/n/${networkCode}/manage/clients/${match.id}/overview)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
report += `**${regNum}** — ${brand} — ${type} — ${status} — ${lastSeen}${deviceName}${connection}${link}\n`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Store Printers (USB filtered) ===
|
||||||
|
const filteredPrinters = (printers || []).filter(p =>
|
||||||
|
(p.connection_type_name || '').toLowerCase() !== 'usb'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (filteredPrinters.length > 0) {
|
||||||
|
report += `\n**🖨️ Store Printers (${filteredPrinters.length})**\n`;
|
||||||
|
|
||||||
|
filteredPrinters.forEach(printer => {
|
||||||
|
const name = (printer.printer_name || 'Unknown').trim();
|
||||||
|
const model = printer.printer_model_name || 'N/A';
|
||||||
|
|
||||||
|
const match = merakiClients.find(c =>
|
||||||
|
c?.description &&
|
||||||
|
(c.description.trim().toLowerCase() === name.toLowerCase() ||
|
||||||
|
c.description.includes(name))
|
||||||
|
);
|
||||||
|
|
||||||
|
const status = match
|
||||||
|
? (match.status === 'Online' ? '✅ Online' : '❌ Offline')
|
||||||
|
: '❓ Unknown';
|
||||||
|
|
||||||
|
const lastSeen = match ? formatLastSeen(match.lastSeen) : '';
|
||||||
|
const deviceName = match?.recentDeviceName ? ` via ${match.recentDeviceName}` : '';
|
||||||
|
const connection = match?.recentDeviceConnection ? ` (${match.recentDeviceConnection})` : '';
|
||||||
|
|
||||||
|
let link = '';
|
||||||
|
if (match && merakiNetwork?.url) {
|
||||||
|
const networkCode = merakiNetwork.url.split('/n/')[1]?.split('/')[0] || merakiNetwork.id;
|
||||||
|
link = ` → [Meraki Client](https://n976.dashboard.meraki.com/${merakiNetwork.name.replace(/ /g, '-')}/n/${networkCode}/manage/clients/${match.id}/overview)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
report += `**${name}** — ${model} — ${status} — ${lastSeen}${deviceName}${connection}${link}\n`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paymentTerminals && paymentTerminals.length > 0) {
|
||||||
|
report += `\n**💳 Payment Terminals (${paymentTerminals.length})**\n`;
|
||||||
|
|
||||||
|
paymentTerminals.forEach(term => {
|
||||||
|
const deviceName = term.device_name || 'Unknown Terminal';
|
||||||
|
const adyenName = term.adyen_device_name || 'N/A';
|
||||||
|
const model = term.device_model_name || 'N/A';
|
||||||
|
const type = term.device_type_name || 'N/A';
|
||||||
|
|
||||||
|
const match = merakiClients.find(c =>
|
||||||
|
c?.description &&
|
||||||
|
(c.description.includes(deviceName) ||
|
||||||
|
c.description.includes(adyenName) ||
|
||||||
|
(term.ip_address && c.description.includes(term.ip_address.split('.')[0])))
|
||||||
|
);
|
||||||
|
|
||||||
|
const status = match
|
||||||
|
? (match.status === 'Online' ? '✅ Online' : '❌ Offline')
|
||||||
|
: '❓ Unknown';
|
||||||
|
|
||||||
|
const lastSeen = match ? formatLastSeen(match.lastSeen) : '';
|
||||||
|
const recentDevice = match?.recentDeviceName ? ` via ${match.recentDeviceName}` : '';
|
||||||
|
const connection = match?.recentDeviceConnection ? ` (${match.recentDeviceConnection})` : '';
|
||||||
|
|
||||||
|
let link = '';
|
||||||
|
if (match && merakiNetwork?.url) {
|
||||||
|
const networkCode = merakiNetwork.url.split('/n/')[1]?.split('/')[0] || merakiNetwork.id;
|
||||||
|
link = ` → [Meraki Client](https://n976.dashboard.meraki.com/${merakiNetwork.name.replace(/ /g, '-')}/n/${networkCode}/manage/clients/${match.id}/overview)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
report += `**${deviceName}** — ${adyenName} — ${model} — ${type} — ${status} — ${lastSeen}${recentDevice}${connection}${link}\n`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mdmDevices && mdmDevices.length > 0) {
|
||||||
|
const servers = mdmDevices.filter(d => (d.UserName || d.DeviceFriendlyName || '').includes('SRV'));
|
||||||
|
const mobileRegs = mdmDevices.filter(d => (d.UserName || d.DeviceFriendlyName || '').includes('MR'));
|
||||||
|
const custDisplays = mdmDevices.filter(d => (d.UserName || d.DeviceFriendlyName || '').includes('CD'));
|
||||||
|
const iphones = mdmDevices.filter(d => (d.UserName || d.DeviceFriendlyName || '').includes('IPH'));
|
||||||
|
|
||||||
|
// === Store Servers ===
|
||||||
|
if (servers.length > 0) {
|
||||||
|
report += `\n**🖥️ Store Servers (${servers.length})**\n`;
|
||||||
|
|
||||||
|
servers.forEach(dev => {
|
||||||
|
const name = dev.UserName || dev.DeviceFriendlyName || 'Unknown Server';
|
||||||
|
const match = merakiClients.find(c =>
|
||||||
|
c?.description &&
|
||||||
|
(c.description.includes(name) || (dev.UserName && c.description.includes(dev.UserName)))
|
||||||
|
);
|
||||||
|
|
||||||
|
const status = match ? (match.status === 'Online' ? '✅ Online' : '❌ Offline') : '❓ Unknown';
|
||||||
|
const lastSeen = match ? formatLastSeen(match.lastSeen) : '';
|
||||||
|
const recentDevice = match?.recentDeviceName ? ` via ${match.recentDeviceName}` : '';
|
||||||
|
const connection = match?.recentDeviceConnection ? ` (${match.recentDeviceConnection})` : '';
|
||||||
|
|
||||||
|
let link = '';
|
||||||
|
if (match && merakiNetwork?.url) {
|
||||||
|
const networkCode = merakiNetwork.url.split('/n/')[1]?.split('/')[0] || merakiNetwork.id;
|
||||||
|
link = ` → [Meraki Client](https://n976.dashboard.meraki.com/${merakiNetwork.name.replace(/ /g, '-')}/n/${networkCode}/manage/clients/${match.id}/overview)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
report += `**${name}** — ${status} — ${lastSeen}${recentDevice}${connection}${link}\n`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Mobile Registers ===
|
||||||
|
if (mobileRegs.length > 0) {
|
||||||
|
report += `\n**📱 Mobile Registers (${mobileRegs.length})**\n`;
|
||||||
|
|
||||||
|
mobileRegs.forEach(dev => {
|
||||||
|
const name = dev.UserName || dev.DeviceFriendlyName || 'Unknown Mobile Register';
|
||||||
|
const match = merakiClients.find(c =>
|
||||||
|
c?.description &&
|
||||||
|
(c.description.includes(name) || (dev.UserName && c.description.includes(dev.UserName)))
|
||||||
|
);
|
||||||
|
|
||||||
|
const status = match ? (match.status === 'Online' ? '✅ Online' : '❌ Offline') : '❓ Unknown';
|
||||||
|
const lastSeen = match ? formatLastSeen(match.lastSeen) : '';
|
||||||
|
const recentDevice = match?.recentDeviceName ? ` via ${match.recentDeviceName}` : '';
|
||||||
|
const connection = match?.recentDeviceConnection ? ` (${match.recentDeviceConnection})` : '';
|
||||||
|
|
||||||
|
let link = '';
|
||||||
|
if (match && merakiNetwork?.url) {
|
||||||
|
const networkCode = merakiNetwork.url.split('/n/')[1]?.split('/')[0] || merakiNetwork.id;
|
||||||
|
link = ` → [Meraki Client](https://n976.dashboard.meraki.com/${merakiNetwork.name.replace(/ /g, '-')}/n/${networkCode}/manage/clients/${match.id}/overview)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
report += `**${name}** — ${status} — ${lastSeen}${recentDevice}${connection}${link}\n`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Customer Displays ===
|
||||||
|
if (custDisplays.length > 0) {
|
||||||
|
report += `\n**🖥️ Customer Displays (${custDisplays.length})**\n`;
|
||||||
|
|
||||||
|
custDisplays.forEach(dev => {
|
||||||
|
const name = dev.UserName || dev.DeviceFriendlyName || 'Unknown Display';
|
||||||
|
const match = merakiClients.find(c =>
|
||||||
|
c?.description &&
|
||||||
|
(c.description.includes(name) || (dev.UserName && c.description.includes(dev.UserName)))
|
||||||
|
);
|
||||||
|
|
||||||
|
const status = match ? (match.status === 'Online' ? '✅ Online' : '❌ Offline') : '❓ Unknown';
|
||||||
|
const lastSeen = match ? formatLastSeen(match.lastSeen) : '';
|
||||||
|
const recentDevice = match?.recentDeviceName ? ` via ${match.recentDeviceName}` : '';
|
||||||
|
const connection = match?.recentDeviceConnection ? ` (${match.recentDeviceConnection})` : '';
|
||||||
|
|
||||||
|
let link = '';
|
||||||
|
if (match && merakiNetwork?.url) {
|
||||||
|
const networkCode = merakiNetwork.url.split('/n/')[1]?.split('/')[0] || merakiNetwork.id;
|
||||||
|
link = ` → [Meraki Client](https://n976.dashboard.meraki.com/${merakiNetwork.name.replace(/ /g, '-')}/n/${networkCode}/manage/clients/${match.id}/overview)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
report += `**${name}** — ${status} — ${lastSeen}${recentDevice}${connection}${link}\n`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Store iPhones ===
|
||||||
|
if (iphones.length > 0) {
|
||||||
|
report += `\n**📱 Store iPhones (${iphones.length})**\n`;
|
||||||
|
|
||||||
|
iphones.forEach(dev => {
|
||||||
|
const name = dev.UserName || dev.DeviceFriendlyName || 'Unknown iPhone';
|
||||||
|
const match = merakiClients.find(c =>
|
||||||
|
c?.description &&
|
||||||
|
(c.description.includes(name) || (dev.UserName && c.description.includes(dev.UserName)))
|
||||||
|
);
|
||||||
|
|
||||||
|
const status = match ? (match.status === 'Online' ? '✅ Online' : '❌ Offline') : '❓ Unknown';
|
||||||
|
const lastSeen = match ? formatLastSeen(match.lastSeen) : '';
|
||||||
|
const recentDevice = match?.recentDeviceName ? ` via ${match.recentDeviceName}` : '';
|
||||||
|
const connection = match?.recentDeviceConnection ? ` (${match.recentDeviceConnection})` : '';
|
||||||
|
|
||||||
|
let link = '';
|
||||||
|
if (match && merakiNetwork?.url) {
|
||||||
|
const networkCode = merakiNetwork.url.split('/n/')[1]?.split('/')[0] || merakiNetwork.id;
|
||||||
|
link = ` → [Meraki Client](https://n976.dashboard.meraki.com/${merakiNetwork.name.replace(/ /g, '-')}/n/${networkCode}/manage/clients/${match.id}/overview)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
report += `**${name}** — ${status} — ${lastSeen}${recentDevice}${connection}${link}\n`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getStoreDetail };
|
||||||
219
integrations/storeHealth.js
Normal file
219
integrations/storeHealth.js
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
const { getStoreLocation } = require('../services/siw');
|
||||||
|
const { findMerakiNetwork, getMerakiDeviceAvailabilities, getMerakiClients } = require('../services/meraki');
|
||||||
|
const { getMDMDevices } = require('../services/mdm');
|
||||||
|
|
||||||
|
async function getStoreHealth(storeNumber) {
|
||||||
|
console.log(`🔍 Running health analysis for store ${storeNumber}`);
|
||||||
|
|
||||||
|
let locationData, merakiNetwork, merakiClients = [], mdmDevices = [], paymentTerminals = [], printers = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
[locationData, merakiNetwork] = await Promise.all([
|
||||||
|
getStoreLocation(storeNumber),
|
||||||
|
findMerakiNetwork(storeNumber)
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (merakiNetwork) {
|
||||||
|
[merakiClients, mdmDevices, paymentTerminals, printers] = await Promise.all([
|
||||||
|
getMerakiClients(merakiNetwork.id),
|
||||||
|
getMDMDevices(storeNumber),
|
||||||
|
require('../services/siw').getStorePaymentTerminals(storeNumber),
|
||||||
|
require('../services/siw').getStorePrinters(storeNumber)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Health analysis error:', err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
let summary = `**📊 Store ${storeNumber} Health Summary - ${locationData?.name || 'Unknown Store'}**\n\n`;
|
||||||
|
let overallScore = 100;
|
||||||
|
const issues = [];
|
||||||
|
|
||||||
|
// === Network Infrastructure ===
|
||||||
|
summary += `**🌐 Network Infrastructure**\n`;
|
||||||
|
if (merakiNetwork) {
|
||||||
|
summary += `- Meraki Network: ✅ [${merakiNetwork.name}](${merakiNetwork.url})\n`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const avail = await getMerakiDeviceAvailabilities(merakiNetwork.id);
|
||||||
|
const switches = avail.filter(d => d.productType === 'switch' && d.status !== 'dormant');
|
||||||
|
const aps = avail.filter(d => d.productType === 'wireless' && d.status !== 'dormant');
|
||||||
|
|
||||||
|
const swOnline = switches.filter(d => d.status === 'online').length;
|
||||||
|
const apOnline = aps.filter(d => d.status === 'online').length;
|
||||||
|
const apAlerting = aps.filter(d => d.status === 'alerting').length;
|
||||||
|
|
||||||
|
summary += `- Switching: ${swOnline}/${switches.length} online\n`;
|
||||||
|
summary += `- Access Points: ${apOnline}/${aps.length} online`;
|
||||||
|
|
||||||
|
if (apAlerting > 0) {
|
||||||
|
summary += ` (${apAlerting} alerting)`;
|
||||||
|
}
|
||||||
|
summary += `\n\n`;
|
||||||
|
|
||||||
|
// Scoring & Issues
|
||||||
|
if (swOnline < switches.length) {
|
||||||
|
const offlineSw = switches.length - swOnline;
|
||||||
|
overallScore -= offlineSw * 15;
|
||||||
|
issues.push(`${offlineSw} switch(es) offline`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apOnline < aps.length || apAlerting > 0) {
|
||||||
|
const totalApIssues = aps.length - apOnline;
|
||||||
|
|
||||||
|
|
||||||
|
if (apAlerting > 0) {
|
||||||
|
overallScore -= totalApIssues * 3;
|
||||||
|
issues.push(`${apAlerting} access point(s) alerting (check cabling/power)`);
|
||||||
|
} else {
|
||||||
|
overallScore -= totalApIssues * 5;
|
||||||
|
issues.push(`${totalApIssues} access point(s) offline`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Meraki availability error:', e.message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
summary += `- Meraki Network: ❌ Not Found\n\n`;
|
||||||
|
overallScore -= 50;
|
||||||
|
issues.push("No Meraki network found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Core Systems ===
|
||||||
|
summary += `**🖥️ POS Systems**\n`;
|
||||||
|
|
||||||
|
if (mdmDevices && mdmDevices.length > 0) {
|
||||||
|
const servers = mdmDevices.filter(d => (d.UserName || d.DeviceFriendlyName || '').includes('SRV'));
|
||||||
|
const mobileRegs = mdmDevices.filter(d => (d.UserName || d.DeviceFriendlyName || '').includes('MR'));
|
||||||
|
const custDisplays = mdmDevices.filter(d => (d.UserName || d.DeviceFriendlyName || '').includes('CD'));
|
||||||
|
const iphones = mdmDevices.filter(d => (d.UserName || d.DeviceFriendlyName || '').includes('IPH'));
|
||||||
|
|
||||||
|
// Server (heavy penalty)
|
||||||
|
let serverStatus = '❓ Unknown';
|
||||||
|
if (servers.length > 0) {
|
||||||
|
const match = merakiClients.find(c =>
|
||||||
|
c?.description && servers.some(s =>
|
||||||
|
(s.UserName || s.DeviceFriendlyName || '').toLowerCase().includes((c.description || '').toLowerCase())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
serverStatus = match && match.status === 'Online' ? '✅ Online' : '❌ Offline';
|
||||||
|
if (serverStatus === '❌ Offline') {
|
||||||
|
overallScore -= 35;
|
||||||
|
issues.push(`Store Server ${servers[0]?.UserName} is offline`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
summary += `- Store Server: ${serverStatus}\n`;
|
||||||
|
|
||||||
|
// Mobile Registers
|
||||||
|
let mobileOnline = 0;
|
||||||
|
mobileRegs.forEach(reg => {
|
||||||
|
const name = (reg.UserName || reg.DeviceFriendlyName || '').trim();
|
||||||
|
const match = merakiClients.find(c =>
|
||||||
|
c?.description && c.description.toLowerCase().includes(name.toLowerCase()) ||
|
||||||
|
(c.user && c.user.toLowerCase().includes(name.toLowerCase()))
|
||||||
|
);
|
||||||
|
const status = match && match.status === 'Online' ? '✅ Online' : '❌ Offline';
|
||||||
|
if (status === '✅ Online') mobileOnline++;
|
||||||
|
if (status === '❌ Offline') issues.push(`Mobile Register ${name} is offline`);
|
||||||
|
});
|
||||||
|
summary += `- Mobile Registers: ${mobileOnline}/${mobileRegs.length} online\n`;
|
||||||
|
|
||||||
|
// Customer Displays & iPhones (lighter penalty)
|
||||||
|
let cdOnline = 0;
|
||||||
|
custDisplays.forEach(cd => {
|
||||||
|
const name = (cd.UserName || cd.DeviceFriendlyName || '').trim();
|
||||||
|
const match = merakiClients.find(c =>
|
||||||
|
c?.description && c.description.toLowerCase().includes(name.toLowerCase()) ||
|
||||||
|
(c.user && c.user.toLowerCase().includes(name.toLowerCase()))
|
||||||
|
);
|
||||||
|
if (match && match.status === 'Online') cdOnline++;
|
||||||
|
if (match && match.status !== 'Online') issues.push(`Customer Display ${name} is offline`);
|
||||||
|
});
|
||||||
|
summary += `- Customer Displays: ${cdOnline}/${custDisplays.length} online\n`;
|
||||||
|
|
||||||
|
let iphoneOnline = 0;
|
||||||
|
iphones.forEach(phone => {
|
||||||
|
const name = (phone.UserName || phone.DeviceFriendlyName || '').trim();
|
||||||
|
const match = merakiClients.find(c =>
|
||||||
|
c?.description && c.description.toLowerCase().includes(name.toLowerCase()) ||
|
||||||
|
(c.user && c.user.toLowerCase().includes(name.toLowerCase()))
|
||||||
|
);
|
||||||
|
if (match && match.status === 'Online') iphoneOnline++;
|
||||||
|
if (match && match.status !== 'Online') issues.push(`iPhone ${name} is offline`);
|
||||||
|
});
|
||||||
|
summary += `- Store iPhones: ${iphoneOnline}/${iphones.length} online\n\n`;
|
||||||
|
} else {
|
||||||
|
summary += `- No MDM data available\n\n`;
|
||||||
|
overallScore -= 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === POS & Peripherals (with per-device penalty) ===
|
||||||
|
summary += `**💳 POS Peripherals**\n`;
|
||||||
|
|
||||||
|
// Payment Terminals
|
||||||
|
if (paymentTerminals && paymentTerminals.length > 0) {
|
||||||
|
let onlinePayments = 0;
|
||||||
|
paymentTerminals.forEach(term => {
|
||||||
|
const name = (term.device_name || term.adyen_device_name || '').trim().toLowerCase();
|
||||||
|
const cleanIp = (term.ip_address || '').split('.')[0].toLowerCase();
|
||||||
|
|
||||||
|
const match = merakiClients.find(c =>
|
||||||
|
c?.description && (
|
||||||
|
c.description.toLowerCase().includes(name) ||
|
||||||
|
c.description.toLowerCase().includes(cleanIp)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const status = match && match.status === 'Online' ? '✅ Online' : '❌ Offline';
|
||||||
|
if (status === '✅ Online') onlinePayments++;
|
||||||
|
if (status === '❌ Offline') {
|
||||||
|
overallScore -= 5; // penalty per offline terminal
|
||||||
|
issues.push(`Payment Terminal ${name} is offline`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
summary += `- Payment Terminals: ${onlinePayments}/${paymentTerminals.length} online\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Printers
|
||||||
|
const filteredPrinters = (printers || []).filter(p =>
|
||||||
|
(p.connection_type_name || '').toLowerCase() !== 'usb'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (filteredPrinters.length > 0) {
|
||||||
|
let onlinePrinters = 0;
|
||||||
|
filteredPrinters.forEach(printer => {
|
||||||
|
const name = (printer.printer_name || '').trim().toLowerCase();
|
||||||
|
const match = merakiClients.find(c =>
|
||||||
|
c?.description && c.description.toLowerCase().includes(name)
|
||||||
|
);
|
||||||
|
const status = match && match.status === 'Online' ? '✅ Online' : '❌ Offline';
|
||||||
|
if (status === '✅ Online') onlinePrinters++;
|
||||||
|
if (status === '❌ Offline') {
|
||||||
|
overallScore -= 4; // penalty per offline printer
|
||||||
|
issues.push(`Printer ${name} is offline`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
summary += `- Printers: ${onlinePrinters}/${filteredPrinters.length} online\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Overall Status ===
|
||||||
|
overallScore = Math.max(0, Math.round(overallScore));
|
||||||
|
|
||||||
|
let statusEmoji = overallScore >= 90 ? '🟢' : overallScore >= 70 ? '🟡' : '🔴';
|
||||||
|
let statusText = overallScore >= 90 ? 'Good' : overallScore >= 70 ? 'Fair' : 'Needs Attention';
|
||||||
|
|
||||||
|
summary += `\n**Overall Status**: ${statusEmoji} ${statusText} (${overallScore}% healthy)\n\n`;
|
||||||
|
|
||||||
|
if (issues.length > 0) {
|
||||||
|
summary += `**⚠️ Issues Detected**\n`;
|
||||||
|
issues.forEach(issue => summary += `- ${issue}\n`);
|
||||||
|
} else {
|
||||||
|
summary += `**✅ No major issues detected**\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { summary };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getStoreHealth };
|
||||||
1
models/.gitkeep
Normal file
1
models/.gitkeep
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
# Reserved for domain models / DTOs
|
||||||
12084
package-lock.json
generated
Normal file
12084
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
34
package.json
Normal file
34
package.json
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
{
|
||||||
|
"name": "netanalyzer",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"main": "server.js",
|
||||||
|
"type": "commonjs",
|
||||||
|
"description": "NetAnalyzer - Webex bot for store network and device health analysis using Meraki, SIW, and MDM integrations",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "nodemon server.js"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"webex",
|
||||||
|
"bot",
|
||||||
|
"meraki",
|
||||||
|
"network-analysis",
|
||||||
|
"store-health"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.16.1",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"webex-node-bot-framework": "^2.5.1",
|
||||||
|
"ws": "^8.20.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.1.14"
|
||||||
|
}
|
||||||
|
}
|
||||||
91
remoteAgent.js
Normal file
91
remoteAgent.js
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
const WebSocket = require('ws');
|
||||||
|
const axios = require('axios');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const WS_URL = process.env.WS_URL;
|
||||||
|
|
||||||
|
if (!WS_URL) {
|
||||||
|
console.error('❌ WS_URL is not set in .env');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let ws = null;
|
||||||
|
let reconnectAttempts = 0;
|
||||||
|
const INITIAL_BACKOFF = 2000; // 2 seconds
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
console.log(`🔄 Connecting to ${WS_URL}...`);
|
||||||
|
|
||||||
|
ws = new WebSocket(WS_URL);
|
||||||
|
|
||||||
|
ws.on('open', () => {
|
||||||
|
console.log('✅ Remote Agent connected to NetAnalyzer');
|
||||||
|
reconnectAttempts = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('message', async (data) => {
|
||||||
|
try {
|
||||||
|
const request = JSON.parse(data);
|
||||||
|
if (request.action !== 'proxyRequest') return;
|
||||||
|
|
||||||
|
console.log(`🔄 Proxying ${request.method || 'GET'} ${request.url}`);
|
||||||
|
|
||||||
|
const response = await axios({
|
||||||
|
method: request.method || 'GET',
|
||||||
|
url: request.url,
|
||||||
|
headers: request.headers || {},
|
||||||
|
auth: request.auth || undefined,
|
||||||
|
data: request.body || undefined,
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
requestId: request.requestId,
|
||||||
|
status: response.status,
|
||||||
|
data: response.data,
|
||||||
|
headers: response.headers
|
||||||
|
}));
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Proxy error:', err.message);
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
requestId: request.requestId,
|
||||||
|
error: err.message,
|
||||||
|
status: err.response?.status || 500,
|
||||||
|
data: err.response?.data || null
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', (code, reason) => {
|
||||||
|
console.log(`❌ Disconnected (code: ${code}). Reconnecting...`);
|
||||||
|
scheduleReconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('error', (err) => {
|
||||||
|
console.error('WebSocket error:', err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReconnect() {
|
||||||
|
reconnectAttempts++;
|
||||||
|
|
||||||
|
// Exponential backoff, capped at 60 seconds
|
||||||
|
const backoff = Math.min(INITIAL_BACKOFF * Math.pow(1.5, reconnectAttempts - 1), 60000);
|
||||||
|
|
||||||
|
console.log(`⏳ Reconnecting in ${Math.round(backoff/1000)}s... (attempt ${reconnectAttempts})`);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
connect();
|
||||||
|
}, backoff);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start initial connection
|
||||||
|
connect();
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
console.log('🛑 Shutting down remote agent...');
|
||||||
|
if (ws) ws.close();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
51
server.js
Normal file
51
server.js
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
const Framework = require('webex-node-bot-framework');
|
||||||
|
const config = require('./config');
|
||||||
|
const { startWebSocketServer } = require('./services/websocket');
|
||||||
|
const { handleStoreCommand, handleAnalyzeCommand } = require('./bot/handlers');
|
||||||
|
|
||||||
|
const framework = new Framework({
|
||||||
|
token: config.webex.token,
|
||||||
|
removeWebhooksOnStart: true,
|
||||||
|
logLevel: 'debug'
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('🚀 Starting NetAnalyzer Framework...');
|
||||||
|
|
||||||
|
// Register command handlers
|
||||||
|
framework.hears(/store/i, handleStoreCommand, '**store <number>** - Analyze a store');
|
||||||
|
framework.hears(/analyze/i, handleAnalyzeCommand, '**analyze <number>** - High-level store health check');
|
||||||
|
|
||||||
|
// Catch-all handler for debugging (lowest priority)
|
||||||
|
framework.hears(/.*/, (bot, trigger) => {
|
||||||
|
// Only log and respond if this pattern matched
|
||||||
|
if (trigger.match && trigger.match[0].length > 0) {
|
||||||
|
const text = trigger.message.text.toLowerCase();
|
||||||
|
|
||||||
|
// Skip if message contains known commands already handled
|
||||||
|
if (!text.includes('store') && !text.includes('analyze')) {
|
||||||
|
console.log('📨 Received message:', trigger.message.text);
|
||||||
|
bot.say('I heard: ' + trigger.message.text + '\nTry: `store <number>` or `analyze <number>` for store analysis');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 99999); // lowest priority
|
||||||
|
|
||||||
|
// Framework events
|
||||||
|
framework.on('initialized', () => {
|
||||||
|
console.log('✅ Framework initialized and connected via WebSocket!');
|
||||||
|
});
|
||||||
|
|
||||||
|
framework.on('spawn', (bot, id, addedBy) => {
|
||||||
|
console.log(`🟢 Bot spawned in room: ${bot.room.title || 'Unknown'}`);
|
||||||
|
if (addedBy) {
|
||||||
|
bot.say('NetAnalyzer is ready! Try `store <number>` or `analyze <number>`');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start framework and WebSocket server sequentially
|
||||||
|
framework.start().then(() => {
|
||||||
|
console.log('WebSocket server for remote agent starting...');
|
||||||
|
startWebSocketServer();
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('❌ Error starting framework:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
53
services/mdm.js
Normal file
53
services/mdm.js
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
const axios = require('axios');
|
||||||
|
const config = require('../config');
|
||||||
|
|
||||||
|
async function getMDMToken() {
|
||||||
|
console.log('🔑 Requesting Workspace ONE token...');
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
config.mdm.tokenUrl,
|
||||||
|
new URLSearchParams({
|
||||||
|
grant_type: 'client_credentials',
|
||||||
|
client_id: config.mdm.clientId,
|
||||||
|
client_secret: config.mdm.clientSecret,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('✅ MDM Token acquired');
|
||||||
|
return response.data.access_token;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ MDM Token failed:', err.message);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMDMDevices(storeNumber) {
|
||||||
|
const storePadded = String(storeNumber).padStart(6, '0');
|
||||||
|
console.log(`🔍 Fetching MDM devices for store ${storePadded}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = await getMDMToken();
|
||||||
|
|
||||||
|
const response = await axios.get(`${config.mdm.baseUrl}/api/mdm/devices/search`, {
|
||||||
|
params: { user: storePadded },
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'aw-tenant-code': config.mdm.tenantCode,
|
||||||
|
Accept: 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const devices = response.data.Devices || [];
|
||||||
|
console.log(`✅ Fetched ${devices.length} MDM devices [getMDMDevices]`);
|
||||||
|
return devices;
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`❌ MDM devices fetch failed:`, err.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getMDMDevices };
|
||||||
146
services/meraki.js
Normal file
146
services/meraki.js
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
const axios = require('axios');
|
||||||
|
const config = require('../config');
|
||||||
|
|
||||||
|
const merakiAxios = axios.create({
|
||||||
|
baseURL: config.meraki.baseUrl,
|
||||||
|
headers: {
|
||||||
|
'X-Cisco-Meraki-API-Key': config.meraki.apiKey,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// In-memory cache
|
||||||
|
let cachedNetworks = [];
|
||||||
|
let lastCacheTime = 0;
|
||||||
|
const CACHE_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours
|
||||||
|
|
||||||
|
async function refreshMerakiNetworksCache() {
|
||||||
|
const start = Date.now();
|
||||||
|
console.log('🔄 Refreshing Meraki networks cache...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `/organizations/${config.meraki.orgId}/networks?perPage=5000`;
|
||||||
|
const res = await merakiAxios.get(url);
|
||||||
|
cachedNetworks = res.data || [];
|
||||||
|
lastCacheTime = Date.now();
|
||||||
|
|
||||||
|
console.log(`✅ Cached ${cachedNetworks.length} Meraki networks (${Date.now() - start}ms)`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Failed to refresh Meraki networks cache:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMerakiNetworks(forceRefresh = false) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (forceRefresh || cachedNetworks.length === 0 || (now - lastCacheTime > CACHE_TTL_MS)) {
|
||||||
|
await refreshMerakiNetworksCache();
|
||||||
|
}
|
||||||
|
return cachedNetworks;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findMerakiNetwork(storeNum) {
|
||||||
|
if (!storeNum) return null;
|
||||||
|
|
||||||
|
const raw = String(storeNum).trim();
|
||||||
|
const match = raw.match(/\d+/);
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
let searchTerm = match[0].padStart(5, '0').slice(-5);
|
||||||
|
|
||||||
|
const networks = await getMerakiNetworks();
|
||||||
|
|
||||||
|
console.log(`🔍 Searching for store "${raw}" → 5-digit term "${searchTerm}"`);
|
||||||
|
|
||||||
|
let bestMatch = null;
|
||||||
|
let bestScore = -1;
|
||||||
|
|
||||||
|
for (const net of networks) {
|
||||||
|
const name = (net.name || '').toLowerCase();
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
|
||||||
|
if (name.includes(term)) {
|
||||||
|
const score = (name.includes(` ${term}`) || name.includes(`-${term}`) || name.includes(term + ' ')) ? 100 : 50;
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
bestMatch = net;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestMatch) {
|
||||||
|
console.log(`✅ Best Meraki match: ${bestMatch.name} (ID: ${bestMatch.id})`);
|
||||||
|
return bestMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`❌ No Meraki network found for store ${storeNum}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getNetworkClients(networkId) {
|
||||||
|
if (!networkId) return [];
|
||||||
|
try {
|
||||||
|
const res = await merakiAxios.get(`/networks/${networkId}/clients?timespan=7200`); // last 2 hours
|
||||||
|
return res.data || [];
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`❌ Failed to get clients for network ${networkId}:`, err.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAllMerakiDevices(networkId) {
|
||||||
|
if (!networkId) return [];
|
||||||
|
try {
|
||||||
|
const res = await merakiAxios.get(`/networks/${networkId}/devices`);
|
||||||
|
return res.data || [];
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`❌ Failed to get devices for network ${networkId}:`, err.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMerakiDeviceAvailabilities(networkId) {
|
||||||
|
if (!networkId || !config.meraki.orgId) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await merakiAxios.get(
|
||||||
|
`/organizations/${config.meraki.orgId}/devices/availabilities`,
|
||||||
|
{
|
||||||
|
params: { networkIds: [networkId] }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return res.data || [];
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`❌ Device availabilities failed:`, err.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMerakiClients(networkId) {
|
||||||
|
if (!networkId) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 7 days = much better chance of catching registers & printers
|
||||||
|
const timespanSeconds = 7 * 24 * 60 * 60; // 604800
|
||||||
|
|
||||||
|
const res = await merakiAxios.get(
|
||||||
|
`/networks/${networkId}/clients?perPage=1000×pan=${timespanSeconds}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const clients = res.data || [];
|
||||||
|
console.log(`✅ Fetched ${clients.length} Meraki clients (7-day timespan) [getMerakiClients]`);
|
||||||
|
|
||||||
|
return clients;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`❌ Failed to fetch Meraki clients:`, err.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
findMerakiNetwork,
|
||||||
|
getNetworkClients,
|
||||||
|
getMerakiClients,
|
||||||
|
getAllMerakiDevices,
|
||||||
|
getMerakiDeviceAvailabilities,
|
||||||
|
refreshMerakiNetworksCache
|
||||||
|
};
|
||||||
88
services/siw.js
Normal file
88
services/siw.js
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
const config = require('../config');
|
||||||
|
const { proxyRequest } = require('./websocket');
|
||||||
|
|
||||||
|
async function getStoreLocation(storeNumber) {
|
||||||
|
const authString = Buffer.from(
|
||||||
|
`${config.siw.username}:${config.siw.password}`
|
||||||
|
).toString('base64');
|
||||||
|
|
||||||
|
const requestConfig = {
|
||||||
|
method: 'GET',
|
||||||
|
url: `${config.siw.baseUrl}/StoreLocation/${storeNumber}`,
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Basic ${authString}`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await proxyRequest(requestConfig);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStoreNetwork(storeNumber) {
|
||||||
|
const authString = Buffer.from(
|
||||||
|
`${config.siw.username}:${config.siw.password}`
|
||||||
|
).toString('base64');
|
||||||
|
|
||||||
|
const requestConfig = {
|
||||||
|
method: 'GET',
|
||||||
|
url: `${config.siw.baseUrl}/StoreNetwork/${storeNumber}`,
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Basic ${authString}`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await proxyRequest(requestConfig);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStoreRegisters(storeNumber) {
|
||||||
|
const authString = Buffer.from(
|
||||||
|
`${config.siw.username}:${config.siw.password}`
|
||||||
|
).toString('base64');
|
||||||
|
|
||||||
|
const requestConfig = {
|
||||||
|
method: 'GET',
|
||||||
|
url: `${config.siw.baseUrl}/StoreRegister/${storeNumber}`,
|
||||||
|
headers: { 'Authorization': `Basic ${authString}` }
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await proxyRequest(requestConfig);
|
||||||
|
return result.data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStorePrinters(storeNumber) {
|
||||||
|
const authString = Buffer.from(
|
||||||
|
`${config.siw.username}:${config.siw.password}`
|
||||||
|
).toString('base64');
|
||||||
|
|
||||||
|
const requestConfig = {
|
||||||
|
method: 'GET',
|
||||||
|
url: `${config.siw.baseUrl}/StorePrinter/${storeNumber}`,
|
||||||
|
headers: { 'Authorization': `Basic ${authString}` }
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await proxyRequest(requestConfig);
|
||||||
|
return result.data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStorePaymentTerminals(storeNumber) {
|
||||||
|
const authString = Buffer.from(
|
||||||
|
`${config.siw.username}:${config.siw.password}`
|
||||||
|
).toString('base64');
|
||||||
|
|
||||||
|
const requestConfig = {
|
||||||
|
method: 'GET',
|
||||||
|
url: `${config.siw.baseUrl}/StorePayment/${storeNumber}`,
|
||||||
|
headers: { 'Authorization': `Basic ${authString}` }
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await proxyRequest(requestConfig);
|
||||||
|
return result.data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getStoreLocation,
|
||||||
|
getStoreRegisters,
|
||||||
|
getStorePrinters,
|
||||||
|
getStorePaymentTerminals // ← new
|
||||||
|
};
|
||||||
103
services/websocket.js
Normal file
103
services/websocket.js
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
const WebSocket = require('ws');
|
||||||
|
const config = require('../config');
|
||||||
|
|
||||||
|
let wss;
|
||||||
|
let connectedAgent = null;
|
||||||
|
const pendingRequests = new Map(); // requestId → { resolve, reject, timeout }
|
||||||
|
|
||||||
|
function startWebSocketServer() {
|
||||||
|
wss = new WebSocket.Server({ port: config.ws.port });
|
||||||
|
|
||||||
|
wss.on('connection', (ws, req) => {
|
||||||
|
// Better URL parsing and logging
|
||||||
|
let token = null;
|
||||||
|
try {
|
||||||
|
const url = new URL(req.url, `http://${req.headers.host}/netanalyze`);
|
||||||
|
token = url.searchParams.get('token');
|
||||||
|
console.log(`🔑 Incoming connection from ${req.socket.remoteAddress} | Path: ${req.url} | Token present: ${!!token}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.log('❌ Failed to parse connection URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token || token !== config.ws.token) {
|
||||||
|
console.log(`❌ Token mismatch! Received: "${token}" | Expected: "${config.ws.token ? config.ws.token.substring(0, 8) + '...' : 'MISSING'}"`);
|
||||||
|
ws.close(1008, 'Invalid or missing token');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Authorized Remote Agent connected');
|
||||||
|
connectedAgent = ws;
|
||||||
|
|
||||||
|
ws.on('message', (data) => {
|
||||||
|
try {
|
||||||
|
const response = JSON.parse(data);
|
||||||
|
const requestId = response.requestId;
|
||||||
|
|
||||||
|
if (requestId && pendingRequests.has(requestId)) {
|
||||||
|
const { resolve, reject, timeout } = pendingRequests.get(requestId);
|
||||||
|
clearTimeout(timeout);
|
||||||
|
pendingRequests.delete(requestId);
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
const err = new Error(response.error);
|
||||||
|
err.status = response.status;
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve(response);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('⚠️ Received response with unknown requestId:', requestId);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse agent message:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', (code, reason) => {
|
||||||
|
console.log(`❌ Remote Agent disconnected | Code: ${code} | Reason: ${reason || 'none'}`);
|
||||||
|
connectedAgent = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add ping/pong to keep connection alive
|
||||||
|
ws.isAlive = true;
|
||||||
|
ws.on('pong', () => { ws.isAlive = true; });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep connections alive
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
wss.clients.forEach((ws) => {
|
||||||
|
if (ws.isAlive === false) return ws.terminate();
|
||||||
|
ws.isAlive = false;
|
||||||
|
ws.ping();
|
||||||
|
});
|
||||||
|
}, 30000); // every 30 seconds
|
||||||
|
|
||||||
|
console.log(`WebSocket server running on ws://0.0.0.0:${config.ws.port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function proxyRequest(requestConfig) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!connectedAgent || connectedAgent.readyState !== WebSocket.OPEN) {
|
||||||
|
return reject(new Error('No remote agent connected'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
pendingRequests.delete(requestId);
|
||||||
|
reject(new Error(`Proxy request timeout after 45s`));
|
||||||
|
}, 45000); // Increased timeout
|
||||||
|
|
||||||
|
pendingRequests.set(requestId, { resolve, reject, timeout });
|
||||||
|
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
action: 'proxyRequest',
|
||||||
|
requestId,
|
||||||
|
...requestConfig
|
||||||
|
});
|
||||||
|
|
||||||
|
connectedAgent.send(payload);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { startWebSocketServer, proxyRequest };
|
||||||
30
utils/dataMerger.js
Normal file
30
utils/dataMerger.js
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
function mergeDeviceData(siwDevices, merakiClients) {
|
||||||
|
return siwDevices.map(siw => {
|
||||||
|
// Primary match: name
|
||||||
|
let match = merakiClients.find(m =>
|
||||||
|
m.description && siw.name &&
|
||||||
|
m.description.toLowerCase().includes(siw.name.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fallback: MAC address
|
||||||
|
if (!match && siw.mac) {
|
||||||
|
match = merakiClients.find(m =>
|
||||||
|
m.mac && m.mac.toLowerCase() === siw.mac.toLowerCase()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: siw.name || siw.deviceName || 'Unknown',
|
||||||
|
ip: siw.ip || siw.ipAddress || match?.ip || 'N/A',
|
||||||
|
status: match?.status === 'Online' ? '✅ Connected' : '❌ Disconnected',
|
||||||
|
lastSeen: match?.lastSeen
|
||||||
|
? new Date(match.lastSeen * 1000).toLocaleString()
|
||||||
|
: 'N/A',
|
||||||
|
deeplink: match?.mac
|
||||||
|
? `https://dashboard.meraki.com/o/${match.orgId || 'your-org'}/n/${match.networkId || 'your-net'}/clients?mac=${match.mac}`
|
||||||
|
: null
|
||||||
|
};
|
||||||
|
}).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { mergeDeviceData };
|
||||||
15
utils/formatter.js
Normal file
15
utils/formatter.js
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
function formatStoreReport(location, storeNumber) {
|
||||||
|
let msg = `### Store ${storeNumber} - ${location.name || 'Unknown'}\n\n`;
|
||||||
|
|
||||||
|
msg += `**ℹ️ Details**\n`;
|
||||||
|
msg += `${location.address || ''}\n`;
|
||||||
|
if (location.address2) msg += `${location.address2}\n`;
|
||||||
|
if (location.address3) msg += `${location.address3}\n`;
|
||||||
|
msg += `${location.city || ''}, ${location.state || ''} ${location.postal_code || ''} ${location.country_code || 'US'}\n`;
|
||||||
|
msg += `Phone: ${location.phone || 'N/A'}\n`;
|
||||||
|
msg += `District ID: ${location.district_id || 'N/A'} Region ID: ${location.region_id || 'N/A'}\n`;
|
||||||
|
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { formatStoreReport };
|
||||||
Loading…
Reference in a new issue