chore: project cleanup — dead code, logging, tests, WS hardening
- Delete unused dataMerger, formatter, Device model, dead service exports, and the empty agents/ + .gitkeep placeholders. - Extract STORE_MODES and MDM device-type filters into a shared constants.js. - Anchor bot regexes (^help|^store|^analyze) so "analyze store 305" no longer fires both handlers; replace catch-all noise. - Hoist inline require() calls in integrations to top-of-file imports. - Harden WebSocket server: Authorization header support, single-agent enforcement, bounded pending requests, server-level error handler, coalesced cache refresh in Meraki client. - Wrap Meraki/MDM network calls with withRetry; add request timeouts. - Migrate all console.* calls onto utils/logger.js (LOG_LEVEL aware); drive Webex framework logLevel from env. - Refactor storeDetail.js: shared renderClientLine + buildMdmSection helpers cut duplication roughly in half. - Refresh README structure, document LOG_LEVEL, add npm run agent script, add jest testMatch + new tests (handlers, HealthReport, Store, ws). Verified: npm run lint clean, 7 suites / 31 tests passing. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
459301cc4d
commit
0a46d25bd6
38 changed files with 8109 additions and 1164 deletions
|
|
@ -4,6 +4,10 @@
|
||||||
# NEVER commit .env — it is gitignored.
|
# NEVER commit .env — it is gitignored.
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
# --- Logging ---
|
||||||
|
# debug | info | warn | error (default: info)
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
# --- Webex Bot (required for bot functionality) ---
|
# --- Webex Bot (required for bot functionality) ---
|
||||||
WEBEX_ACCESS_TOKEN=your_webex_bot_access_token_here
|
WEBEX_ACCESS_TOKEN=your_webex_bot_access_token_here
|
||||||
BOT_NAME=NetAnalyzer
|
BOT_NAME=NetAnalyzer
|
||||||
|
|
|
||||||
5
.prettierignore
Normal file
5
.prettierignore
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
node_modules
|
||||||
|
coverage
|
||||||
|
.env*
|
||||||
|
.git
|
||||||
|
package-lock.json
|
||||||
9
.prettierrc.json
Normal file
9
.prettierrc.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 100,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "avoid"
|
||||||
|
}
|
||||||
62
README.md
62
README.md
|
|
@ -42,6 +42,7 @@ SIW and some MDM systems are only reachable from specific internal networks. The
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
1. **Clone and install**
|
1. **Clone and install**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone <repo>
|
git clone <repo>
|
||||||
cd netanalyzer
|
cd netanalyzer
|
||||||
|
|
@ -49,6 +50,7 @@ SIW and some MDM systems are only reachable from specific internal networks. The
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Configure environment**
|
2. **Configure environment**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Edit .env with your real credentials
|
# Edit .env with your real credentials
|
||||||
|
|
@ -67,6 +69,7 @@ SIW and some MDM systems are only reachable from specific internal networks. The
|
||||||
## Running
|
## Running
|
||||||
|
|
||||||
### Main server + bot (where the Webex connection lives)
|
### Main server + bot (where the Webex connection lives)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm start
|
npm start
|
||||||
# or for development with auto-reload
|
# or for development with auto-reload
|
||||||
|
|
@ -74,10 +77,12 @@ npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
This starts:
|
This starts:
|
||||||
|
|
||||||
- The Webex bot framework (listens for messages in Webex spaces)
|
- The Webex bot framework (listens for messages in Webex spaces)
|
||||||
- The WebSocket server on the port defined in `WS_PORT` (default 8080)
|
- The WebSocket server on the port defined in `WS_PORT` (default 8080)
|
||||||
|
|
||||||
### Remote agent (run on a machine that can reach internal systems)
|
### Remote agent (run on a machine that can reach internal systems)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# On the internal machine
|
# On the internal machine
|
||||||
node remoteAgent.js
|
node remoteAgent.js
|
||||||
|
|
@ -99,40 +104,59 @@ The bot also responds to variations containing "store" or "analyze".
|
||||||
```
|
```
|
||||||
.
|
.
|
||||||
├── bot/
|
├── bot/
|
||||||
│ └── handlers.js # Webex command handlers
|
│ └── handlers.js # Webex command handlers (store / analyze / help)
|
||||||
├── config/
|
├── config/
|
||||||
│ └── index.js # Centralized configuration from env
|
│ └── index.js # Centralized env-driven configuration + validation
|
||||||
├── integrations/
|
├── integrations/
|
||||||
│ ├── storeDetail.js # Full store report builder
|
│ ├── storeDetail.js # Full store report builder (Meraki + SIW + MDM)
|
||||||
│ └── storeHealth.js # Health score + summary
|
│ └── storeHealth.js # Health score + summary
|
||||||
|
├── models/
|
||||||
|
│ ├── Store.js # Store domain model (normalizes SIW location)
|
||||||
|
│ └── HealthReport.js # Score / issue accumulator
|
||||||
├── services/
|
├── services/
|
||||||
│ ├── meraki.js # Meraki API client + caching
|
│ ├── meraki.js # Meraki API client (cached, retried)
|
||||||
│ ├── siw.js # SIW calls (via remote proxy)
|
│ ├── siw.js # SIW calls (proxied via the remote agent)
|
||||||
│ ├── mdm.js # Workspace ONE MDM client
|
│ ├── mdm.js # Workspace ONE MDM client (retried)
|
||||||
│ └── websocket.js # WebSocket server + proxyRequest helper
|
│ └── websocket.js # WS server + proxyRequest helper
|
||||||
|
├── tests/
|
||||||
|
│ ├── *.test.js # Unit tests
|
||||||
|
│ ├── integration/ # Integration tests using mocks
|
||||||
|
│ └── mocks/ # Service mocks for tests
|
||||||
├── utils/
|
├── utils/
|
||||||
│ ├── formatter.js
|
│ ├── logger.js # Structured JSON logger (LOG_LEVEL aware)
|
||||||
│ └── dataMerger.js # (currently unused)
|
│ ├── merakiMatcher.js # Device ↔ Meraki client matching
|
||||||
├── remoteAgent.js # Lightweight proxy client
|
│ ├── retry.js # withRetry wrapper (exponential backoff)
|
||||||
├── server.js # Main entry point (bot + ws server)
|
│ └── validate.js # Input parsers
|
||||||
|
├── constants.js # Shared constants (STORE_MODES, MDM device types)
|
||||||
|
├── remoteAgent.js # Lightweight proxy client (run on internal host)
|
||||||
|
├── server.js # Main entry point (bot + WS server)
|
||||||
├── package.json
|
├── package.json
|
||||||
|
├── eslint.config.js
|
||||||
|
├── .prettierrc.json
|
||||||
└── .env.example
|
└── .env.example
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
The app emits single-line JSON to stdout/stderr via `utils/logger.js`. Set `LOG_LEVEL` in `.env` to one of `debug | info | warn | error` (default: `info`).
|
||||||
|
|
||||||
## Security Notes
|
## Security Notes
|
||||||
|
|
||||||
- **Never commit `.env`** (it is gitignored).
|
- **Never commit `.env`** (it is gitignored, along with `.env.bak.*`).
|
||||||
- The WebSocket connection between server and remote agent is protected by a shared token (`WS_TOKEN`).
|
- The WebSocket connection between server and remote agent is protected by a shared `WS_TOKEN`. The remote agent now sends the token in an `Authorization: Bearer` header (the legacy `?token=...` query parameter still works for older deployments but should be migrated).
|
||||||
|
- Only one remote agent may be connected at a time; a newer connection replaces the older one and any in-flight proxy requests are rejected (rather than silently hanging).
|
||||||
- All SIW communication uses Basic Auth and is only performed through the remote agent.
|
- 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.
|
- Meraki and MDM calls use tokens that should be scoped to the minimum necessary permissions.
|
||||||
|
|
||||||
## Next Steps / Roadmap
|
## Development
|
||||||
|
|
||||||
See the phased improvement plan in the project history (or ask the maintainer for current priorities):
|
```bash
|
||||||
- Phase 0: Foundational hygiene (git, secrets, docs) ← **current**
|
npm run lint # eslint + prettier check
|
||||||
- Phase 1: Input validation, shared logic extraction, graceful shutdown
|
npm run lint:fix # auto-fix lint issues
|
||||||
- Phase 2: Linting, tests, structured logging, basic resilience
|
npm run format # prettier write
|
||||||
- Phase 3: Domain modeling and architectural cleanup
|
npm test # jest (unit + integration tests using mocks)
|
||||||
|
npm run test:watch
|
||||||
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
# Reserved for future agent implementations
|
|
||||||
131
bot/handlers.js
131
bot/handlers.js
|
|
@ -1,18 +1,39 @@
|
||||||
const { getStoreDetail } = require('../integrations/storeDetail');
|
const { getStoreDetail } = require('../integrations/storeDetail');
|
||||||
|
const { getStoreHealth } = require('../integrations/storeHealth');
|
||||||
|
const { parseStoreNumber } = require('../utils/validate');
|
||||||
|
const { STORE_MODES } = require('../constants');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
async function handleStoreCommand(bot, trigger) {
|
function parseStoreCommand(text) {
|
||||||
const words = trigger.message.text.trim().split(/\s+/);
|
const storeNumber = parseStoreNumber(text);
|
||||||
const storeNumber = words[1];
|
if (!storeNumber) return { storeNumber: null, mode: null };
|
||||||
|
|
||||||
if (!storeNumber || isNaN(storeNumber)) {
|
const lower = text.toLowerCase();
|
||||||
return bot.say('markdown', 'Usage: `store <number>`\nExample: `store 305`');
|
let mode = STORE_MODES.DEFAULT;
|
||||||
|
|
||||||
|
if (lower.includes(' pos')) mode = STORE_MODES.POS;
|
||||||
|
else if (lower.includes(' ios') || lower.includes(' iphone')) mode = STORE_MODES.IOS;
|
||||||
|
|
||||||
|
return { storeNumber, mode };
|
||||||
}
|
}
|
||||||
|
|
||||||
bot.say('markdown', `🔍 Analyzing Store **${storeNumber}**...`);
|
async function handleStoreCommand(bot, trigger) {
|
||||||
|
const { storeNumber, mode } = parseStoreCommand(trigger.message.text);
|
||||||
|
|
||||||
|
if (!storeNumber) {
|
||||||
|
return bot.say(
|
||||||
|
'markdown',
|
||||||
|
'Usage: `store <number>`\n\nExample: `store 305`\n\nOptions:\n• `store 305` — info + network + server\n• `store 305 pos` — POS systems\n• `store 305 ios` — iOS devices\n\nType `help store` for more details.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const modeLabel =
|
||||||
|
mode === STORE_MODES.POS ? 'POS' : mode === STORE_MODES.IOS ? 'iOS' : 'Overview';
|
||||||
|
bot.say('markdown', `🔍 Analyzing Store **${storeNumber}** (${modeLabel})...`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fullReport = await getStoreDetail(storeNumber);
|
const report = await getStoreDetail(storeNumber, mode);
|
||||||
const sections = fullReport.split(/\n\n(?=\*\*)/);
|
const sections = report.split(/\n\n(?=\*\*)/);
|
||||||
|
|
||||||
for (let i = 0; i < sections.length; i++) {
|
for (let i = 0; i < sections.length; i++) {
|
||||||
const section = sections[i].trim();
|
const section = sections[i].trim();
|
||||||
|
|
@ -22,35 +43,105 @@ async function handleStoreCommand(bot, trigger) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('❌ Analysis error:', err);
|
logger.error('Store analysis error', { storeNumber, error: err.message });
|
||||||
bot.say('markdown', `❌ Error analyzing store **${storeNumber}**:\n${err.message}`);
|
bot.say('markdown', `❌ Error analyzing store **${storeNumber}**:\n${err.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { getStoreHealth } = require('../integrations/storeHealth');
|
|
||||||
|
|
||||||
async function handleAnalyzeCommand(bot, trigger) {
|
async function handleAnalyzeCommand(bot, trigger) {
|
||||||
const words = trigger.message.text.trim().split(/\s+/);
|
const { storeNumber, mode } = parseStoreCommand(trigger.message.text);
|
||||||
const storeNumber = words[1];
|
|
||||||
|
|
||||||
if (!storeNumber || isNaN(storeNumber)) {
|
if (!storeNumber) {
|
||||||
return bot.say('markdown', 'Usage: `analyze <number>`\nExample: `analyze 305`');
|
return bot.say(
|
||||||
|
'markdown',
|
||||||
|
'Usage: `analyze <number>`\n\nExample: `analyze 305`\n\nOptions:\n• `analyze 305` — full health summary\n• `analyze 305 pos` — POS health (broken only)\n• `analyze 305 ios` — iOS health (broken only)\n\nType `help analyze` for more details.'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
bot.say('markdown', `🔍 Running Health Analysis for Store **${storeNumber}**...`);
|
const modeLabel =
|
||||||
|
mode === STORE_MODES.POS ? 'POS' : mode === STORE_MODES.IOS ? 'iOS' : 'Overview';
|
||||||
|
bot.say('markdown', `🔍 Running Health Analysis for Store **${storeNumber}** (${modeLabel})...`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const health = await getStoreHealth(storeNumber);
|
const health = await getStoreHealth(storeNumber, mode);
|
||||||
|
|
||||||
if (health && health.summary) {
|
if (health && health.summary) {
|
||||||
bot.say('markdown', health.summary); // ← Correct way
|
let output = health.summary;
|
||||||
|
|
||||||
|
// For sub-modes or analyze, only show broken components
|
||||||
|
if (mode !== STORE_MODES.DEFAULT) {
|
||||||
|
const lines = output.split('\n');
|
||||||
|
const issueStart = lines.findIndex(l => l.includes('Issues Detected') || l.includes('⚠️'));
|
||||||
|
if (issueStart > -1) {
|
||||||
|
output =
|
||||||
|
lines.slice(0, issueStart + 1).join('\n') +
|
||||||
|
'\n' +
|
||||||
|
lines
|
||||||
|
.slice(issueStart + 1)
|
||||||
|
.filter(l => l.trim().startsWith('-'))
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bot.say('markdown', output);
|
||||||
} else {
|
} else {
|
||||||
bot.say('markdown', '✅ Analysis completed, but no summary was generated.');
|
bot.say('markdown', '✅ Analysis completed, but no summary was generated.');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('❌ Health analysis error:', err);
|
logger.error('Store health analysis error', { storeNumber, error: err.message });
|
||||||
bot.say('markdown', `❌ Error analyzing store **${storeNumber}**:\n${err.message}`);
|
bot.say('markdown', `❌ Error analyzing store **${storeNumber}**:\n${err.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { handleStoreCommand, handleAnalyzeCommand };
|
async function handleHelpCommand(bot, trigger) {
|
||||||
|
const text = trigger.message.text.toLowerCase().trim();
|
||||||
|
|
||||||
|
let response;
|
||||||
|
|
||||||
|
if (text.includes('store')) {
|
||||||
|
response = `**Store Commands**
|
||||||
|
|
||||||
|
• \`store <number>\` — Store info/details, active network devices, and the store server
|
||||||
|
• \`store <number> pos\` — Store server, registers, mobile registers, printers, and payment terminals
|
||||||
|
• \`store <number> ios\` — All iOS devices
|
||||||
|
|
||||||
|
**Tip:** Type the command without a number for usage examples.`;
|
||||||
|
} else if (text.includes('analyze')) {
|
||||||
|
response = `**Analyze Commands** (shows only broken components for sub-modes)
|
||||||
|
|
||||||
|
• \`analyze <number>\` — Overall health summary
|
||||||
|
• \`analyze <number> pos\` — POS health (only issues)
|
||||||
|
• \`analyze <number> ios\` — iOS health (only issues)
|
||||||
|
|
||||||
|
**Tip:** Type the command without a number for usage examples.`;
|
||||||
|
} else {
|
||||||
|
response = `**NetAnalyzer Help**
|
||||||
|
|
||||||
|
Available Commands:
|
||||||
|
|
||||||
|
**Store Commands:**
|
||||||
|
• \`store <number>\` — Store info/details, active network devices, and the store server
|
||||||
|
• \`store <number> pos\` — Store server, registers, mobile registers, printers, and payment terminals
|
||||||
|
• \`store <number> ios\` — All iOS devices
|
||||||
|
|
||||||
|
**Analyze Commands:**
|
||||||
|
• \`analyze <number>\` — Overall health summary
|
||||||
|
• \`analyze <number> pos\` — POS health (broken components only)
|
||||||
|
• \`analyze <number> ios\` — iOS health (broken components only)
|
||||||
|
|
||||||
|
**Tips:**
|
||||||
|
• Most commands work in both group spaces and 1:1 chats.
|
||||||
|
• Type a command without a number for usage examples.
|
||||||
|
• Use \`help store\` or \`help analyze\` for more details.
|
||||||
|
• Try \`store 782\` or \`analyze 782\` to get started.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
bot.say('markdown', response);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
handleStoreCommand,
|
||||||
|
handleAnalyzeCommand,
|
||||||
|
handleHelpCommand,
|
||||||
|
parseStoreCommand,
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,85 @@
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Required environment variables for core operation.
|
||||||
|
* The app will refuse to start if these are missing.
|
||||||
|
*/
|
||||||
|
const REQUIRED_ENV_VARS = ['WEBEX_ACCESS_TOKEN', 'MERAKI_API_KEY', 'MERAKI_ORG_ID', 'WS_TOKEN'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recommended environment variables for full functionality.
|
||||||
|
* Warnings will be logged but startup will continue.
|
||||||
|
*/
|
||||||
|
const RECOMMENDED_ENV_VARS = [
|
||||||
|
'SIW_BASE_URL',
|
||||||
|
'SIW_USERNAME',
|
||||||
|
'SIW_PASSWORD',
|
||||||
|
'WS1_BASE_URL',
|
||||||
|
'WS1_TOKEN_URL',
|
||||||
|
'WS1_CLIENT_ID',
|
||||||
|
'WS1_CLIENT_SECRET',
|
||||||
|
'WS1_TENANT_CODE',
|
||||||
|
];
|
||||||
|
|
||||||
|
function validateEnvironment() {
|
||||||
|
const missingRequired = REQUIRED_ENV_VARS.filter(
|
||||||
|
key => !process.env[key] || process.env[key].trim() === ''
|
||||||
|
);
|
||||||
|
const missingRecommended = RECOMMENDED_ENV_VARS.filter(
|
||||||
|
key => !process.env[key] || process.env[key].trim() === ''
|
||||||
|
);
|
||||||
|
|
||||||
|
if (missingRequired.length > 0) {
|
||||||
|
logger.error('Missing required environment variables', { missing: missingRequired });
|
||||||
|
throw new Error(`Missing required environment variables: ${missingRequired.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingRecommended.length > 0) {
|
||||||
|
logger.warn('Optional environment variables not set; some features may be limited', {
|
||||||
|
missing: missingRecommended,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const wsPort = parseInt(process.env.WS_PORT, 10);
|
||||||
|
if (process.env.WS_PORT && (isNaN(wsPort) || wsPort < 1 || wsPort > 65535)) {
|
||||||
|
throw new Error('WS_PORT must be a valid port number between 1 and 65535');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Environment validation passed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip validation when running unit tests so tests don't require a fully
|
||||||
|
// populated .env. Integration tests can opt in by unsetting JEST_WORKER_ID.
|
||||||
|
if (!process.env.JEST_WORKER_ID) {
|
||||||
|
validateEnvironment();
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
logLevel: process.env.LOG_LEVEL || 'info',
|
||||||
webex: {
|
webex: {
|
||||||
token: process.env.WEBEX_ACCESS_TOKEN,
|
token: process.env.WEBEX_ACCESS_TOKEN,
|
||||||
name: process.env.BOT_NAME || 'NetAnalyzer'
|
name: process.env.BOT_NAME || 'NetAnalyzer',
|
||||||
},
|
},
|
||||||
meraki: {
|
meraki: {
|
||||||
baseUrl: 'https://api.meraki.com/api/v1',
|
baseUrl: 'https://api.meraki.com/api/v1',
|
||||||
apiKey: process.env.MERAKI_API_KEY,
|
apiKey: process.env.MERAKI_API_KEY,
|
||||||
orgId: process.env.MERAKI_ORG_ID
|
orgId: process.env.MERAKI_ORG_ID,
|
||||||
},
|
},
|
||||||
ws: {
|
ws: {
|
||||||
port: parseInt(process.env.WS_PORT) || 8080,
|
port: parseInt(process.env.WS_PORT) || 8080,
|
||||||
token: process.env.WS_TOKEN
|
token: process.env.WS_TOKEN,
|
||||||
},
|
},
|
||||||
siw: {
|
siw: {
|
||||||
baseUrl: process.env.SIW_BASE_URL,
|
baseUrl: process.env.SIW_BASE_URL,
|
||||||
username: process.env.SIW_USERNAME,
|
username: process.env.SIW_USERNAME,
|
||||||
password: process.env.SIW_PASSWORD
|
password: process.env.SIW_PASSWORD,
|
||||||
},
|
},
|
||||||
mdm: {
|
mdm: {
|
||||||
baseUrl: process.env.WS1_BASE_URL,
|
baseUrl: process.env.WS1_BASE_URL,
|
||||||
tokenUrl: process.env.WS1_TOKEN_URL,
|
tokenUrl: process.env.WS1_TOKEN_URL,
|
||||||
clientId: process.env.WS1_CLIENT_ID,
|
clientId: process.env.WS1_CLIENT_ID,
|
||||||
clientSecret: process.env.WS1_CLIENT_SECRET,
|
clientSecret: process.env.WS1_CLIENT_SECRET,
|
||||||
tenantCode: process.env.WS1_TENANT_CODE
|
tenantCode: process.env.WS1_TENANT_CODE,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
43
constants.js
Normal file
43
constants.js
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
/**
|
||||||
|
* Shared constants used across the bot, integrations, and services.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STORE_MODES = Object.freeze({
|
||||||
|
DEFAULT: 'default', // store info + active network devices + store server
|
||||||
|
POS: 'pos', // store server + registers + mobile registers + printers + payment terminals
|
||||||
|
IOS: 'ios', // all iOS devices
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MDM (Workspace ONE) device-name conventions.
|
||||||
|
* Devices are classified by substring on UserName or DeviceFriendlyName.
|
||||||
|
*/
|
||||||
|
const MDM_DEVICE_TYPES = Object.freeze({
|
||||||
|
SERVER: 'SRV',
|
||||||
|
MOBILE_REGISTER: 'MR',
|
||||||
|
CUSTOMER_DISPLAY: 'CD',
|
||||||
|
IPHONE: 'IPH',
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the canonical device-name string used to classify an MDM device.
|
||||||
|
*/
|
||||||
|
function mdmDeviceName(device) {
|
||||||
|
if (!device) return '';
|
||||||
|
return (device.UserName || device.DeviceFriendlyName || '').toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter MDM devices whose name contains the given marker (SRV/MR/CD/IPH).
|
||||||
|
*/
|
||||||
|
function filterMdmByType(devices, marker) {
|
||||||
|
if (!Array.isArray(devices) || !marker) return [];
|
||||||
|
return devices.filter(d => mdmDeviceName(d).includes(marker));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
STORE_MODES,
|
||||||
|
MDM_DEVICE_TYPES,
|
||||||
|
mdmDeviceName,
|
||||||
|
filterMdmByType,
|
||||||
|
};
|
||||||
46
eslint.config.js
Normal file
46
eslint.config.js
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
const js = require('@eslint/js');
|
||||||
|
const globals = require('globals');
|
||||||
|
const prettierPlugin = require('eslint-plugin-prettier');
|
||||||
|
const prettierConfig = require('eslint-config-prettier');
|
||||||
|
|
||||||
|
module.exports = [
|
||||||
|
js.configs.recommended,
|
||||||
|
prettierConfig,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
sourceType: 'commonjs',
|
||||||
|
globals: {
|
||||||
|
...globals.node,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
prettier: prettierPlugin,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'prettier/prettier': 'error',
|
||||||
|
'no-console': 'off',
|
||||||
|
'no-unused-vars': [
|
||||||
|
'warn',
|
||||||
|
{
|
||||||
|
argsIgnorePattern: '^_',
|
||||||
|
caughtErrorsIgnorePattern: '^_',
|
||||||
|
varsIgnorePattern: '^_',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'no-constant-condition': ['error', { checkLoops: false }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Jest globals for test files
|
||||||
|
{
|
||||||
|
files: ['**/*.test.js', '**/__tests__/**/*.js'],
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.jest,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ignores: ['node_modules/**', 'coverage/**', '.git/**'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -1,84 +1,138 @@
|
||||||
const { getStoreLocation, getStoreRegisters, getStorePrinters, getStorePaymentTerminals } = require('../services/siw');
|
const {
|
||||||
const { findMerakiNetwork, getMerakiDeviceAvailabilities, getMerakiClients } = require('../services/meraki');
|
getStoreLocation,
|
||||||
const { formatStoreReport } = require('../utils/formatter');
|
getStoreRegisters,
|
||||||
|
getStorePrinters,
|
||||||
|
getStorePaymentTerminals,
|
||||||
|
} = require('../services/siw');
|
||||||
|
const {
|
||||||
|
findMerakiNetwork,
|
||||||
|
getMerakiDeviceAvailabilities,
|
||||||
|
getMerakiClients,
|
||||||
|
} = require('../services/meraki');
|
||||||
|
const { getMDMDevices } = require('../services/mdm');
|
||||||
|
const { createStore } = require('../models/Store');
|
||||||
|
const {
|
||||||
|
findMatchingClient,
|
||||||
|
getClientStatus,
|
||||||
|
formatLastSeen,
|
||||||
|
buildMerakiClientLink,
|
||||||
|
} = require('../utils/merakiMatcher');
|
||||||
|
const { STORE_MODES, MDM_DEVICE_TYPES, filterMdmByType } = require('../constants');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
function formatLastSeen(lastSeen) {
|
function shouldFetchForMode(mode, category) {
|
||||||
if (!lastSeen) return 'N/A';
|
if (mode === STORE_MODES.IOS) {
|
||||||
const now = new Date();
|
return ['mdm', 'meraki', 'merakiClients'].includes(category);
|
||||||
const seen = new Date(lastSeen);
|
}
|
||||||
const diffMs = now - seen;
|
if (mode === STORE_MODES.POS) {
|
||||||
const diffMin = Math.floor(diffMs / 60000);
|
return ['siw', 'mdm', 'meraki', 'merakiClients'].includes(category);
|
||||||
|
}
|
||||||
if (diffMin < 1) return 'Just now';
|
return true; // default fetches most things
|
||||||
if (diffMin < 60) return `${diffMin} min ago`;
|
|
||||||
const hours = Math.floor(diffMin / 60);
|
|
||||||
return `${hours} hr ago`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getStoreDetail(storeNumber) {
|
async function getStoreDetail(storeNumber, mode = STORE_MODES.DEFAULT) {
|
||||||
console.log(`🔍 Starting full analysis for store ${storeNumber}`);
|
logger.info('Starting store analysis', { storeNumber, mode });
|
||||||
|
|
||||||
let locationData, merakiNetwork, registers, printers, paymentTerminals, merakiClients = [], mdmDevices = [];
|
let locationData = null,
|
||||||
|
merakiNetwork = null,
|
||||||
|
registers = [],
|
||||||
|
printers = [],
|
||||||
|
paymentTerminals = [],
|
||||||
|
merakiClients = [],
|
||||||
|
mdmDevices = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Phase 1: Fast lookups
|
// Phase 1: basic lookups
|
||||||
[locationData, merakiNetwork] = await Promise.all([
|
[locationData, merakiNetwork] = await Promise.all([
|
||||||
getStoreLocation(storeNumber),
|
shouldFetchForMode(mode, 'location') ? getStoreLocation(storeNumber) : Promise.resolve(null),
|
||||||
findMerakiNetwork(storeNumber)
|
shouldFetchForMode(mode, 'meraki') ? findMerakiNetwork(storeNumber) : Promise.resolve(null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Phase 2: Heavy parallel lookups
|
// Phase 2: heavy data, fanned out in parallel
|
||||||
const parallelPromises = [
|
const dataPromises = [];
|
||||||
|
|
||||||
|
if (shouldFetchForMode(mode, 'siw')) {
|
||||||
|
dataPromises.push(
|
||||||
getStoreRegisters(storeNumber),
|
getStoreRegisters(storeNumber),
|
||||||
getStorePrinters(storeNumber),
|
getStorePrinters(storeNumber),
|
||||||
getStorePaymentTerminals(storeNumber)
|
getStorePaymentTerminals(storeNumber)
|
||||||
];
|
|
||||||
|
|
||||||
if (merakiNetwork) {
|
|
||||||
parallelPromises.push(
|
|
||||||
getMerakiClients(merakiNetwork.id),
|
|
||||||
require('../services/mdm').getMDMDevices(storeNumber)
|
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
dataPromises.push(Promise.resolve([]), Promise.resolve([]), Promise.resolve([]));
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = await Promise.all(parallelPromises);
|
dataPromises.push(
|
||||||
|
merakiNetwork && shouldFetchForMode(mode, 'merakiClients')
|
||||||
|
? getMerakiClients(merakiNetwork.id)
|
||||||
|
: Promise.resolve([])
|
||||||
|
);
|
||||||
|
|
||||||
registers = results[0];
|
dataPromises.push(
|
||||||
printers = results[1];
|
shouldFetchForMode(mode, 'mdm') ? getMDMDevices(storeNumber) : Promise.resolve([])
|
||||||
paymentTerminals = results[2];
|
);
|
||||||
|
|
||||||
if (merakiNetwork) {
|
const [reg, prn, pay, clients, mdm] = await Promise.all(dataPromises);
|
||||||
console.log(`Meraki Network:`)
|
registers = reg || [];
|
||||||
console.log(JSON.stringify(merakiNetwork, null, 2))
|
printers = prn || [];
|
||||||
merakiClients = results[3] || [];
|
paymentTerminals = pay || [];
|
||||||
mdmDevices = results[4] || [];
|
merakiClients = clients || [];
|
||||||
}
|
mdmDevices = mdm || [];
|
||||||
|
|
||||||
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`);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
logger.info('Store data fetched', { mode });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('❌ Error fetching data:', err.message);
|
logger.error('Error fetching store data', { error: err.message });
|
||||||
}
|
}
|
||||||
|
|
||||||
let report = formatStoreReport(locationData, storeNumber);
|
let report = '';
|
||||||
|
|
||||||
// === Meraki Network Hardware ===
|
if (mode === STORE_MODES.DEFAULT) {
|
||||||
if (merakiNetwork) {
|
report += createStore(locationData, storeNumber).toSummary();
|
||||||
report += `**🌐 [${merakiNetwork.name}](${merakiNetwork.url})**\n\n`;
|
report += await buildActiveNetworkDevices(merakiNetwork);
|
||||||
|
report += buildStoreServers(mdmDevices, merakiClients, merakiNetwork);
|
||||||
|
} else if (mode === STORE_MODES.POS) {
|
||||||
|
report += buildStoreServers(mdmDevices, merakiClients, merakiNetwork);
|
||||||
|
report += buildRegisters(registers, merakiClients, merakiNetwork);
|
||||||
|
report += buildMobileRegisters(mdmDevices, merakiClients, merakiNetwork);
|
||||||
|
report += buildPrinters(printers, merakiClients, merakiNetwork);
|
||||||
|
report += buildPaymentTerminals(paymentTerminals, merakiClients, merakiNetwork);
|
||||||
|
} else if (mode === STORE_MODES.IOS) {
|
||||||
|
report += buildIOSDevices(mdmDevices, merakiClients, merakiNetwork);
|
||||||
|
}
|
||||||
|
|
||||||
|
return report || 'No matching data for this view.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Section Builders ===
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a single "device → matched Meraki client" line.
|
||||||
|
* `prefixParts` is rendered before the status; `identifiers` is passed
|
||||||
|
* straight through to findMatchingClient.
|
||||||
|
*/
|
||||||
|
function renderClientLine({ prefixParts, identifiers, merakiClients, merakiNetwork }) {
|
||||||
|
const match = findMatchingClient(merakiClients, identifiers);
|
||||||
|
const status = getClientStatus(match);
|
||||||
|
const lastSeen = formatLastSeen(match?.lastSeen);
|
||||||
|
const recentDevice = match?.recentDeviceName ? ` via ${match.recentDeviceName}` : '';
|
||||||
|
const connection = match?.recentDeviceConnection ? ` (${match.recentDeviceConnection})` : '';
|
||||||
|
const link = match ? ` → [Meraki Client](${buildMerakiClientLink(merakiNetwork, match)})` : '';
|
||||||
|
const prefix = prefixParts.filter(Boolean).join(' — ');
|
||||||
|
return `${prefix} — ${status} — ${lastSeen}${recentDevice}${connection}${link}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildActiveNetworkDevices(merakiNetwork) {
|
||||||
|
if (!merakiNetwork) return '\n⚠️ No matching Meraki network found.\n';
|
||||||
|
|
||||||
|
let out = `**🌐 [${merakiNetwork.name}](${merakiNetwork.url})**\n\n`;
|
||||||
|
|
||||||
let activeDevices = [];
|
let activeDevices = [];
|
||||||
try {
|
try {
|
||||||
const availabilities = await getMerakiDeviceAvailabilities(merakiNetwork.id);
|
const availabilities = await getMerakiDeviceAvailabilities(merakiNetwork.id);
|
||||||
activeDevices = availabilities.filter(d => d.status !== 'dormant');
|
activeDevices = availabilities.filter(d => d.status !== 'dormant');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Device availabilities failed:', e.message);
|
logger.error('Device availabilities failed', { error: e.message });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort: Rear Switch → Front Switch → APs
|
|
||||||
activeDevices.sort((a, b) => {
|
activeDevices.sort((a, b) => {
|
||||||
const nameA = (a.name || '').trim().toUpperCase();
|
const nameA = (a.name || '').trim().toUpperCase();
|
||||||
const nameB = (b.name || '').trim().toUpperCase();
|
const nameB = (b.name || '').trim().toUpperCase();
|
||||||
|
|
@ -92,40 +146,100 @@ async function getStoreDetail(storeNumber) {
|
||||||
return nameA.localeCompare(nameB);
|
return nameA.localeCompare(nameB);
|
||||||
});
|
});
|
||||||
|
|
||||||
report += `**🛠️ Active Network Devices (${activeDevices.length})**\n`;
|
out += `**🛠️ Active Network Devices (${activeDevices.length})**\n`;
|
||||||
|
|
||||||
activeDevices.forEach(dev => {
|
activeDevices.forEach(dev => {
|
||||||
const statusEmoji = dev.status === 'online' ? '✅' : '⚠️';
|
const statusEmoji = dev.status === 'online' ? '✅' : '⚠️';
|
||||||
const deviceName = (dev.name || dev.serial || 'Unknown').trim();
|
const deviceName = (dev.name || dev.serial || 'Unknown').trim();
|
||||||
|
|
||||||
report += `**${deviceName}** — ${statusEmoji} ${dev.status}`;
|
out += `**${deviceName}** — ${statusEmoji} ${dev.status}`;
|
||||||
|
|
||||||
if (dev.serial && merakiNetwork.url) {
|
if (dev.serial && merakiNetwork.url) {
|
||||||
// Extract short network code from the URL (e.g. JCz-ucnd)
|
|
||||||
const urlParts = merakiNetwork.url.split('/n/');
|
const urlParts = merakiNetwork.url.split('/n/');
|
||||||
const networkCode = urlParts[1] ? urlParts[1].split('/')[0] : merakiNetwork.id;
|
const networkCode = urlParts[1] ? urlParts[1].split('/')[0] : merakiNetwork.id;
|
||||||
|
|
||||||
let link = `${urlParts[0]}/n/${networkCode}`;
|
let link = `${urlParts[0]}/n/${networkCode}`;
|
||||||
|
|
||||||
if (dev.productType === 'switch') {
|
if (dev.productType === 'switch') link += `/manage/switches/${dev.serial}/summary`;
|
||||||
link += `/manage/switches/${dev.serial}/summary`;
|
else if (dev.productType === 'wireless')
|
||||||
} else if (dev.productType === 'wireless') {
|
|
||||||
link += `/manage/access_points/${dev.serial}/summary`;
|
link += `/manage/access_points/${dev.serial}/summary`;
|
||||||
} else {
|
else link += `/manage/${dev.serial}/summary`;
|
||||||
link += `/manage/${dev.serial}/summary`;
|
|
||||||
}
|
|
||||||
|
|
||||||
report += ` → [Dashboard](${link})`;
|
out += ` → [Dashboard](${link})`;
|
||||||
}
|
}
|
||||||
report += `\n`;
|
out += `\n`;
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
report += `\n⚠️ No matching Meraki network found.\n`;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Store Registers ===
|
function buildMdmSection({
|
||||||
if (registers && registers.length > 0) {
|
devices,
|
||||||
report += `\n**🖥️ Store Registers (${registers.length})**\n`;
|
marker,
|
||||||
|
title,
|
||||||
|
fallbackName,
|
||||||
|
merakiClients,
|
||||||
|
merakiNetwork,
|
||||||
|
formatPrefix,
|
||||||
|
}) {
|
||||||
|
const filtered = filterMdmByType(devices, marker);
|
||||||
|
if (filtered.length === 0) return '';
|
||||||
|
|
||||||
|
let out = `\n**${title} (${filtered.length})**\n`;
|
||||||
|
|
||||||
|
filtered.forEach(dev => {
|
||||||
|
const name = dev.UserName || dev.DeviceFriendlyName || fallbackName;
|
||||||
|
out += renderClientLine({
|
||||||
|
prefixParts: formatPrefix ? formatPrefix(name, dev) : [`**${name}**`],
|
||||||
|
identifiers: {
|
||||||
|
UserName: dev.UserName,
|
||||||
|
DeviceFriendlyName: dev.DeviceFriendlyName,
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
merakiClients,
|
||||||
|
merakiNetwork,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStoreServers(mdmDevices, merakiClients, merakiNetwork) {
|
||||||
|
return buildMdmSection({
|
||||||
|
devices: mdmDevices,
|
||||||
|
marker: MDM_DEVICE_TYPES.SERVER,
|
||||||
|
title: '🖥️ Store Servers',
|
||||||
|
fallbackName: 'Unknown Server',
|
||||||
|
merakiClients,
|
||||||
|
merakiNetwork,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMobileRegisters(mdmDevices, merakiClients, merakiNetwork) {
|
||||||
|
return buildMdmSection({
|
||||||
|
devices: mdmDevices,
|
||||||
|
marker: MDM_DEVICE_TYPES.MOBILE_REGISTER,
|
||||||
|
title: '📱 Mobile Registers',
|
||||||
|
fallbackName: 'Unknown Mobile Register',
|
||||||
|
merakiClients,
|
||||||
|
merakiNetwork,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIOSDevices(mdmDevices, merakiClients, merakiNetwork) {
|
||||||
|
return buildMdmSection({
|
||||||
|
devices: mdmDevices,
|
||||||
|
marker: MDM_DEVICE_TYPES.IPHONE,
|
||||||
|
title: '📱 Store iPhones',
|
||||||
|
fallbackName: 'Unknown iPhone',
|
||||||
|
merakiClients,
|
||||||
|
merakiNetwork,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRegisters(registers, merakiClients, merakiNetwork) {
|
||||||
|
if (!registers || registers.length === 0) return '';
|
||||||
|
|
||||||
|
let out = `\n**🖥️ Store Registers (${registers.length})**\n`;
|
||||||
|
|
||||||
registers.forEach(reg => {
|
registers.forEach(reg => {
|
||||||
const regNum = reg.register_number ? `Register ${reg.register_number}` : 'Register';
|
const regNum = reg.register_number ? `Register ${reg.register_number}` : 'Register';
|
||||||
|
|
@ -133,212 +247,58 @@ async function getStoreDetail(storeNumber) {
|
||||||
const type = reg.register_type_name || 'Unknown';
|
const type = reg.register_type_name || 'Unknown';
|
||||||
const name = (reg.register_display_name || reg.printer_name || 'Unknown').trim();
|
const name = (reg.register_display_name || reg.printer_name || 'Unknown').trim();
|
||||||
|
|
||||||
const match = merakiClients.find(c =>
|
out += renderClientLine({
|
||||||
c?.description &&
|
prefixParts: [`**${regNum}**`, brand, type],
|
||||||
(c.description.trim().toLowerCase() === name.toLowerCase() ||
|
identifiers: { name, register_display_name: name },
|
||||||
c.description.includes(name))
|
merakiClients,
|
||||||
);
|
merakiNetwork,
|
||||||
|
|
||||||
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`;
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Store Printers (USB filtered) ===
|
function buildPrinters(printers, merakiClients, merakiNetwork) {
|
||||||
const filteredPrinters = (printers || []).filter(p =>
|
const filtered = (printers || []).filter(
|
||||||
(p.connection_type_name || '').toLowerCase() !== 'usb'
|
p => (p.connection_type_name || '').toLowerCase() !== 'usb'
|
||||||
);
|
);
|
||||||
|
if (filtered.length === 0) return '';
|
||||||
|
|
||||||
if (filteredPrinters.length > 0) {
|
let out = `\n**🖨️ Store Printers (${filtered.length})**\n`;
|
||||||
report += `\n**🖨️ Store Printers (${filteredPrinters.length})**\n`;
|
|
||||||
|
|
||||||
filteredPrinters.forEach(printer => {
|
filtered.forEach(printer => {
|
||||||
const name = (printer.printer_name || 'Unknown').trim();
|
const name = (printer.printer_name || 'Unknown').trim();
|
||||||
const model = printer.printer_model_name || 'N/A';
|
const model = printer.printer_model_name || 'N/A';
|
||||||
|
out += renderClientLine({
|
||||||
const match = merakiClients.find(c =>
|
prefixParts: [`**${name}**`, model],
|
||||||
c?.description &&
|
identifiers: { name, printer_name: name },
|
||||||
(c.description.trim().toLowerCase() === name.toLowerCase() ||
|
merakiClients,
|
||||||
c.description.includes(name))
|
merakiNetwork,
|
||||||
);
|
|
||||||
|
|
||||||
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`;
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (paymentTerminals && paymentTerminals.length > 0) {
|
function buildPaymentTerminals(paymentTerminals, merakiClients, merakiNetwork) {
|
||||||
report += `\n**💳 Payment Terminals (${paymentTerminals.length})**\n`;
|
if (!paymentTerminals || paymentTerminals.length === 0) return '';
|
||||||
|
|
||||||
|
let out = `\n**💳 Payment Terminals (${paymentTerminals.length})**\n`;
|
||||||
|
|
||||||
paymentTerminals.forEach(term => {
|
paymentTerminals.forEach(term => {
|
||||||
const deviceName = term.device_name || 'Unknown Terminal';
|
const deviceName = term.device_name || 'Unknown Terminal';
|
||||||
const adyenName = term.adyen_device_name || 'N/A';
|
const adyenName = term.adyen_device_name || 'N/A';
|
||||||
const model = term.device_model_name || 'N/A';
|
const model = term.device_model_name || 'N/A';
|
||||||
const type = term.device_type_name || 'N/A';
|
const type = term.device_type_name || 'N/A';
|
||||||
|
out += renderClientLine({
|
||||||
const match = merakiClients.find(c =>
|
prefixParts: [`**${deviceName}**`, adyenName, model, type],
|
||||||
c?.description &&
|
identifiers: { deviceName, adyenName, ip_address: term.ip_address },
|
||||||
(c.description.includes(deviceName) ||
|
merakiClients,
|
||||||
c.description.includes(adyenName) ||
|
merakiNetwork,
|
||||||
(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 ===
|
return out;
|
||||||
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 };
|
module.exports = { getStoreDetail };
|
||||||
|
|
@ -1,38 +1,64 @@
|
||||||
const { getStoreLocation } = require('../services/siw');
|
const { getStoreLocation, getStorePaymentTerminals, getStorePrinters } = require('../services/siw');
|
||||||
const { findMerakiNetwork, getMerakiDeviceAvailabilities, getMerakiClients } = require('../services/meraki');
|
const {
|
||||||
|
findMerakiNetwork,
|
||||||
|
getMerakiDeviceAvailabilities,
|
||||||
|
getMerakiClients,
|
||||||
|
} = require('../services/meraki');
|
||||||
const { getMDMDevices } = require('../services/mdm');
|
const { getMDMDevices } = require('../services/mdm');
|
||||||
|
const { findMatchingClient, getClientStatus } = require('../utils/merakiMatcher');
|
||||||
|
const { createHealthReport } = require('../models/HealthReport');
|
||||||
|
const { MDM_DEVICE_TYPES, filterMdmByType } = require('../constants');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
async function getStoreHealth(storeNumber) {
|
// Configurable scoring penalties (higher = worse impact)
|
||||||
console.log(`🔍 Running health analysis for store ${storeNumber}`);
|
const SCORING = {
|
||||||
|
noMerakiNetwork: 50,
|
||||||
|
switchOffline: 15,
|
||||||
|
apOffline: 5,
|
||||||
|
apAlerting: 3,
|
||||||
|
serverOffline: 35,
|
||||||
|
noMdmData: 20,
|
||||||
|
paymentTerminalOffline: 5,
|
||||||
|
printerOffline: 4,
|
||||||
|
// Mobile registers, customer displays and iPhones currently contribute to issues list only
|
||||||
|
};
|
||||||
|
|
||||||
let locationData, merakiNetwork, merakiClients = [], mdmDevices = [], paymentTerminals = [], printers = [];
|
async function getStoreHealth(storeNumber, _mode = 'default') {
|
||||||
|
logger.info('Running health analysis', { storeNumber });
|
||||||
|
|
||||||
|
let locationData,
|
||||||
|
merakiNetwork,
|
||||||
|
merakiClients = [],
|
||||||
|
mdmDevices = [],
|
||||||
|
paymentTerminals = [],
|
||||||
|
printers = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
[locationData, merakiNetwork] = await Promise.all([
|
[locationData, merakiNetwork] = await Promise.all([
|
||||||
getStoreLocation(storeNumber),
|
getStoreLocation(storeNumber),
|
||||||
findMerakiNetwork(storeNumber)
|
findMerakiNetwork(storeNumber),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (merakiNetwork) {
|
if (merakiNetwork) {
|
||||||
[merakiClients, mdmDevices, paymentTerminals, printers] = await Promise.all([
|
[merakiClients, mdmDevices, paymentTerminals, printers] = await Promise.all([
|
||||||
getMerakiClients(merakiNetwork.id),
|
getMerakiClients(merakiNetwork.id),
|
||||||
getMDMDevices(storeNumber),
|
getMDMDevices(storeNumber),
|
||||||
require('../services/siw').getStorePaymentTerminals(storeNumber),
|
getStorePaymentTerminals(storeNumber),
|
||||||
require('../services/siw').getStorePrinters(storeNumber)
|
getStorePrinters(storeNumber),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('❌ Health analysis error:', err.message);
|
logger.error('Health analysis error', { error: err.message });
|
||||||
}
|
}
|
||||||
|
|
||||||
let summary = `**📊 Store ${storeNumber} Health Summary - ${locationData?.name || 'Unknown Store'}**\n\n`;
|
const healthReport = createHealthReport(storeNumber, locationData?.name || 'Unknown Store');
|
||||||
let overallScore = 100;
|
|
||||||
const issues = [];
|
|
||||||
|
|
||||||
// === Network Infrastructure ===
|
// === Network Infrastructure ===
|
||||||
summary += `**🌐 Network Infrastructure**\n`;
|
|
||||||
if (merakiNetwork) {
|
if (merakiNetwork) {
|
||||||
summary += `- Meraki Network: ✅ [${merakiNetwork.name}](${merakiNetwork.url})\n`;
|
healthReport.addSection(
|
||||||
|
'**🌐 Network Infrastructure**',
|
||||||
|
`- Meraki Network: ✅ [${merakiNetwork.name}](${merakiNetwork.url})\n`
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const avail = await getMerakiDeviceAvailabilities(merakiNetwork.id);
|
const avail = await getMerakiDeviceAvailabilities(merakiNetwork.id);
|
||||||
|
|
@ -43,177 +69,175 @@ async function getStoreHealth(storeNumber) {
|
||||||
const apOnline = aps.filter(d => d.status === 'online').length;
|
const apOnline = aps.filter(d => d.status === 'online').length;
|
||||||
const apAlerting = aps.filter(d => d.status === 'alerting').length;
|
const apAlerting = aps.filter(d => d.status === 'alerting').length;
|
||||||
|
|
||||||
summary += `- Switching: ${swOnline}/${switches.length} online\n`;
|
let netContent = `- Switching: ${swOnline}/${switches.length} online\n`;
|
||||||
summary += `- Access Points: ${apOnline}/${aps.length} online`;
|
netContent += `- Access Points: ${apOnline}/${aps.length} online`;
|
||||||
|
|
||||||
if (apAlerting > 0) {
|
if (apAlerting > 0) {
|
||||||
summary += ` (${apAlerting} alerting)`;
|
netContent += ` (${apAlerting} alerting)`;
|
||||||
}
|
}
|
||||||
summary += `\n\n`;
|
netContent += `\n\n`;
|
||||||
|
healthReport.addSection('', netContent); // append
|
||||||
|
|
||||||
// Scoring & Issues
|
// Scoring & Issues
|
||||||
if (swOnline < switches.length) {
|
if (swOnline < switches.length) {
|
||||||
const offlineSw = switches.length - swOnline;
|
const offlineSw = switches.length - swOnline;
|
||||||
overallScore -= offlineSw * 15;
|
healthReport.deduct(offlineSw * SCORING.switchOffline, `${offlineSw} switch(es) offline`);
|
||||||
issues.push(`${offlineSw} switch(es) offline`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (apOnline < aps.length || apAlerting > 0) {
|
if (apOnline < aps.length || apAlerting > 0) {
|
||||||
const totalApIssues = aps.length - apOnline;
|
const totalApIssues = aps.length - apOnline;
|
||||||
|
|
||||||
|
|
||||||
if (apAlerting > 0) {
|
if (apAlerting > 0) {
|
||||||
overallScore -= totalApIssues * 3;
|
healthReport.deduct(
|
||||||
issues.push(`${apAlerting} access point(s) alerting (check cabling/power)`);
|
totalApIssues * SCORING.apAlerting,
|
||||||
|
`${apAlerting} access point(s) alerting (check cabling/power)`
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
overallScore -= totalApIssues * 5;
|
healthReport.deduct(
|
||||||
issues.push(`${totalApIssues} access point(s) offline`);
|
totalApIssues * SCORING.apOffline,
|
||||||
|
`${totalApIssues} access point(s) offline`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Meraki availability error:', e.message);
|
logger.error('Meraki availability error', { error: e.message });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
summary += `- Meraki Network: ❌ Not Found\n\n`;
|
healthReport.addSection('**🌐 Network Infrastructure**', `- Meraki Network: ❌ Not Found\n\n`);
|
||||||
overallScore -= 50;
|
healthReport.deduct(SCORING.noMerakiNetwork, 'No Meraki network found');
|
||||||
issues.push("No Meraki network found");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Core Systems ===
|
// === Core Systems ===
|
||||||
summary += `**🖥️ POS Systems**\n`;
|
healthReport.addSection('**🖥️ POS Systems**', '');
|
||||||
|
|
||||||
if (mdmDevices && mdmDevices.length > 0) {
|
if (mdmDevices && mdmDevices.length > 0) {
|
||||||
const servers = mdmDevices.filter(d => (d.UserName || d.DeviceFriendlyName || '').includes('SRV'));
|
const servers = filterMdmByType(mdmDevices, MDM_DEVICE_TYPES.SERVER);
|
||||||
const mobileRegs = mdmDevices.filter(d => (d.UserName || d.DeviceFriendlyName || '').includes('MR'));
|
const mobileRegs = filterMdmByType(mdmDevices, MDM_DEVICE_TYPES.MOBILE_REGISTER);
|
||||||
const custDisplays = mdmDevices.filter(d => (d.UserName || d.DeviceFriendlyName || '').includes('CD'));
|
const custDisplays = filterMdmByType(mdmDevices, MDM_DEVICE_TYPES.CUSTOMER_DISPLAY);
|
||||||
const iphones = mdmDevices.filter(d => (d.UserName || d.DeviceFriendlyName || '').includes('IPH'));
|
const iphones = filterMdmByType(mdmDevices, MDM_DEVICE_TYPES.IPHONE);
|
||||||
|
|
||||||
// Server (heavy penalty)
|
// Server (heavy penalty)
|
||||||
let serverStatus = '❓ Unknown';
|
let serverStatus = '❓ Unknown';
|
||||||
if (servers.length > 0) {
|
if (servers.length > 0) {
|
||||||
const match = merakiClients.find(c =>
|
const srv = servers[0];
|
||||||
c?.description && servers.some(s =>
|
const match = findMatchingClient(merakiClients, {
|
||||||
(s.UserName || s.DeviceFriendlyName || '').toLowerCase().includes((c.description || '').toLowerCase())
|
UserName: srv.UserName,
|
||||||
)
|
DeviceFriendlyName: srv.DeviceFriendlyName,
|
||||||
);
|
});
|
||||||
serverStatus = match && match.status === 'Online' ? '✅ Online' : '❌ Offline';
|
serverStatus = getClientStatus(match);
|
||||||
if (serverStatus === '❌ Offline') {
|
if (serverStatus === '❌ Offline') {
|
||||||
overallScore -= 35;
|
healthReport.deduct(
|
||||||
issues.push(`Store Server ${servers[0]?.UserName} is offline`);
|
SCORING.serverOffline,
|
||||||
|
`Store Server ${srv?.UserName || srv?.DeviceFriendlyName} is offline`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
summary += `- Store Server: ${serverStatus}\n`;
|
healthReport.addSection('', `- Store Server: ${serverStatus}\n`);
|
||||||
|
|
||||||
// Mobile Registers
|
// Mobile Registers
|
||||||
let mobileOnline = 0;
|
let mobileOnline = 0;
|
||||||
mobileRegs.forEach(reg => {
|
mobileRegs.forEach(reg => {
|
||||||
const name = (reg.UserName || reg.DeviceFriendlyName || '').trim();
|
const name = (reg.UserName || reg.DeviceFriendlyName || '').trim();
|
||||||
const match = merakiClients.find(c =>
|
const match = findMatchingClient(merakiClients, {
|
||||||
c?.description && c.description.toLowerCase().includes(name.toLowerCase()) ||
|
UserName: reg.UserName,
|
||||||
(c.user && c.user.toLowerCase().includes(name.toLowerCase()))
|
DeviceFriendlyName: reg.DeviceFriendlyName,
|
||||||
);
|
|
||||||
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`;
|
const status = getClientStatus(match);
|
||||||
|
if (status === '✅ Online') mobileOnline++;
|
||||||
|
if (status === '❌ Offline') healthReport.addIssue(`Mobile Register ${name} is offline`);
|
||||||
|
});
|
||||||
|
healthReport.addSection(
|
||||||
|
'',
|
||||||
|
`- Mobile Registers: ${mobileOnline}/${mobileRegs.length} online\n`
|
||||||
|
);
|
||||||
|
|
||||||
// Customer Displays & iPhones (lighter penalty)
|
// Customer Displays & iPhones (lighter penalty)
|
||||||
let cdOnline = 0;
|
let cdOnline = 0;
|
||||||
custDisplays.forEach(cd => {
|
custDisplays.forEach(cd => {
|
||||||
const name = (cd.UserName || cd.DeviceFriendlyName || '').trim();
|
const name = (cd.UserName || cd.DeviceFriendlyName || '').trim();
|
||||||
const match = merakiClients.find(c =>
|
const match = findMatchingClient(merakiClients, {
|
||||||
c?.description && c.description.toLowerCase().includes(name.toLowerCase()) ||
|
UserName: cd.UserName,
|
||||||
(c.user && c.user.toLowerCase().includes(name.toLowerCase()))
|
DeviceFriendlyName: cd.DeviceFriendlyName,
|
||||||
);
|
|
||||||
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`;
|
const status = getClientStatus(match);
|
||||||
|
if (status === '✅ Online') cdOnline++;
|
||||||
|
if (status === '❌ Offline') healthReport.addIssue(`Customer Display ${name} is offline`);
|
||||||
|
});
|
||||||
|
healthReport.addSection('', `- Customer Displays: ${cdOnline}/${custDisplays.length} online\n`);
|
||||||
|
|
||||||
let iphoneOnline = 0;
|
let iphoneOnline = 0;
|
||||||
iphones.forEach(phone => {
|
iphones.forEach(phone => {
|
||||||
const name = (phone.UserName || phone.DeviceFriendlyName || '').trim();
|
const name = (phone.UserName || phone.DeviceFriendlyName || '').trim();
|
||||||
const match = merakiClients.find(c =>
|
const match = findMatchingClient(merakiClients, {
|
||||||
c?.description && c.description.toLowerCase().includes(name.toLowerCase()) ||
|
UserName: phone.UserName,
|
||||||
(c.user && c.user.toLowerCase().includes(name.toLowerCase()))
|
DeviceFriendlyName: phone.DeviceFriendlyName,
|
||||||
);
|
|
||||||
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`;
|
const status = getClientStatus(match);
|
||||||
|
if (status === '✅ Online') iphoneOnline++;
|
||||||
|
if (status === '❌ Offline') healthReport.addIssue(`iPhone ${name} is offline`);
|
||||||
|
});
|
||||||
|
healthReport.addSection('', `- Store iPhones: ${iphoneOnline}/${iphones.length} online\n\n`);
|
||||||
} else {
|
} else {
|
||||||
summary += `- No MDM data available\n\n`;
|
healthReport.addSection('', `- No MDM data available\n\n`);
|
||||||
overallScore -= 20;
|
healthReport.deduct(SCORING.noMdmData);
|
||||||
}
|
}
|
||||||
|
|
||||||
// === POS & Peripherals (with per-device penalty) ===
|
// === POS & Peripherals (with per-device penalty) ===
|
||||||
summary += `**💳 POS Peripherals**\n`;
|
healthReport.addSection('**💳 POS Peripherals**', '');
|
||||||
|
|
||||||
// Payment Terminals
|
// Payment Terminals
|
||||||
if (paymentTerminals && paymentTerminals.length > 0) {
|
if (paymentTerminals && paymentTerminals.length > 0) {
|
||||||
let onlinePayments = 0;
|
let onlinePayments = 0;
|
||||||
paymentTerminals.forEach(term => {
|
paymentTerminals.forEach(term => {
|
||||||
const name = (term.device_name || term.adyen_device_name || '').trim().toLowerCase();
|
const match = findMatchingClient(merakiClients, {
|
||||||
const cleanIp = (term.ip_address || '').split('.')[0].toLowerCase();
|
name: term.device_name,
|
||||||
|
adyenName: term.adyen_device_name,
|
||||||
|
ip_address: term.ip_address,
|
||||||
|
});
|
||||||
|
|
||||||
const match = merakiClients.find(c =>
|
const terminalName = (term.device_name || term.adyen_device_name || 'unknown').trim();
|
||||||
c?.description && (
|
const status = getClientStatus(match);
|
||||||
c.description.toLowerCase().includes(name) ||
|
|
||||||
c.description.toLowerCase().includes(cleanIp)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const status = match && match.status === 'Online' ? '✅ Online' : '❌ Offline';
|
|
||||||
if (status === '✅ Online') onlinePayments++;
|
if (status === '✅ Online') onlinePayments++;
|
||||||
if (status === '❌ Offline') {
|
if (status === '❌ Offline') {
|
||||||
overallScore -= 5; // penalty per offline terminal
|
healthReport.deduct(
|
||||||
issues.push(`Payment Terminal ${name} is offline`);
|
SCORING.paymentTerminalOffline,
|
||||||
|
`Payment Terminal ${terminalName} is offline`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
summary += `- Payment Terminals: ${onlinePayments}/${paymentTerminals.length} online\n`;
|
healthReport.addSection(
|
||||||
|
'',
|
||||||
|
`- Payment Terminals: ${onlinePayments}/${paymentTerminals.length} online\n`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Printers
|
// Printers
|
||||||
const filteredPrinters = (printers || []).filter(p =>
|
const filteredPrinters = (printers || []).filter(
|
||||||
(p.connection_type_name || '').toLowerCase() !== 'usb'
|
p => (p.connection_type_name || '').toLowerCase() !== 'usb'
|
||||||
);
|
);
|
||||||
|
|
||||||
if (filteredPrinters.length > 0) {
|
if (filteredPrinters.length > 0) {
|
||||||
let onlinePrinters = 0;
|
let onlinePrinters = 0;
|
||||||
filteredPrinters.forEach(printer => {
|
filteredPrinters.forEach(printer => {
|
||||||
const name = (printer.printer_name || '').trim().toLowerCase();
|
const name = (printer.printer_name || '').trim().toLowerCase();
|
||||||
const match = merakiClients.find(c =>
|
const match = findMatchingClient(merakiClients, { name: printer.printer_name });
|
||||||
c?.description && c.description.toLowerCase().includes(name)
|
const status = getClientStatus(match);
|
||||||
);
|
|
||||||
const status = match && match.status === 'Online' ? '✅ Online' : '❌ Offline';
|
|
||||||
if (status === '✅ Online') onlinePrinters++;
|
if (status === '✅ Online') onlinePrinters++;
|
||||||
if (status === '❌ Offline') {
|
if (status === '❌ Offline') {
|
||||||
overallScore -= 4; // penalty per offline printer
|
healthReport.deduct(SCORING.printerOffline, `Printer ${name} is offline`);
|
||||||
issues.push(`Printer ${name} is offline`);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
summary += `- Printers: ${onlinePrinters}/${filteredPrinters.length} online\n`;
|
healthReport.addSection(
|
||||||
|
'',
|
||||||
|
`- Printers: ${onlinePrinters}/${filteredPrinters.length} online\n`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Overall Status ===
|
// Finalize and return
|
||||||
overallScore = Math.max(0, Math.round(overallScore));
|
const report = healthReport.finalize();
|
||||||
|
return { summary: report.summary };
|
||||||
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 };
|
module.exports = { getStoreHealth };
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
# Reserved for domain models / DTOs
|
|
||||||
73
models/HealthReport.js
Normal file
73
models/HealthReport.js
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
/**
|
||||||
|
* Domain model for Store Health Report.
|
||||||
|
* Encapsulates score, issues, and summary generation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class HealthReport {
|
||||||
|
constructor(storeNumber, storeName = 'Unknown Store') {
|
||||||
|
this.storeNumber = storeNumber;
|
||||||
|
this.storeName = storeName;
|
||||||
|
this.overallScore = 100;
|
||||||
|
this.issues = [];
|
||||||
|
this.sections = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
deduct(points, reason) {
|
||||||
|
this.overallScore = Math.max(0, this.overallScore - points);
|
||||||
|
if (reason) {
|
||||||
|
this.issues.push(reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addSection(title, content) {
|
||||||
|
this.sections.push({ title, content });
|
||||||
|
}
|
||||||
|
|
||||||
|
addIssue(issue) {
|
||||||
|
if (issue && !this.issues.includes(issue)) {
|
||||||
|
this.issues.push(issue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finalize() {
|
||||||
|
this.overallScore = Math.round(this.overallScore);
|
||||||
|
|
||||||
|
const statusEmoji = this.overallScore >= 90 ? '🟢' : this.overallScore >= 70 ? '🟡' : '🔴';
|
||||||
|
const statusText =
|
||||||
|
this.overallScore >= 90 ? 'Good' : this.overallScore >= 70 ? 'Fair' : 'Needs Attention';
|
||||||
|
|
||||||
|
let summary = `**📊 Store ${this.storeNumber} Health Summary - ${this.storeName}**\n\n`;
|
||||||
|
|
||||||
|
this.sections.forEach(section => {
|
||||||
|
summary += section.content + '\n';
|
||||||
|
});
|
||||||
|
|
||||||
|
summary += `\n**Overall Status**: ${statusEmoji} ${statusText} (${this.overallScore}% healthy)\n\n`;
|
||||||
|
|
||||||
|
if (this.issues.length > 0) {
|
||||||
|
summary += `**⚠️ Issues Detected**\n`;
|
||||||
|
this.issues.forEach(issue => (summary += `- ${issue}\n`));
|
||||||
|
} else {
|
||||||
|
summary += `**✅ No major issues detected**\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.summary = summary;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
return {
|
||||||
|
storeNumber: this.storeNumber,
|
||||||
|
storeName: this.storeName,
|
||||||
|
overallScore: this.overallScore,
|
||||||
|
issues: [...this.issues],
|
||||||
|
summary: this.summary,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHealthReport(storeNumber, storeName) {
|
||||||
|
return new HealthReport(storeNumber, storeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { HealthReport, createHealthReport };
|
||||||
39
models/Store.js
Normal file
39
models/Store.js
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
/**
|
||||||
|
* Domain model for a Store.
|
||||||
|
* Normalizes location data coming from SIW.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class Store {
|
||||||
|
constructor(data = {}, storeNumber) {
|
||||||
|
this.number = String(storeNumber || data.store_number || '').trim();
|
||||||
|
this.name = data.name || data.store_name || `Store ${this.number}`;
|
||||||
|
this.address = data.address || '';
|
||||||
|
this.address2 = data.address2 || '';
|
||||||
|
this.address3 = data.address3 || '';
|
||||||
|
this.city = data.city || '';
|
||||||
|
this.state = data.state || '';
|
||||||
|
this.postalCode = data.postal_code || '';
|
||||||
|
this.countryCode = data.country_code || 'US';
|
||||||
|
this.phone = data.phone || '';
|
||||||
|
this.districtId = data.district_id || null;
|
||||||
|
this.regionId = data.region_id || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get fullAddress() {
|
||||||
|
const parts = [this.address, this.address2, this.address3].filter(Boolean);
|
||||||
|
const cityState = `${this.city}, ${this.state} ${this.postalCode}`.trim();
|
||||||
|
return [...parts, cityState, this.phone ? `Phone: ${this.phone}` : '']
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
toSummary() {
|
||||||
|
return `### Store ${this.number} - ${this.name}\n\n**ℹ️ Details**\n${this.fullAddress}\nDistrict ID: ${this.districtId || 'N/A'} Region ID: ${this.regionId || 'N/A'}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStore(data, storeNumber) {
|
||||||
|
return new Store(data, storeNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { Store, createStore };
|
||||||
6262
package-lock.json
generated
6262
package-lock.json
generated
File diff suppressed because it is too large
Load diff
26
package.json
26
package.json
|
|
@ -7,7 +7,15 @@
|
||||||
"description": "NetAnalyzer - Webex bot for store network and device health analysis using Meraki, SIW, and MDM integrations",
|
"description": "NetAnalyzer - Webex bot for store network and device health analysis using Meraki, SIW, and MDM integrations",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
"dev": "nodemon server.js"
|
"agent": "node remoteAgent.js",
|
||||||
|
"dev": "nodemon server.js",
|
||||||
|
"dev:agent": "nodemon remoteAgent.js",
|
||||||
|
"lint": "eslint . --ext .js",
|
||||||
|
"lint:fix": "eslint . --ext .js --fix",
|
||||||
|
"format": "prettier --write \"**/*.{js,json,md}\"",
|
||||||
|
"format:check": "prettier --check \"**/*.{js,json,md}\"",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"webex",
|
"webex",
|
||||||
|
|
@ -29,6 +37,20 @@
|
||||||
"ws": "^8.20.1"
|
"ws": "^8.20.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.1.14"
|
"@eslint/js": "^10.0.1",
|
||||||
|
"eslint": "^10.5.0",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-prettier": "^5.5.6",
|
||||||
|
"globals": "^17.7.0",
|
||||||
|
"jest": "^30.4.2",
|
||||||
|
"nodemon": "^3.1.14",
|
||||||
|
"prettier": "^3.8.4"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"testEnvironment": "node",
|
||||||
|
"testMatch": [
|
||||||
|
"<rootDir>/tests/**/*.test.js"
|
||||||
|
],
|
||||||
|
"verbose": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3,29 +3,45 @@ const axios = require('axios');
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
const WS_URL = process.env.WS_URL;
|
const WS_URL = process.env.WS_URL;
|
||||||
|
const WS_TOKEN = process.env.WS_TOKEN;
|
||||||
|
|
||||||
if (!WS_URL) {
|
if (!WS_URL) {
|
||||||
console.error('❌ WS_URL is not set in .env');
|
console.error('❌ WS_URL is not set in .env');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const INITIAL_BACKOFF_MS = 2000;
|
||||||
|
const MAX_BACKOFF_MS = 60000;
|
||||||
|
const PROXY_TIMEOUT_MS = 30000;
|
||||||
|
|
||||||
let ws = null;
|
let ws = null;
|
||||||
let reconnectAttempts = 0;
|
let reconnectAttempts = 0;
|
||||||
const INITIAL_BACKOFF = 2000; // 2 seconds
|
let shuttingDown = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If WS_TOKEN is provided, send it as an Authorization: Bearer header so the
|
||||||
|
* secret stays out of access logs. (The server still accepts the legacy
|
||||||
|
* ?token=... query parameter for backward compatibility.)
|
||||||
|
*/
|
||||||
|
function buildClientOptions() {
|
||||||
|
if (!WS_TOKEN) return undefined;
|
||||||
|
return { headers: { Authorization: `Bearer ${WS_TOKEN}` } };
|
||||||
|
}
|
||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
console.log(`🔄 Connecting to ${WS_URL}...`);
|
console.log(`🔄 Connecting to ${WS_URL}...`);
|
||||||
|
|
||||||
ws = new WebSocket(WS_URL);
|
ws = new WebSocket(WS_URL, buildClientOptions());
|
||||||
|
|
||||||
ws.on('open', () => {
|
ws.on('open', () => {
|
||||||
console.log('✅ Remote Agent connected to NetAnalyzer');
|
console.log('✅ Remote Agent connected to NetAnalyzer');
|
||||||
reconnectAttempts = 0;
|
reconnectAttempts = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on('message', async (data) => {
|
ws.on('message', async data => {
|
||||||
|
let request;
|
||||||
try {
|
try {
|
||||||
const request = JSON.parse(data);
|
request = JSON.parse(data);
|
||||||
if (request.action !== 'proxyRequest') return;
|
if (request.action !== 'proxyRequest') return;
|
||||||
|
|
||||||
console.log(`🔄 Proxying ${request.method || 'GET'} ${request.url}`);
|
console.log(`🔄 Proxying ${request.method || 'GET'} ${request.url}`);
|
||||||
|
|
@ -36,33 +52,36 @@ function connect() {
|
||||||
headers: request.headers || {},
|
headers: request.headers || {},
|
||||||
auth: request.auth || undefined,
|
auth: request.auth || undefined,
|
||||||
data: request.body || undefined,
|
data: request.body || undefined,
|
||||||
timeout: 30000,
|
timeout: PROXY_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.send(JSON.stringify({
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
requestId: request.requestId,
|
requestId: request.requestId,
|
||||||
status: response.status,
|
status: response.status,
|
||||||
data: response.data,
|
data: response.data,
|
||||||
headers: response.headers
|
headers: response.headers,
|
||||||
}));
|
})
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Proxy error:', err.message);
|
console.error('Proxy error:', err.message);
|
||||||
ws.send(JSON.stringify({
|
ws.send(
|
||||||
requestId: request.requestId,
|
JSON.stringify({
|
||||||
|
requestId: request ? request.requestId : null,
|
||||||
error: err.message,
|
error: err.message,
|
||||||
status: err.response?.status || 500,
|
status: err.response?.status || 500,
|
||||||
data: err.response?.data || null
|
data: err.response?.data || null,
|
||||||
}));
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on('close', (code, reason) => {
|
ws.on('close', code => {
|
||||||
console.log(`❌ Disconnected (code: ${code}). Reconnecting...`);
|
console.log(`❌ Disconnected (code: ${code}).`);
|
||||||
scheduleReconnect();
|
if (!shuttingDown) scheduleReconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on('error', (err) => {
|
ws.on('error', err => {
|
||||||
console.error('WebSocket error:', err.message);
|
console.error('WebSocket error:', err.message);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -70,22 +89,32 @@ function connect() {
|
||||||
function scheduleReconnect() {
|
function scheduleReconnect() {
|
||||||
reconnectAttempts++;
|
reconnectAttempts++;
|
||||||
|
|
||||||
// Exponential backoff, capped at 60 seconds
|
const backoff = Math.min(
|
||||||
const backoff = Math.min(INITIAL_BACKOFF * Math.pow(1.5, reconnectAttempts - 1), 60000);
|
INITIAL_BACKOFF_MS * Math.pow(1.5, reconnectAttempts - 1),
|
||||||
|
MAX_BACKOFF_MS
|
||||||
|
);
|
||||||
|
|
||||||
console.log(`⏳ Reconnecting in ${Math.round(backoff/1000)}s... (attempt ${reconnectAttempts})`);
|
console.log(
|
||||||
|
`⏳ Reconnecting in ${Math.round(backoff / 1000)}s... (attempt ${reconnectAttempts})`
|
||||||
|
);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(connect, backoff);
|
||||||
connect();
|
|
||||||
}, backoff);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start initial connection
|
|
||||||
connect();
|
connect();
|
||||||
|
|
||||||
// Graceful shutdown
|
function shutdownRemote(signal) {
|
||||||
process.on('SIGINT', () => {
|
console.log(`🛑 ${signal} received. Shutting down remote agent...`);
|
||||||
console.log('🛑 Shutting down remote agent...');
|
shuttingDown = true;
|
||||||
if (ws) ws.close();
|
if (ws) {
|
||||||
|
try {
|
||||||
|
ws.close();
|
||||||
|
} catch (_e) {
|
||||||
|
// ignore close errors during shutdown
|
||||||
|
}
|
||||||
|
}
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
}
|
||||||
|
|
||||||
|
process.on('SIGINT', () => shutdownRemote('SIGINT'));
|
||||||
|
process.on('SIGTERM', () => shutdownRemote('SIGTERM'));
|
||||||
|
|
|
||||||
117
server.js
117
server.js
|
|
@ -1,51 +1,108 @@
|
||||||
const Framework = require('webex-node-bot-framework');
|
const Framework = require('webex-node-bot-framework');
|
||||||
const config = require('./config');
|
const config = require('./config');
|
||||||
const { startWebSocketServer } = require('./services/websocket');
|
const { startWebSocketServer, stopWebSocketServer } = require('./services/websocket');
|
||||||
const { handleStoreCommand, handleAnalyzeCommand } = require('./bot/handlers');
|
const { handleStoreCommand, handleAnalyzeCommand, handleHelpCommand } = require('./bot/handlers');
|
||||||
|
const logger = require('./utils/logger');
|
||||||
|
|
||||||
const framework = new Framework({
|
const framework = new Framework({
|
||||||
token: config.webex.token,
|
token: config.webex.token,
|
||||||
removeWebhooksOnStart: true,
|
removeWebhooksOnStart: true,
|
||||||
logLevel: 'debug'
|
logLevel: config.logLevel,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('🚀 Starting NetAnalyzer Framework...');
|
logger.info('Starting NetAnalyzer Framework', { botName: config.webex.name });
|
||||||
|
|
||||||
// Register command handlers
|
// Register command handlers (anchored at the start of the message so
|
||||||
framework.hears(/store/i, handleStoreCommand, '**store <number>** - Analyze a store');
|
// "please analyze store 305" doesn't fire both store and analyze handlers).
|
||||||
framework.hears(/analyze/i, handleAnalyzeCommand, '**analyze <number>** - High-level store health check');
|
framework.hears(
|
||||||
|
/^\s*help\b/i,
|
||||||
|
handleHelpCommand,
|
||||||
|
'Show available commands (try: help, help store, help analyze)'
|
||||||
|
);
|
||||||
|
framework.hears(
|
||||||
|
/^\s*store\b/i,
|
||||||
|
handleStoreCommand,
|
||||||
|
'store <number> — info + network + server\nstore <number> pos — POS devices\nstore <number> ios — iOS devices'
|
||||||
|
);
|
||||||
|
framework.hears(
|
||||||
|
/^\s*analyze\b/i,
|
||||||
|
handleAnalyzeCommand,
|
||||||
|
'analyze <number> — health summary\nanalyze <number> pos — POS health (broken only)\nanalyze <number> ios — iOS health (broken only)'
|
||||||
|
);
|
||||||
|
|
||||||
// Catch-all handler for debugging (lowest priority)
|
// Friendly fallback for anything that didn't match the commands above.
|
||||||
framework.hears(/.*/, (bot, trigger) => {
|
framework.hears(
|
||||||
// Only log and respond if this pattern matched
|
/.*/,
|
||||||
if (trigger.match && trigger.match[0].length > 0) {
|
(bot, trigger) => {
|
||||||
const text = trigger.message.text.toLowerCase();
|
logger.info('Unhandled message', { text: trigger.message.text });
|
||||||
|
bot.say(
|
||||||
|
'I heard: ' +
|
||||||
|
trigger.message.text +
|
||||||
|
'\n\nTry `store <number>`, `store <number> pos`, `analyze <number>`, or `help` for commands.'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
99999
|
||||||
|
);
|
||||||
|
|
||||||
// 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', () => {
|
framework.on('initialized', () => {
|
||||||
console.log('✅ Framework initialized and connected via WebSocket!');
|
logger.info('Framework initialized and connected via WebSocket');
|
||||||
});
|
});
|
||||||
|
|
||||||
framework.on('spawn', (bot, id, addedBy) => {
|
framework.on('spawn', (bot, _id, addedBy) => {
|
||||||
console.log(`🟢 Bot spawned in room: ${bot.room.title || 'Unknown'}`);
|
logger.info('Bot spawned in room', { room: bot.room.title || 'Unknown' });
|
||||||
if (addedBy) {
|
if (addedBy) {
|
||||||
bot.say('NetAnalyzer is ready! Try `store <number>` or `analyze <number>`');
|
bot.say(
|
||||||
|
'NetAnalyzer is ready!\n\nTry `store 782`, `store 782 pos`, `analyze 782`, or type `help` for all commands.'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start framework and WebSocket server sequentially
|
framework
|
||||||
framework.start().then(() => {
|
.start()
|
||||||
console.log('WebSocket server for remote agent starting...');
|
.then(() => {
|
||||||
|
logger.info('WebSocket server for remote agent starting');
|
||||||
startWebSocketServer();
|
startWebSocketServer();
|
||||||
}).catch(err => {
|
})
|
||||||
console.error('❌ Error starting framework:', err);
|
.catch(err => {
|
||||||
|
logger.error('Error starting framework', { error: err.message });
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ==================== Graceful Shutdown ====================
|
||||||
|
let isShuttingDown = false;
|
||||||
|
|
||||||
|
async function shutdown(signal) {
|
||||||
|
if (isShuttingDown) return;
|
||||||
|
isShuttingDown = true;
|
||||||
|
|
||||||
|
logger.info('Shutting down gracefully', { signal });
|
||||||
|
|
||||||
|
try {
|
||||||
|
stopWebSocketServer();
|
||||||
|
|
||||||
|
// webex-node-bot-framework may not expose a clean stop; do what we can.
|
||||||
|
if (framework && typeof framework.stop === 'function') {
|
||||||
|
await framework.stop().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Cleanup complete. Exiting.');
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Error during shutdown', { error: err.message });
|
||||||
|
} finally {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
|
|
||||||
|
process.on('uncaughtException', err => {
|
||||||
|
logger.error('Uncaught Exception', { error: err.message, stack: err.stack });
|
||||||
|
shutdown('uncaughtException');
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('unhandledRejection', reason => {
|
||||||
|
logger.error('Unhandled Rejection', {
|
||||||
|
reason: reason instanceof Error ? reason.message : String(reason),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,16 @@
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
|
const { withRetry } = require('../utils/retry');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
const RETRY_OPTS = { retries: 2, initialDelayMs: 300 };
|
||||||
|
|
||||||
async function getMDMToken() {
|
async function getMDMToken() {
|
||||||
console.log('🔑 Requesting Workspace ONE token...');
|
logger.debug('Requesting Workspace ONE token');
|
||||||
try {
|
|
||||||
const response = await axios.post(
|
const response = await withRetry(
|
||||||
|
() =>
|
||||||
|
axios.post(
|
||||||
config.mdm.tokenUrl,
|
config.mdm.tokenUrl,
|
||||||
new URLSearchParams({
|
new URLSearchParams({
|
||||||
grant_type: 'client_credentials',
|
grant_type: 'client_credentials',
|
||||||
|
|
@ -12,40 +18,43 @@ async function getMDMToken() {
|
||||||
client_secret: config.mdm.clientSecret,
|
client_secret: config.mdm.clientSecret,
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
timeout: 15000,
|
||||||
}
|
}
|
||||||
|
),
|
||||||
|
RETRY_OPTS
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log('✅ MDM Token acquired');
|
logger.info('MDM token acquired');
|
||||||
return response.data.access_token;
|
return response.data.access_token;
|
||||||
} catch (err) {
|
|
||||||
console.error('❌ MDM Token failed:', err.message);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getMDMDevices(storeNumber) {
|
async function getMDMDevices(storeNumber) {
|
||||||
const storePadded = String(storeNumber).padStart(6, '0');
|
const storePadded = String(storeNumber).padStart(6, '0');
|
||||||
console.log(`🔍 Fetching MDM devices for store ${storePadded}`);
|
logger.info('Fetching MDM devices', { storePadded });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = await getMDMToken();
|
const token = await getMDMToken();
|
||||||
|
|
||||||
const response = await axios.get(`${config.mdm.baseUrl}/api/mdm/devices/search`, {
|
const response = await withRetry(
|
||||||
|
() =>
|
||||||
|
axios.get(`${config.mdm.baseUrl}/api/mdm/devices/search`, {
|
||||||
params: { user: storePadded },
|
params: { user: storePadded },
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
'aw-tenant-code': config.mdm.tenantCode,
|
'aw-tenant-code': config.mdm.tenantCode,
|
||||||
Accept: 'application/json'
|
Accept: 'application/json',
|
||||||
}
|
},
|
||||||
});
|
timeout: 20000,
|
||||||
|
}),
|
||||||
|
RETRY_OPTS
|
||||||
|
);
|
||||||
|
|
||||||
const devices = response.data.Devices || [];
|
const devices = response.data.Devices || [];
|
||||||
console.log(`✅ Fetched ${devices.length} MDM devices [getMDMDevices]`);
|
logger.info('Fetched MDM devices', { count: devices.length });
|
||||||
return devices;
|
return devices;
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`❌ MDM devices fetch failed:`, err.message);
|
logger.error('MDM devices fetch failed', { error: err.message });
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,55 +1,71 @@
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
|
const { parseStoreNumber } = require('../utils/validate');
|
||||||
|
const { withRetry } = require('../utils/retry');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
const merakiAxios = axios.create({
|
const merakiAxios = axios.create({
|
||||||
baseURL: config.meraki.baseUrl,
|
baseURL: config.meraki.baseUrl,
|
||||||
headers: {
|
headers: {
|
||||||
'X-Cisco-Meraki-API-Key': config.meraki.apiKey,
|
'X-Cisco-Meraki-API-Key': config.meraki.apiKey,
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json',
|
||||||
}
|
},
|
||||||
|
timeout: 20000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// In-memory cache
|
// In-memory cache
|
||||||
let cachedNetworks = [];
|
let cachedNetworks = [];
|
||||||
let lastCacheTime = 0;
|
let lastCacheTime = 0;
|
||||||
|
let inFlightRefresh = null;
|
||||||
const CACHE_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours
|
const CACHE_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours
|
||||||
|
|
||||||
async function refreshMerakiNetworksCache() {
|
const RETRY_OPTS = { retries: 2, initialDelayMs: 500 };
|
||||||
const start = Date.now();
|
|
||||||
console.log('🔄 Refreshing Meraki networks cache...');
|
|
||||||
|
|
||||||
|
async function refreshMerakiNetworksCache() {
|
||||||
|
// Coalesce concurrent callers onto a single in-flight refresh.
|
||||||
|
if (inFlightRefresh) return inFlightRefresh;
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
logger.info('Refreshing Meraki networks cache');
|
||||||
|
|
||||||
|
inFlightRefresh = (async () => {
|
||||||
try {
|
try {
|
||||||
const url = `/organizations/${config.meraki.orgId}/networks?perPage=5000`;
|
const url = `/organizations/${config.meraki.orgId}/networks?perPage=5000`;
|
||||||
const res = await merakiAxios.get(url);
|
const res = await withRetry(() => merakiAxios.get(url), RETRY_OPTS);
|
||||||
cachedNetworks = res.data || [];
|
cachedNetworks = res.data || [];
|
||||||
lastCacheTime = Date.now();
|
lastCacheTime = Date.now();
|
||||||
|
logger.info('Meraki networks cache refreshed', {
|
||||||
console.log(`✅ Cached ${cachedNetworks.length} Meraki networks (${Date.now() - start}ms)`);
|
count: cachedNetworks.length,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('❌ Failed to refresh Meraki networks cache:', err.message);
|
logger.error('Failed to refresh Meraki networks cache', { error: err.message });
|
||||||
|
// Keep the (possibly stale) cache and let callers decide what to do.
|
||||||
|
} finally {
|
||||||
|
inFlightRefresh = null;
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return inFlightRefresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getMerakiNetworks(forceRefresh = false) {
|
async function getMerakiNetworks(forceRefresh = false) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (forceRefresh || cachedNetworks.length === 0 || (now - lastCacheTime > CACHE_TTL_MS)) {
|
if (forceRefresh || cachedNetworks.length === 0 || now - lastCacheTime > CACHE_TTL_MS) {
|
||||||
await refreshMerakiNetworksCache();
|
await refreshMerakiNetworksCache();
|
||||||
}
|
}
|
||||||
return cachedNetworks;
|
return cachedNetworks;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findMerakiNetwork(storeNum) {
|
async function findMerakiNetwork(storeNum) {
|
||||||
if (!storeNum) return null;
|
const normalized = parseStoreNumber(storeNum);
|
||||||
|
if (!normalized) return null;
|
||||||
|
|
||||||
const raw = String(storeNum).trim();
|
// Many store networks in Meraki are named with a 5-digit padded store number.
|
||||||
const match = raw.match(/\d+/);
|
const searchTerm = normalized.padStart(5, '0').slice(-5);
|
||||||
if (!match) return null;
|
|
||||||
|
|
||||||
let searchTerm = match[0].padStart(5, '0').slice(-5);
|
|
||||||
|
|
||||||
const networks = await getMerakiNetworks();
|
const networks = await getMerakiNetworks();
|
||||||
|
logger.debug('Searching Meraki networks', { storeNum: normalized, searchTerm });
|
||||||
console.log(`🔍 Searching for store "${raw}" → 5-digit term "${searchTerm}"`);
|
|
||||||
|
|
||||||
let bestMatch = null;
|
let bestMatch = null;
|
||||||
let bestScore = -1;
|
let bestScore = -1;
|
||||||
|
|
@ -58,59 +74,41 @@ async function findMerakiNetwork(storeNum) {
|
||||||
const name = (net.name || '').toLowerCase();
|
const name = (net.name || '').toLowerCase();
|
||||||
const term = searchTerm.toLowerCase();
|
const term = searchTerm.toLowerCase();
|
||||||
|
|
||||||
if (name.includes(term)) {
|
if (!name.includes(term)) continue;
|
||||||
const score = (name.includes(` ${term}`) || name.includes(`-${term}`) || name.includes(term + ' ')) ? 100 : 50;
|
|
||||||
|
const score =
|
||||||
|
name.includes(` ${term}`) || name.includes(`-${term}`) || name.includes(`${term} `)
|
||||||
|
? 100
|
||||||
|
: 50;
|
||||||
|
|
||||||
if (score > bestScore) {
|
if (score > bestScore) {
|
||||||
bestScore = score;
|
bestScore = score;
|
||||||
bestMatch = net;
|
bestMatch = net;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (bestMatch) {
|
if (bestMatch) {
|
||||||
console.log(`✅ Best Meraki match: ${bestMatch.name} (ID: ${bestMatch.id})`);
|
logger.info('Best Meraki network match', { name: bestMatch.name, id: bestMatch.id });
|
||||||
|
} else {
|
||||||
|
logger.info('No Meraki network match', { storeNum });
|
||||||
|
}
|
||||||
return bestMatch;
|
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) {
|
async function getMerakiDeviceAvailabilities(networkId) {
|
||||||
if (!networkId || !config.meraki.orgId) return [];
|
if (!networkId || !config.meraki.orgId) return [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await merakiAxios.get(
|
const res = await withRetry(
|
||||||
`/organizations/${config.meraki.orgId}/devices/availabilities`,
|
() =>
|
||||||
{
|
merakiAxios.get(`/organizations/${config.meraki.orgId}/devices/availabilities`, {
|
||||||
params: { networkIds: [networkId] }
|
params: { networkIds: [networkId] },
|
||||||
}
|
}),
|
||||||
|
RETRY_OPTS
|
||||||
);
|
);
|
||||||
return res.data || [];
|
return res.data || [];
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`❌ Device availabilities failed:`, err.message);
|
logger.error('Device availabilities failed', { error: err.message });
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -119,28 +117,28 @@ async function getMerakiClients(networkId) {
|
||||||
if (!networkId) return [];
|
if (!networkId) return [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 7 days = much better chance of catching registers & printers
|
// 7-day window catches registers and printers that only check in occasionally.
|
||||||
const timespanSeconds = 7 * 24 * 60 * 60; // 604800
|
const timespanSeconds = 7 * 24 * 60 * 60;
|
||||||
|
const res = await withRetry(
|
||||||
const res = await merakiAxios.get(
|
() =>
|
||||||
`/networks/${networkId}/clients?perPage=1000×pan=${timespanSeconds}`
|
merakiAxios.get(`/networks/${networkId}/clients`, {
|
||||||
|
params: { perPage: 1000, timespan: timespanSeconds },
|
||||||
|
}),
|
||||||
|
RETRY_OPTS
|
||||||
);
|
);
|
||||||
|
|
||||||
const clients = res.data || [];
|
const clients = res.data || [];
|
||||||
console.log(`✅ Fetched ${clients.length} Meraki clients (7-day timespan) [getMerakiClients]`);
|
logger.info('Fetched Meraki clients', { count: clients.length, networkId });
|
||||||
|
|
||||||
return clients;
|
return clients;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`❌ Failed to fetch Meraki clients:`, err.message);
|
logger.error('Failed to fetch Meraki clients', { error: err.message, networkId });
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
findMerakiNetwork,
|
findMerakiNetwork,
|
||||||
getNetworkClients,
|
|
||||||
getMerakiClients,
|
getMerakiClients,
|
||||||
getAllMerakiDevices,
|
|
||||||
getMerakiDeviceAvailabilities,
|
getMerakiDeviceAvailabilities,
|
||||||
refreshMerakiNetworksCache
|
refreshMerakiNetworksCache,
|
||||||
};
|
};
|
||||||
|
|
@ -1,82 +1,56 @@
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
const { proxyRequest } = require('./websocket');
|
const { proxyRequest } = require('./websocket');
|
||||||
|
const { parseStoreNumber } = require('../utils/validate');
|
||||||
|
|
||||||
|
function getSiwAuthHeaders() {
|
||||||
|
const { username, password } = config.siw;
|
||||||
|
if (!username || !password) {
|
||||||
|
throw new Error('SIW credentials are not configured (SIW_USERNAME / SIW_PASSWORD)');
|
||||||
|
}
|
||||||
|
const authString = Buffer.from(`${username}:${password}`).toString('base64');
|
||||||
|
return { Authorization: `Basic ${authString}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSiwRequest(path) {
|
||||||
|
if (!config.siw.baseUrl) {
|
||||||
|
throw new Error('SIW_BASE_URL is not configured');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
method: 'GET',
|
||||||
|
url: `${config.siw.baseUrl}${path}`,
|
||||||
|
headers: getSiwAuthHeaders(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function getStoreLocation(storeNumber) {
|
async function getStoreLocation(storeNumber) {
|
||||||
const authString = Buffer.from(
|
const normalized = parseStoreNumber(storeNumber);
|
||||||
`${config.siw.username}:${config.siw.password}`
|
if (!normalized) throw new Error('Invalid store number');
|
||||||
).toString('base64');
|
|
||||||
|
|
||||||
const requestConfig = {
|
const result = await proxyRequest(buildSiwRequest(`/StoreLocation/${normalized}`));
|
||||||
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;
|
return result.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getStoreRegisters(storeNumber) {
|
async function getStoreRegisters(storeNumber) {
|
||||||
const authString = Buffer.from(
|
const normalized = parseStoreNumber(storeNumber);
|
||||||
`${config.siw.username}:${config.siw.password}`
|
if (!normalized) throw new Error('Invalid store number');
|
||||||
).toString('base64');
|
|
||||||
|
|
||||||
const requestConfig = {
|
const result = await proxyRequest(buildSiwRequest(`/StoreRegister/${normalized}`));
|
||||||
method: 'GET',
|
|
||||||
url: `${config.siw.baseUrl}/StoreRegister/${storeNumber}`,
|
|
||||||
headers: { 'Authorization': `Basic ${authString}` }
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await proxyRequest(requestConfig);
|
|
||||||
return result.data || [];
|
return result.data || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getStorePrinters(storeNumber) {
|
async function getStorePrinters(storeNumber) {
|
||||||
const authString = Buffer.from(
|
const normalized = parseStoreNumber(storeNumber);
|
||||||
`${config.siw.username}:${config.siw.password}`
|
if (!normalized) throw new Error('Invalid store number');
|
||||||
).toString('base64');
|
|
||||||
|
|
||||||
const requestConfig = {
|
const result = await proxyRequest(buildSiwRequest(`/StorePrinter/${normalized}`));
|
||||||
method: 'GET',
|
|
||||||
url: `${config.siw.baseUrl}/StorePrinter/${storeNumber}`,
|
|
||||||
headers: { 'Authorization': `Basic ${authString}` }
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await proxyRequest(requestConfig);
|
|
||||||
return result.data || [];
|
return result.data || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getStorePaymentTerminals(storeNumber) {
|
async function getStorePaymentTerminals(storeNumber) {
|
||||||
const authString = Buffer.from(
|
const normalized = parseStoreNumber(storeNumber);
|
||||||
`${config.siw.username}:${config.siw.password}`
|
if (!normalized) throw new Error('Invalid store number');
|
||||||
).toString('base64');
|
|
||||||
|
|
||||||
const requestConfig = {
|
const result = await proxyRequest(buildSiwRequest(`/StorePayment/${normalized}`));
|
||||||
method: 'GET',
|
|
||||||
url: `${config.siw.baseUrl}/StorePayment/${storeNumber}`,
|
|
||||||
headers: { 'Authorization': `Basic ${authString}` }
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await proxyRequest(requestConfig);
|
|
||||||
return result.data || [];
|
return result.data || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,5 +58,5 @@ module.exports = {
|
||||||
getStoreLocation,
|
getStoreLocation,
|
||||||
getStoreRegisters,
|
getStoreRegisters,
|
||||||
getStorePrinters,
|
getStorePrinters,
|
||||||
getStorePaymentTerminals // ← new
|
getStorePaymentTerminals,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,57 @@
|
||||||
const WebSocket = require('ws');
|
const WebSocket = require('ws');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
const PROXY_REQUEST_TIMEOUT_MS = 45000;
|
||||||
|
const PING_INTERVAL_MS = 30000;
|
||||||
|
const MAX_PENDING_REQUESTS = 200;
|
||||||
|
|
||||||
let wss;
|
let wss;
|
||||||
let connectedAgent = null;
|
let connectedAgent = null;
|
||||||
|
let pingInterval = null;
|
||||||
const pendingRequests = new Map(); // requestId → { resolve, reject, timeout }
|
const pendingRequests = new Map(); // requestId → { resolve, reject, timeout }
|
||||||
|
|
||||||
function startWebSocketServer() {
|
/**
|
||||||
wss = new WebSocket.Server({ port: config.ws.port });
|
* Pull a bearer token from the standard `Authorization` header first
|
||||||
|
* (preferred — keeps the token out of access logs) and fall back to the
|
||||||
wss.on('connection', (ws, req) => {
|
* legacy `?token=` query parameter for backward compatibility.
|
||||||
// Better URL parsing and logging
|
*/
|
||||||
let token = null;
|
function extractToken(req) {
|
||||||
|
const auth = req.headers['authorization'];
|
||||||
|
if (auth && /^Bearer\s+/i.test(auth)) {
|
||||||
|
return auth.replace(/^Bearer\s+/i, '').trim();
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const url = new URL(req.url, `http://${req.headers.host}/netanalyze`);
|
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
||||||
token = url.searchParams.get('token');
|
return url.searchParams.get('token');
|
||||||
console.log(`🔑 Incoming connection from ${req.socket.remoteAddress} | Path: ${req.url} | Token present: ${!!token}`);
|
} catch (_e) {
|
||||||
} catch (e) {
|
return null;
|
||||||
console.log('❌ Failed to parse connection URL');
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!token || token !== config.ws.token) {
|
function rejectPending(reason) {
|
||||||
console.log(`❌ Token mismatch! Received: "${token}" | Expected: "${config.ws.token ? config.ws.token.substring(0, 8) + '...' : 'MISSING'}"`);
|
for (const { reject, timeout } of pendingRequests.values()) {
|
||||||
ws.close(1008, 'Invalid or missing token');
|
clearTimeout(timeout);
|
||||||
|
reject(new Error(reason));
|
||||||
|
}
|
||||||
|
pendingRequests.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAgentMessage(data) {
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = JSON.parse(data);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to parse agent message', { error: e.message });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('✅ Authorized Remote Agent connected');
|
const { requestId } = response;
|
||||||
connectedAgent = ws;
|
if (!requestId || !pendingRequests.has(requestId)) {
|
||||||
|
logger.warn('Received response with unknown requestId', { requestId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
const { resolve, reject, timeout } = pendingRequests.get(requestId);
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
pendingRequests.delete(requestId);
|
pendingRequests.delete(requestId);
|
||||||
|
|
@ -45,34 +63,101 @@ function startWebSocketServer() {
|
||||||
} else {
|
} else {
|
||||||
resolve(response);
|
resolve(response);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
console.log('⚠️ Received response with unknown requestId:', requestId);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to parse agent message:', e);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startWebSocketServer() {
|
||||||
|
wss = new WebSocket.Server({ port: config.ws.port });
|
||||||
|
|
||||||
|
wss.on('error', err => {
|
||||||
|
logger.error('WebSocket server error', { error: err.message });
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on('close', (code, reason) => {
|
wss.on('connection', (ws, req) => {
|
||||||
console.log(`❌ Remote Agent disconnected | Code: ${code} | Reason: ${reason || 'none'}`);
|
const token = extractToken(req);
|
||||||
connectedAgent = null;
|
const expected = config.ws.token;
|
||||||
});
|
const remote = req.socket.remoteAddress;
|
||||||
|
|
||||||
// Add ping/pong to keep connection alive
|
if (!expected) {
|
||||||
|
logger.error('WS_TOKEN not configured; refusing connection', { remote });
|
||||||
|
ws.close(1011, 'Server misconfigured');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token || token !== expected) {
|
||||||
|
logger.warn('Rejected agent connection: token mismatch', {
|
||||||
|
remote,
|
||||||
|
tokenPresent: !!token,
|
||||||
|
});
|
||||||
|
ws.close(1008, 'Invalid or missing token');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connectedAgent && connectedAgent.readyState === WebSocket.OPEN) {
|
||||||
|
logger.warn('Replacing previously connected remote agent', { remote });
|
||||||
|
try {
|
||||||
|
connectedAgent.close(1013, 'Replaced by newer agent');
|
||||||
|
} catch (_e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
rejectPending('Remote agent replaced; in-flight requests dropped');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Remote agent connected', { remote });
|
||||||
|
connectedAgent = ws;
|
||||||
ws.isAlive = true;
|
ws.isAlive = true;
|
||||||
ws.on('pong', () => { ws.isAlive = true; });
|
|
||||||
|
ws.on('message', handleAgentMessage);
|
||||||
|
ws.on('pong', () => {
|
||||||
|
ws.isAlive = true;
|
||||||
|
});
|
||||||
|
ws.on('error', err => {
|
||||||
|
logger.error('Remote agent socket error', { error: err.message });
|
||||||
|
});
|
||||||
|
ws.on('close', (code, reason) => {
|
||||||
|
logger.info('Remote agent disconnected', {
|
||||||
|
code,
|
||||||
|
reason: reason?.toString() || 'none',
|
||||||
|
});
|
||||||
|
if (connectedAgent === ws) {
|
||||||
|
connectedAgent = null;
|
||||||
|
rejectPending('Remote agent disconnected');
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Keep connections alive
|
pingInterval = setInterval(() => {
|
||||||
const interval = setInterval(() => {
|
wss.clients.forEach(ws => {
|
||||||
wss.clients.forEach((ws) => {
|
|
||||||
if (ws.isAlive === false) return ws.terminate();
|
if (ws.isAlive === false) return ws.terminate();
|
||||||
ws.isAlive = false;
|
ws.isAlive = false;
|
||||||
ws.ping();
|
ws.ping();
|
||||||
});
|
});
|
||||||
}, 30000); // every 30 seconds
|
}, PING_INTERVAL_MS);
|
||||||
|
|
||||||
console.log(`WebSocket server running on ws://0.0.0.0:${config.ws.port}`);
|
logger.info('WebSocket server listening', { port: config.ws.port });
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopWebSocketServer() {
|
||||||
|
if (pingInterval) {
|
||||||
|
clearInterval(pingInterval);
|
||||||
|
pingInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connectedAgent) {
|
||||||
|
try {
|
||||||
|
connectedAgent.close();
|
||||||
|
} catch (_e) {
|
||||||
|
// ignore errors during shutdown
|
||||||
|
}
|
||||||
|
connectedAgent = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wss) {
|
||||||
|
logger.info('Shutting down WebSocket server');
|
||||||
|
wss.close(() => logger.info('WebSocket server closed'));
|
||||||
|
wss = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectPending('Server shutting down');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function proxyRequest(requestConfig) {
|
async function proxyRequest(requestConfig) {
|
||||||
|
|
@ -81,23 +166,33 @@ async function proxyRequest(requestConfig) {
|
||||||
return reject(new Error('No remote agent connected'));
|
return reject(new Error('No remote agent connected'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pendingRequests.size >= MAX_PENDING_REQUESTS) {
|
||||||
|
return reject(new Error('Too many in-flight proxy requests'));
|
||||||
|
}
|
||||||
|
|
||||||
const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
pendingRequests.delete(requestId);
|
pendingRequests.delete(requestId);
|
||||||
reject(new Error(`Proxy request timeout after 45s`));
|
reject(new Error(`Proxy request timeout after ${PROXY_REQUEST_TIMEOUT_MS / 1000}s`));
|
||||||
}, 45000); // Increased timeout
|
}, PROXY_REQUEST_TIMEOUT_MS);
|
||||||
|
|
||||||
pendingRequests.set(requestId, { resolve, reject, timeout });
|
pendingRequests.set(requestId, { resolve, reject, timeout });
|
||||||
|
|
||||||
const payload = JSON.stringify({
|
const payload = JSON.stringify({
|
||||||
action: 'proxyRequest',
|
action: 'proxyRequest',
|
||||||
requestId,
|
requestId,
|
||||||
...requestConfig
|
...requestConfig,
|
||||||
});
|
});
|
||||||
|
|
||||||
connectedAgent.send(payload);
|
connectedAgent.send(payload, err => {
|
||||||
|
if (err) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
pendingRequests.delete(requestId);
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { startWebSocketServer, proxyRequest };
|
module.exports = { startWebSocketServer, stopWebSocketServer, proxyRequest };
|
||||||
|
|
|
||||||
41
tests/handlers.test.js
Normal file
41
tests/handlers.test.js
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
const { parseStoreCommand } = require('../bot/handlers');
|
||||||
|
const { STORE_MODES } = require('../constants');
|
||||||
|
|
||||||
|
describe('parseStoreCommand', () => {
|
||||||
|
it('parses a default store command', () => {
|
||||||
|
expect(parseStoreCommand('store 305')).toEqual({
|
||||||
|
storeNumber: '305',
|
||||||
|
mode: STORE_MODES.DEFAULT,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects pos mode', () => {
|
||||||
|
expect(parseStoreCommand('store 305 pos')).toEqual({
|
||||||
|
storeNumber: '305',
|
||||||
|
mode: STORE_MODES.POS,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects ios mode (ios or iphone)', () => {
|
||||||
|
expect(parseStoreCommand('store 305 ios')).toEqual({
|
||||||
|
storeNumber: '305',
|
||||||
|
mode: STORE_MODES.IOS,
|
||||||
|
});
|
||||||
|
expect(parseStoreCommand('analyze 305 iphone')).toEqual({
|
||||||
|
storeNumber: '305',
|
||||||
|
mode: STORE_MODES.IOS,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null storeNumber for non-numeric input', () => {
|
||||||
|
expect(parseStoreCommand('store')).toEqual({ storeNumber: null, mode: null });
|
||||||
|
expect(parseStoreCommand('analyze something')).toEqual({ storeNumber: null, mode: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses analyze commands the same way', () => {
|
||||||
|
expect(parseStoreCommand('analyze 782')).toEqual({
|
||||||
|
storeNumber: '782',
|
||||||
|
mode: STORE_MODES.DEFAULT,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
52
tests/healthReport.test.js
Normal file
52
tests/healthReport.test.js
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
const { createHealthReport } = require('../models/HealthReport');
|
||||||
|
|
||||||
|
describe('HealthReport', () => {
|
||||||
|
it('starts at a perfect score of 100', () => {
|
||||||
|
const r = createHealthReport('305', 'Test Store').finalize();
|
||||||
|
expect(r.overallScore).toBe(100);
|
||||||
|
expect(r.summary).toContain('🟢 Good');
|
||||||
|
expect(r.summary).toContain('No major issues');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deducts points and floors at zero', () => {
|
||||||
|
const r = createHealthReport('305');
|
||||||
|
r.deduct(60, 'major outage');
|
||||||
|
r.deduct(80, 'second outage');
|
||||||
|
r.finalize();
|
||||||
|
expect(r.overallScore).toBe(0);
|
||||||
|
expect(r.summary).toContain('🔴 Needs Attention');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chooses the right status emoji at each threshold', () => {
|
||||||
|
const fair = createHealthReport('1');
|
||||||
|
fair.deduct(15, 'minor');
|
||||||
|
fair.finalize();
|
||||||
|
expect(fair.overallScore).toBe(85);
|
||||||
|
expect(fair.summary).toContain('🟡 Fair');
|
||||||
|
|
||||||
|
const bad = createHealthReport('1');
|
||||||
|
bad.deduct(35, 'big issue');
|
||||||
|
bad.finalize();
|
||||||
|
expect(bad.summary).toContain('🔴 Needs Attention');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates identical issues added via addIssue', () => {
|
||||||
|
const r = createHealthReport('1');
|
||||||
|
r.addIssue('same problem');
|
||||||
|
r.addIssue('same problem');
|
||||||
|
r.addIssue('different problem');
|
||||||
|
r.finalize();
|
||||||
|
const occurrences = (r.summary.match(/same problem/g) || []).length;
|
||||||
|
expect(occurrences).toBe(1);
|
||||||
|
expect(r.summary).toContain('different problem');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toJSON returns a serializable snapshot', () => {
|
||||||
|
const r = createHealthReport('305', 'Test').finalize();
|
||||||
|
const j = r.toJSON();
|
||||||
|
expect(j.storeNumber).toBe('305');
|
||||||
|
expect(j.storeName).toBe('Test');
|
||||||
|
expect(j.overallScore).toBe(100);
|
||||||
|
expect(typeof j.summary).toBe('string');
|
||||||
|
});
|
||||||
|
});
|
||||||
29
tests/integration/storeHealth.test.js
Normal file
29
tests/integration/storeHealth.test.js
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
/**
|
||||||
|
* Integration test for store health analysis using mocks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { getStoreHealth } = require('../../integrations/storeHealth');
|
||||||
|
|
||||||
|
// Mock the services
|
||||||
|
jest.mock('../../services/meraki', () => require('../mocks/mockMeraki'));
|
||||||
|
jest.mock('../../services/mdm', () => require('../mocks/mockMdm'));
|
||||||
|
|
||||||
|
// SIW is required dynamically in the file, so we mock the whole module
|
||||||
|
jest.mock('../../services/siw', () => require('../mocks/mockSiw'));
|
||||||
|
|
||||||
|
describe('storeHealth integration (with mocks)', () => {
|
||||||
|
it('returns a health summary with reasonable score for a known store', async () => {
|
||||||
|
const result = await getStoreHealth('305');
|
||||||
|
|
||||||
|
expect(result).toHaveProperty('summary');
|
||||||
|
expect(result.summary).toContain('Store 305 Health Summary');
|
||||||
|
expect(result.summary).toContain('Overall Status');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles missing Meraki network gracefully', async () => {
|
||||||
|
// Store 999 does not exist in mock
|
||||||
|
const result = await getStoreHealth('999');
|
||||||
|
|
||||||
|
expect(result.summary).toContain('No Meraki network found');
|
||||||
|
});
|
||||||
|
});
|
||||||
87
tests/merakiMatcher.test.js
Normal file
87
tests/merakiMatcher.test.js
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
const {
|
||||||
|
findMatchingClient,
|
||||||
|
getClientStatus,
|
||||||
|
formatLastSeen,
|
||||||
|
buildMerakiClientLink,
|
||||||
|
} = require('../utils/merakiMatcher');
|
||||||
|
|
||||||
|
describe('merakiMatcher', () => {
|
||||||
|
const sampleClients = [
|
||||||
|
{ id: 'c1', description: 'Register 305', status: 'Online', lastSeen: '2025-01-01T10:00:00Z' },
|
||||||
|
{
|
||||||
|
id: 'c2',
|
||||||
|
description: 'Printer Front',
|
||||||
|
status: 'Offline',
|
||||||
|
lastSeen: Date.now() - 5 * 60 * 1000,
|
||||||
|
},
|
||||||
|
{ id: 'c3', description: '192.168.10.45 - Terminal', status: 'Online', lastSeen: null },
|
||||||
|
{ id: 'c4', description: 'SRV-042', status: 'Online' },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('findMatchingClient', () => {
|
||||||
|
it('matches by exact or substring on description', () => {
|
||||||
|
expect(findMatchingClient(sampleClients, { name: 'Register 305' })).toBe(sampleClients[0]);
|
||||||
|
expect(findMatchingClient(sampleClients, { name: 'printer' })).toBe(sampleClients[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches using multiple identifier fields', () => {
|
||||||
|
const match = findMatchingClient(sampleClients, {
|
||||||
|
deviceName: 'Terminal',
|
||||||
|
ip_address: '192.168.10.45',
|
||||||
|
});
|
||||||
|
expect(match).toBe(sampleClients[2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches MDM style UserName / DeviceFriendlyName', () => {
|
||||||
|
const match = findMatchingClient(sampleClients, {
|
||||||
|
UserName: 'SRV-042',
|
||||||
|
DeviceFriendlyName: 'Server 042',
|
||||||
|
});
|
||||||
|
expect(match).toBe(sampleClients[3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when no clients or no match', () => {
|
||||||
|
expect(findMatchingClient([], { name: 'foo' })).toBeNull();
|
||||||
|
expect(findMatchingClient(sampleClients, { name: 'nonexistent' })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getClientStatus', () => {
|
||||||
|
it('returns correct emojis and fallback', () => {
|
||||||
|
expect(getClientStatus({ status: 'Online' })).toBe('✅ Online');
|
||||||
|
expect(getClientStatus({ status: 'Offline' })).toBe('❌ Offline');
|
||||||
|
expect(getClientStatus(null)).toBe('❓ Unknown');
|
||||||
|
expect(getClientStatus(undefined)).toBe('❓ Unknown');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatLastSeen', () => {
|
||||||
|
it('handles missing value', () => {
|
||||||
|
expect(formatLastSeen(null)).toBe('N/A');
|
||||||
|
expect(formatLastSeen(undefined)).toBe('N/A');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats recent times', () => {
|
||||||
|
const justNow = new Date();
|
||||||
|
expect(formatLastSeen(justNow)).toBe('Just now');
|
||||||
|
|
||||||
|
const tenMin = new Date(Date.now() - 10 * 60 * 1000);
|
||||||
|
expect(formatLastSeen(tenMin)).toMatch(/10 min ago/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildMerakiClientLink', () => {
|
||||||
|
const network = { id: 'N_123', name: 'Store 305', url: 'https://example.com/n/ABC123' };
|
||||||
|
|
||||||
|
it('builds a client link when possible', () => {
|
||||||
|
const client = { id: 'c99' };
|
||||||
|
const link = buildMerakiClientLink(network, client);
|
||||||
|
expect(link).toContain('/manage/clients/c99/overview');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string on bad input', () => {
|
||||||
|
expect(buildMerakiClientLink(null, {})).toBe('');
|
||||||
|
expect(buildMerakiClientLink(network, null)).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
13
tests/mocks/mockMdm.js
Normal file
13
tests/mocks/mockMdm.js
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
/**
|
||||||
|
* Mock MDM (Workspace ONE) service for testing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function getMDMDevices(storeNumber) {
|
||||||
|
const padded = String(storeNumber).padStart(6, '0');
|
||||||
|
return Promise.resolve([
|
||||||
|
{ UserName: `SRV-${padded}`, DeviceFriendlyName: 'Store Server' },
|
||||||
|
{ UserName: `MR-${padded}`, DeviceFriendlyName: 'Mobile Register' },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getMDMDevices };
|
||||||
42
tests/mocks/mockMeraki.js
Normal file
42
tests/mocks/mockMeraki.js
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
/**
|
||||||
|
* Mock Meraki service for testing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const mockNetworks = [
|
||||||
|
{ id: 'N_001', name: 'Store 001', url: 'https://example.com/n/STORE001' },
|
||||||
|
{ id: 'N_305', name: 'Store 305 - Main', url: 'https://example.com/n/STORE305' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function findMerakiNetwork(storeNum) {
|
||||||
|
const num = String(storeNum).padStart(3, '0');
|
||||||
|
return Promise.resolve(mockNetworks.find(n => n.name.includes(num)) || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMerakiClients(networkId) {
|
||||||
|
if (networkId === 'N_305') {
|
||||||
|
return Promise.resolve([
|
||||||
|
{ id: 'c1', description: 'Register 305', status: 'Online', lastSeen: new Date() },
|
||||||
|
{
|
||||||
|
id: 'c2',
|
||||||
|
description: 'Printer Front',
|
||||||
|
status: 'Offline',
|
||||||
|
lastSeen: Date.now() - 10 * 60 * 1000,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMerakiDeviceAvailabilities(_networkId) {
|
||||||
|
return Promise.resolve([
|
||||||
|
{ serial: 'SW1', name: 'SWR-001', productType: 'switch', status: 'online' },
|
||||||
|
{ serial: 'AP1', name: 'AP-Front', productType: 'wireless', status: 'online' },
|
||||||
|
{ serial: 'AP2', name: 'AP-Back', productType: 'wireless', status: 'alerting' },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
findMerakiNetwork,
|
||||||
|
getMerakiClients,
|
||||||
|
getMerakiDeviceAvailabilities,
|
||||||
|
};
|
||||||
44
tests/mocks/mockSiw.js
Normal file
44
tests/mocks/mockSiw.js
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
/**
|
||||||
|
* Mock SIW service for testing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function getStoreLocation(storeNumber) {
|
||||||
|
return Promise.resolve({
|
||||||
|
name: `Store ${storeNumber}`,
|
||||||
|
address: '123 Main St',
|
||||||
|
city: 'Anytown',
|
||||||
|
state: 'CA',
|
||||||
|
postal_code: '90210',
|
||||||
|
phone: '555-1234',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStoreRegisters(_storeNumber) {
|
||||||
|
return Promise.resolve([
|
||||||
|
{
|
||||||
|
register_number: '1',
|
||||||
|
register_display_name: 'Register 305',
|
||||||
|
brand_display_name: 'NCR',
|
||||||
|
register_type_name: 'POS',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStorePrinters(_storeNumber) {
|
||||||
|
return Promise.resolve([
|
||||||
|
{ printer_name: 'Printer Front', printer_model_name: 'Epson', connection_type_name: 'network' },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStorePaymentTerminals(_storeNumber) {
|
||||||
|
return Promise.resolve([
|
||||||
|
{ device_name: 'Terminal 1', adyen_device_name: 'Adyen-01', ip_address: '192.168.1.50' },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getStoreLocation,
|
||||||
|
getStoreRegisters,
|
||||||
|
getStorePrinters,
|
||||||
|
getStorePaymentTerminals,
|
||||||
|
};
|
||||||
41
tests/store.test.js
Normal file
41
tests/store.test.js
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
const { createStore, Store } = require('../models/Store');
|
||||||
|
|
||||||
|
describe('Store model', () => {
|
||||||
|
it('uses store_number from data when no explicit number given', () => {
|
||||||
|
const s = createStore({ store_number: '305', name: 'Foo' });
|
||||||
|
expect(s.number).toBe('305');
|
||||||
|
expect(s.name).toBe('Foo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to `Store <n>` when name is missing', () => {
|
||||||
|
const s = createStore({}, 42);
|
||||||
|
expect(s.name).toBe('Store 42');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles missing address fields without throwing', () => {
|
||||||
|
const s = createStore({}, 1);
|
||||||
|
expect(() => s.toSummary()).not.toThrow();
|
||||||
|
const summary = s.toSummary();
|
||||||
|
expect(summary).toContain('Store 1');
|
||||||
|
expect(summary).toContain('District ID: N/A');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds a full address from optional parts', () => {
|
||||||
|
const s = new Store(
|
||||||
|
{
|
||||||
|
address: '123 Main',
|
||||||
|
address2: 'Suite 5',
|
||||||
|
city: 'Anytown',
|
||||||
|
state: 'CA',
|
||||||
|
postal_code: '90210',
|
||||||
|
phone: '555-1234',
|
||||||
|
},
|
||||||
|
'305'
|
||||||
|
);
|
||||||
|
const addr = s.fullAddress;
|
||||||
|
expect(addr).toContain('123 Main');
|
||||||
|
expect(addr).toContain('Suite 5');
|
||||||
|
expect(addr).toContain('Anytown, CA 90210');
|
||||||
|
expect(addr).toContain('Phone: 555-1234');
|
||||||
|
});
|
||||||
|
});
|
||||||
28
tests/validate.test.js
Normal file
28
tests/validate.test.js
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
const { parseStoreNumber } = require('../utils/validate');
|
||||||
|
|
||||||
|
describe('parseStoreNumber', () => {
|
||||||
|
it('extracts a simple store number', () => {
|
||||||
|
expect(parseStoreNumber('store 782')).toBe('782');
|
||||||
|
expect(parseStoreNumber('analyze 305')).toBe('305');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles numbers with surrounding text', () => {
|
||||||
|
expect(parseStoreNumber('please analyze store 1234 now')).toBe('1234');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for invalid input', () => {
|
||||||
|
expect(parseStoreNumber(null)).toBeNull();
|
||||||
|
expect(parseStoreNumber('')).toBeNull();
|
||||||
|
expect(parseStoreNumber('store abc')).toBeNull();
|
||||||
|
expect(parseStoreNumber('store 0')).toBeNull();
|
||||||
|
expect(parseStoreNumber('store -42')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores numbers that are too long', () => {
|
||||||
|
expect(parseStoreNumber('store 12345678901')).toBeNull(); // 11 digits
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes by removing leading zeros in result (via parseInt)', () => {
|
||||||
|
expect(parseStoreNumber('store 00042')).toBe('42');
|
||||||
|
});
|
||||||
|
});
|
||||||
16
tests/websocket.test.js
Normal file
16
tests/websocket.test.js
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
// Avoid pulling in the real config (which would require env vars). The
|
||||||
|
// websocket module only reads config.ws.{port,token}, so a small stub is enough.
|
||||||
|
jest.mock('../config', () => ({
|
||||||
|
ws: { port: 0, token: 'test-token' },
|
||||||
|
logLevel: 'error',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { proxyRequest } = require('../services/websocket');
|
||||||
|
|
||||||
|
describe('proxyRequest', () => {
|
||||||
|
it('rejects when no remote agent is connected', async () => {
|
||||||
|
await expect(proxyRequest({ method: 'GET', url: 'http://x' })).rejects.toThrow(
|
||||||
|
'No remote agent connected'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
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 };
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
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 };
|
|
||||||
45
utils/logger.js
Normal file
45
utils/logger.js
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
/**
|
||||||
|
* Lightweight structured logger.
|
||||||
|
*
|
||||||
|
* Honors LOG_LEVEL (debug | info | warn | error). Defaults to 'info'.
|
||||||
|
* Output is single-line JSON for easy ingestion by log shippers.
|
||||||
|
*
|
||||||
|
* Note: requires config-free defaults so it can be imported anywhere without
|
||||||
|
* pulling in config/index.js (which validates env on load).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const LEVELS = { debug: 10, info: 20, warn: 30, error: 40 };
|
||||||
|
|
||||||
|
function configuredLevel() {
|
||||||
|
const raw = (process.env.LOG_LEVEL || 'info').toLowerCase();
|
||||||
|
return LEVELS[raw] ?? LEVELS.info;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldEmit(level) {
|
||||||
|
return LEVELS[level] >= configuredLevel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function log(level, message, meta = {}) {
|
||||||
|
if (!shouldEmit(level)) return;
|
||||||
|
|
||||||
|
const entry = {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
level,
|
||||||
|
msg: message,
|
||||||
|
...meta,
|
||||||
|
};
|
||||||
|
const line = JSON.stringify(entry);
|
||||||
|
|
||||||
|
if (level === 'error') console.error(line);
|
||||||
|
else if (level === 'warn') console.warn(line);
|
||||||
|
else console.log(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
const logger = {
|
||||||
|
debug: (msg, meta) => log('debug', msg, meta),
|
||||||
|
info: (msg, meta) => log('info', msg, meta),
|
||||||
|
warn: (msg, meta) => log('warn', msg, meta),
|
||||||
|
error: (msg, meta) => log('error', msg, meta),
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = logger;
|
||||||
100
utils/merakiMatcher.js
Normal file
100
utils/merakiMatcher.js
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
/**
|
||||||
|
* Device matching and Meraki client utilities.
|
||||||
|
* Centralizes the fragile name/description matching logic used across reports.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the best matching Meraki client for a device using multiple strategies.
|
||||||
|
*
|
||||||
|
* identifiers can contain: name, deviceName, adyenName, UserName, DeviceFriendlyName, ip
|
||||||
|
*/
|
||||||
|
function findMatchingClient(merakiClients = [], identifiers = {}) {
|
||||||
|
if (!merakiClients.length) return null;
|
||||||
|
|
||||||
|
const names = [
|
||||||
|
identifiers.name,
|
||||||
|
identifiers.deviceName,
|
||||||
|
identifiers.adyenName,
|
||||||
|
identifiers.UserName,
|
||||||
|
identifiers.DeviceFriendlyName,
|
||||||
|
identifiers.printer_name,
|
||||||
|
identifiers.register_display_name,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(n => String(n).toLowerCase().trim());
|
||||||
|
|
||||||
|
const ipPrefix = identifiers.ip_address
|
||||||
|
? String(identifiers.ip_address).split('.')[0]
|
||||||
|
: identifiers.ip
|
||||||
|
? String(identifiers.ip).split('.')[0]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
for (const client of merakiClients) {
|
||||||
|
if (!client?.description) continue;
|
||||||
|
const desc = client.description.toLowerCase().trim();
|
||||||
|
|
||||||
|
// Strategy 1: exact or substring match on any name
|
||||||
|
if (names.some(n => n && (desc === n || desc.includes(n) || n.includes(desc)))) {
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 2: IP prefix fallback
|
||||||
|
if (ipPrefix && desc.includes(ipPrefix)) {
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 3: check client.user field
|
||||||
|
if (client.user) {
|
||||||
|
const user = client.user.toLowerCase();
|
||||||
|
if (names.some(n => n && user.includes(n))) {
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClientStatus(client) {
|
||||||
|
if (!client) return '❓ Unknown';
|
||||||
|
return client.status === 'Online' ? '✅ Online' : '❌ Offline';
|
||||||
|
}
|
||||||
|
|
||||||
|
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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a best-effort link to the Meraki client detail page.
|
||||||
|
* Note: This logic is environment-specific (dashboard URLs).
|
||||||
|
*/
|
||||||
|
function buildMerakiClientLink(merakiNetwork, client) {
|
||||||
|
if (!client || !merakiNetwork?.url) return '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const networkCode = merakiNetwork.url.split('/n/')[1]?.split('/')[0] || merakiNetwork.id;
|
||||||
|
const base = merakiNetwork.url.split('/n/')[0] || 'https://n976.dashboard.meraki.com';
|
||||||
|
|
||||||
|
// Different sections for switches vs APs vs clients
|
||||||
|
// Default to clients view
|
||||||
|
return `${base}/${merakiNetwork.name.replace(/ /g, '-')}/n/${networkCode}/manage/clients/${client.id}/overview`;
|
||||||
|
} catch (_e) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
findMatchingClient,
|
||||||
|
getClientStatus,
|
||||||
|
formatLastSeen,
|
||||||
|
buildMerakiClientLink,
|
||||||
|
};
|
||||||
38
utils/retry.js
Normal file
38
utils/retry.js
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
/**
|
||||||
|
* Simple retry wrapper with exponential backoff.
|
||||||
|
* Usage: await withRetry(() => someAsyncCall(), { retries: 3 })
|
||||||
|
*/
|
||||||
|
|
||||||
|
async function withRetry(fn, options = {}) {
|
||||||
|
const {
|
||||||
|
retries = 3,
|
||||||
|
initialDelayMs = 500,
|
||||||
|
maxDelayMs = 5000,
|
||||||
|
factor = 2,
|
||||||
|
onRetry = null,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
let attempt = 0;
|
||||||
|
let delay = initialDelayMs;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (err) {
|
||||||
|
attempt++;
|
||||||
|
|
||||||
|
if (attempt > retries) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onRetry) {
|
||||||
|
onRetry(err, attempt);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delay));
|
||||||
|
delay = Math.min(delay * factor, maxDelayMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { withRetry };
|
||||||
28
utils/validate.js
Normal file
28
utils/validate.js
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
/**
|
||||||
|
* Validation helpers for NetAnalyzer
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract and validate a store number from user input.
|
||||||
|
* Looks for the first sequence of 1-10 digits.
|
||||||
|
* Returns a normalized string (no leading zeros issues) or null.
|
||||||
|
*
|
||||||
|
* @param {string} text
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
function parseStoreNumber(text) {
|
||||||
|
if (!text || typeof text !== 'string') return null;
|
||||||
|
|
||||||
|
// Match 1-10 digits not preceded by minus sign
|
||||||
|
const match = text.trim().match(/(?<!-)\b(\d{1,10})\b/);
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
const num = parseInt(match[1], 10);
|
||||||
|
if (!num || num <= 0) return null;
|
||||||
|
|
||||||
|
return String(num);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
parseStoreNumber,
|
||||||
|
};
|
||||||
Loading…
Reference in a new issue