cisco-perfmon 1.6.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/release.yml +15 -0
- package/README.md +354 -53
- package/bin/cisco-perfmon.js +2 -0
- package/cli/commands/collect.js +48 -0
- package/cli/commands/config.js +85 -0
- package/cli/commands/describe.js +43 -0
- package/cli/commands/doctor.js +115 -0
- package/cli/commands/list-instances.js +39 -0
- package/cli/commands/list-objects.js +45 -0
- package/cli/commands/session.js +162 -0
- package/cli/commands/watch.js +163 -0
- package/cli/formatters/csv.js +10 -0
- package/cli/formatters/json.js +5 -0
- package/cli/formatters/table.js +25 -0
- package/cli/formatters/toon.js +6 -0
- package/cli/index.js +40 -0
- package/cli/utils/audit.js +30 -0
- package/cli/utils/config.js +112 -0
- package/cli/utils/connection.js +36 -0
- package/cli/utils/output.js +28 -0
- package/main.js +6 -7
- package/package.json +13 -2
- package/skills/cisco-perfmon-cli/SKILL.md +176 -0
- package/test/cli/config.test.js +196 -0
- package/test/cli/connection.test.js +151 -0
- package/test/cli/formatters.test.js +144 -0
- package/test/cli/watch.test.js +76 -0
- package/test/unit.js +24 -0
- package/types/index.d.ts +5 -1
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
const os = require("node:os");
|
|
4
|
+
const { execSync } = require("node:child_process");
|
|
5
|
+
|
|
6
|
+
const SS_PLACEHOLDER_RE = /<ss:(\d+):(\w+)>/g;
|
|
7
|
+
|
|
8
|
+
function getConfigDir() {
|
|
9
|
+
return process.env.CISCO_PERFMON_CONFIG_DIR || path.join(os.homedir(), ".cisco-perfmon");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function getConfigPath() {
|
|
13
|
+
return path.join(getConfigDir(), "config.json");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function loadConfig() {
|
|
17
|
+
const configPath = getConfigPath();
|
|
18
|
+
if (!fs.existsSync(configPath)) return { activeCluster: null, clusters: {} };
|
|
19
|
+
return JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function saveConfig(config) {
|
|
23
|
+
const dir = getConfigDir();
|
|
24
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
25
|
+
fs.writeFileSync(getConfigPath(), JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function addCluster(name, opts) {
|
|
29
|
+
const config = loadConfig();
|
|
30
|
+
config.clusters[name] = { host: opts.host, username: opts.username, password: opts.password };
|
|
31
|
+
if (opts.insecure) config.clusters[name].insecure = true;
|
|
32
|
+
if (!config.activeCluster) config.activeCluster = name;
|
|
33
|
+
saveConfig(config);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function useCluster(name) {
|
|
37
|
+
const config = loadConfig();
|
|
38
|
+
if (!config.clusters[name]) throw new Error(`Cluster "${name}" not found. Run "cisco-perfmon config list" to see available clusters.`);
|
|
39
|
+
config.activeCluster = name;
|
|
40
|
+
saveConfig(config);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function removeCluster(name) {
|
|
44
|
+
const config = loadConfig();
|
|
45
|
+
if (!config.clusters[name]) throw new Error(`Cluster "${name}" not found.`);
|
|
46
|
+
delete config.clusters[name];
|
|
47
|
+
if (config.activeCluster === name) {
|
|
48
|
+
const remaining = Object.keys(config.clusters);
|
|
49
|
+
config.activeCluster = remaining.length > 0 ? remaining[0] : null;
|
|
50
|
+
}
|
|
51
|
+
saveConfig(config);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getActiveCluster(clusterName) {
|
|
55
|
+
const config = loadConfig();
|
|
56
|
+
const name = clusterName || config.activeCluster;
|
|
57
|
+
if (!name || !config.clusters[name]) return null;
|
|
58
|
+
return { name, ...config.clusters[name] };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function listClusters() {
|
|
62
|
+
const config = loadConfig();
|
|
63
|
+
return { activeCluster: config.activeCluster, clusters: config.clusters };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function maskPassword(password) {
|
|
67
|
+
if (!password) return "";
|
|
68
|
+
SS_PLACEHOLDER_RE.lastIndex = 0;
|
|
69
|
+
if (SS_PLACEHOLDER_RE.test(password)) { SS_PLACEHOLDER_RE.lastIndex = 0; return password; }
|
|
70
|
+
return "*".repeat(password.length);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function hasSsPlaceholders(obj) {
|
|
74
|
+
for (const value of Object.values(obj)) {
|
|
75
|
+
if (typeof value === "string") {
|
|
76
|
+
SS_PLACEHOLDER_RE.lastIndex = 0;
|
|
77
|
+
if (SS_PLACEHOLDER_RE.test(value)) { SS_PLACEHOLDER_RE.lastIndex = 0; return true; }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function resolveSsPlaceholders(obj) {
|
|
84
|
+
const resolved = { ...obj };
|
|
85
|
+
for (const [key, value] of Object.entries(resolved)) {
|
|
86
|
+
if (typeof value !== "string") continue;
|
|
87
|
+
SS_PLACEHOLDER_RE.lastIndex = 0;
|
|
88
|
+
resolved[key] = value.replace(SS_PLACEHOLDER_RE, (match, id, field) => {
|
|
89
|
+
try {
|
|
90
|
+
const output = execSync(`ss-cli get ${id} --format json`, { encoding: "utf-8", timeout: 10000 });
|
|
91
|
+
const secret = JSON.parse(output);
|
|
92
|
+
if (secret[field] !== undefined) return secret[field];
|
|
93
|
+
if (Array.isArray(secret.items)) {
|
|
94
|
+
const item = secret.items.find((i) => i.fieldName === field || i.slug === field);
|
|
95
|
+
if (item) return item.itemValue;
|
|
96
|
+
}
|
|
97
|
+
throw new Error(`Field "${field}" not found in secret ${id}`);
|
|
98
|
+
} catch (err) {
|
|
99
|
+
if (err.message.includes("ENOENT") || err.message.includes("not found")) {
|
|
100
|
+
throw new Error(`Config contains Secret Server references (<ss:...>) but ss-cli is not available. Install with: npm install -g @sieteunoseis/ss-cli`);
|
|
101
|
+
}
|
|
102
|
+
throw err;
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return resolved;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports = {
|
|
110
|
+
getConfigDir, getConfigPath, loadConfig, saveConfig, addCluster, useCluster, removeCluster,
|
|
111
|
+
getActiveCluster, listClusters, maskPassword, hasSsPlaceholders, resolveSsPlaceholders,
|
|
112
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const configUtil = require("./config.js");
|
|
2
|
+
|
|
3
|
+
function resolveConfig(flags) {
|
|
4
|
+
const env = {
|
|
5
|
+
host: process.env.CUCM_HOST || process.env.CUCM_HOSTNAME || undefined,
|
|
6
|
+
username: process.env.CUCM_USERNAME || undefined,
|
|
7
|
+
password: process.env.CUCM_PASSWORD || undefined,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
let fileConfig = {};
|
|
11
|
+
const cluster = configUtil.getActiveCluster(flags.cluster || undefined);
|
|
12
|
+
if (cluster) fileConfig = cluster;
|
|
13
|
+
|
|
14
|
+
const resolved = {
|
|
15
|
+
host: flags.host || env.host || fileConfig.host,
|
|
16
|
+
username: flags.username || env.username || fileConfig.username,
|
|
17
|
+
password: flags.password || env.password || fileConfig.password,
|
|
18
|
+
insecure: flags.insecure || fileConfig.insecure || false,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
if (!resolved.host || !resolved.username || !resolved.password) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
"No cluster configured. Set one up with:\n" +
|
|
24
|
+
" cisco-perfmon config add <name> --host <h> --username <u> --password <p>\n" +
|
|
25
|
+
" Or set environment variables: CUCM_HOST, CUCM_USERNAME, CUCM_PASSWORD"
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (configUtil.hasSsPlaceholders(resolved)) {
|
|
30
|
+
Object.assign(resolved, configUtil.resolveSsPlaceholders(resolved));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return resolved;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
module.exports = { resolveConfig };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const formatTable = require("../formatters/table.js");
|
|
2
|
+
const formatJson = require("../formatters/json.js");
|
|
3
|
+
const formatToon = require("../formatters/toon.js");
|
|
4
|
+
const formatCsv = require("../formatters/csv.js");
|
|
5
|
+
|
|
6
|
+
const formatters = { table: formatTable, json: formatJson, toon: formatToon, csv: formatCsv };
|
|
7
|
+
|
|
8
|
+
async function printResult(data, format) {
|
|
9
|
+
const formatter = formatters[format || "table"];
|
|
10
|
+
if (!formatter) throw new Error(`Unknown format "${format}". Valid: table, json, toon, csv`);
|
|
11
|
+
const output = await Promise.resolve(formatter(data));
|
|
12
|
+
console.log(output);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function printError(err) {
|
|
16
|
+
const message = err.message || String(err);
|
|
17
|
+
process.stderr.write(`Error: ${message}\n`);
|
|
18
|
+
if (message.includes("Authentication failed") || (err.status === 401)) {
|
|
19
|
+
process.stderr.write('Hint: Run "cisco-perfmon config test" to verify your credentials.\n');
|
|
20
|
+
} else if (message.includes("Rate limit") || message.includes("Exceeded allowed rate") || err.status === 429) {
|
|
21
|
+
process.stderr.write("Hint: Wait 30 seconds and try again, or reduce polling frequency with --interval.\n");
|
|
22
|
+
} else if (message.includes("certificate") || message.includes("CERT")) {
|
|
23
|
+
process.stderr.write("Hint: Use --insecure for self-signed certificates.\n");
|
|
24
|
+
}
|
|
25
|
+
process.exitCode = 1;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module.exports = { printResult, printError };
|
package/main.js
CHANGED
|
@@ -112,19 +112,21 @@ const XML_REMOVE_COUNTER_ENVELOPE = (sessionHandle, counters) => `<soapenv:Envel
|
|
|
112
112
|
* @param {string} host - The host to collect data from. This is usually the IP address/FQDN of the CUCM publisher.
|
|
113
113
|
* @param {string} username - The username to authenticate with. This is usually an AXL user. Can leave this blank if using JESSIONSSO cookie.
|
|
114
114
|
* @param {string} password - The password to authenticate with. This is usually an AXL user. Can leave this blank if using JESSIONSSO cookie.
|
|
115
|
-
* @param {object} options -
|
|
115
|
+
* @param {object} options - Options object. Supports `retries` (default 3), `retryDelay` ms (default 5000), and any additional headers (e.g. cookies for SSO).
|
|
116
116
|
* @returns {object} returns constructor object.
|
|
117
117
|
*/
|
|
118
118
|
class perfMonService {
|
|
119
119
|
constructor(host, username, password, options = {}, retry = true) {
|
|
120
120
|
const RATE_LIMIT_DELAYS = [30000, 60000, 120000]; // 30s, 60s, 120s exponential backoff
|
|
121
|
+
const maxRetries = options.retries ?? (process.env.PM_RETRY ? parseInt(process.env.PM_RETRY) : 3);
|
|
122
|
+
const retryDelay = options.retryDelay ?? (process.env.PM_RETRY_DELAY ? parseInt(process.env.PM_RETRY_DELAY) : 5000);
|
|
121
123
|
|
|
122
124
|
this._OPTIONS = {
|
|
123
125
|
retryOn: async function (attempt, error, response) {
|
|
124
126
|
if (!retry) {
|
|
125
127
|
return false;
|
|
126
128
|
}
|
|
127
|
-
if (attempt >
|
|
129
|
+
if (attempt > maxRetries) {
|
|
128
130
|
return false;
|
|
129
131
|
}
|
|
130
132
|
|
|
@@ -147,7 +149,7 @@ class perfMonService {
|
|
|
147
149
|
// retry on any network error, or 4xx or 5xx status codes
|
|
148
150
|
if (error !== null || response.status >= 400) {
|
|
149
151
|
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
150
|
-
await delay(
|
|
152
|
+
await delay(retryDelay);
|
|
151
153
|
return true;
|
|
152
154
|
}
|
|
153
155
|
},
|
|
@@ -204,7 +206,7 @@ class perfMonService {
|
|
|
204
206
|
*/
|
|
205
207
|
async collectCounterData(host, object) {
|
|
206
208
|
try {
|
|
207
|
-
let options = this._OPTIONS;
|
|
209
|
+
let options = { ...this._OPTIONS, headers: { ...this._OPTIONS.headers } };
|
|
208
210
|
let server = this._HOST;
|
|
209
211
|
let XML = XML_COLLECT_COUNTER_ENVELOPE(escapeXml(host), escapeXml(object));
|
|
210
212
|
let soapBody = Buffer.from(XML);
|
|
@@ -220,9 +222,6 @@ class perfMonService {
|
|
|
220
222
|
};
|
|
221
223
|
|
|
222
224
|
promiseResults.cookie = response.headers.get("set-cookie") ? response.headers.get("set-cookie") : "";
|
|
223
|
-
if (promiseResults.cookie) {
|
|
224
|
-
this.setCookie(promiseResults.cookie);
|
|
225
|
-
}
|
|
226
225
|
if (promiseResults.cookie) {
|
|
227
226
|
this.setCookie(promiseResults.cookie);
|
|
228
227
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cisco-perfmon",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "A library to pull Perfmon data from Cisco VOS applications via SOAP",
|
|
5
5
|
"main": "main.js",
|
|
6
6
|
"module": "main.mjs",
|
|
@@ -12,8 +12,14 @@
|
|
|
12
12
|
"types": "./types/index.d.ts"
|
|
13
13
|
}
|
|
14
14
|
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"cisco-perfmon": "./bin/cisco-perfmon.js"
|
|
17
|
+
},
|
|
18
|
+
"funding": "https://buymeacoffee.com/automatebldrs",
|
|
15
19
|
"scripts": {
|
|
16
|
-
"test": "
|
|
20
|
+
"test": "node ./test/unit.js",
|
|
21
|
+
"test:unit": "node ./test/unit.js",
|
|
22
|
+
"test:integration": "NODE_OPTIONS=--experimental-vm-modules NODE_NO_WARNINGS=1 NODE_TLS_REJECT_UNAUTHORIZED=0 NODE_ENV=test node ./test/tests.js",
|
|
17
23
|
"development": "NODE_OPTIONS=--experimental-vm-modules NODE_NO_WARNINGS=1 NODE_TLS_REJECT_UNAUTHORIZED=0 NODE_ENV=development node ./test/tests.js",
|
|
18
24
|
"generate-config": "NODE_OPTIONS=--experimental-vm-modules NODE_NO_WARNINGS=1 NODE_TLS_REJECT_UNAUTHORIZED=0 NODE_ENV=test node ./test/generateConfig.js",
|
|
19
25
|
"rate-limit-test": "NODE_OPTIONS=--experimental-vm-modules NODE_NO_WARNINGS=1 NODE_TLS_REJECT_UNAUTHORIZED=0 NODE_ENV=test node ./test/rateLimitTest.js"
|
|
@@ -36,7 +42,12 @@
|
|
|
36
42
|
},
|
|
37
43
|
"homepage": "https://github.com/sieteunoseis/cisco-perfmon#readme",
|
|
38
44
|
"dependencies": {
|
|
45
|
+
"@toon-format/toon": "^2.1.0",
|
|
46
|
+
"cli-table3": "^0.6.5",
|
|
47
|
+
"commander": "^12.1.0",
|
|
48
|
+
"csv-stringify": "^6.5.2",
|
|
39
49
|
"fetch-retry": "^6.0.0",
|
|
50
|
+
"update-notifier": "^7.3.1",
|
|
40
51
|
"xml2js": "^0.6.0"
|
|
41
52
|
},
|
|
42
53
|
"devDependencies": {
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cisco-perfmon-cli
|
|
3
|
+
description: Use when collecting Cisco CUCM real-time performance counters via the cisco-perfmon CLI — CPU, memory, call statistics, device counts, and continuous monitoring with sparkline visualization.
|
|
4
|
+
license: MIT
|
|
5
|
+
metadata:
|
|
6
|
+
author: sieteunoseis
|
|
7
|
+
version: "1.0.0"
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# cisco-perfmon CLI
|
|
11
|
+
|
|
12
|
+
CLI for collecting Cisco CUCM real-time performance counters via the PerfMon SOAP API. Supports one-shot collection, session-based polling, continuous monitoring with live sparkline visualization, and multiple output formats.
|
|
13
|
+
|
|
14
|
+
## Setup
|
|
15
|
+
|
|
16
|
+
Configure a cluster (one-time):
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
cisco-perfmon config add <name> --host <host> --username <user> --password <pass> --insecure
|
|
20
|
+
cisco-perfmon config test
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
For Secret Server integration:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
cisco-perfmon config add <name> --host '<ss:ID:host>' --username '<ss:ID:username>' --password '<ss:ID:password>' --insecure
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Or use environment variables:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
export CUCM_HOST=<host>
|
|
33
|
+
export CUCM_USERNAME=<user>
|
|
34
|
+
export CUCM_PASSWORD=<pass>
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Commands
|
|
38
|
+
|
|
39
|
+
### config -- Manage cluster configurations
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
cisco-perfmon config add <name> --host <host> --username <user> --password <pass>
|
|
43
|
+
cisco-perfmon config use <name>
|
|
44
|
+
cisco-perfmon config list
|
|
45
|
+
cisco-perfmon config show
|
|
46
|
+
cisco-perfmon config remove <name>
|
|
47
|
+
cisco-perfmon config test
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### list-objects -- List available perfmon counter objects
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
cisco-perfmon list-objects # all objects
|
|
54
|
+
cisco-perfmon list-objects --search "CallManager" # filter by keyword
|
|
55
|
+
cisco-perfmon list-objects --format json # JSON output
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### list-instances -- List instances of a perfmon object
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
cisco-perfmon list-instances "Cisco CallManager"
|
|
62
|
+
cisco-perfmon list-instances "Process" --format json
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### describe -- Get counter descriptions
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
cisco-perfmon describe "Cisco CallManager"
|
|
69
|
+
cisco-perfmon describe "Cisco CallManager" --counter CallsActive
|
|
70
|
+
cisco-perfmon describe "Cisco CallManager" --counter CallsActive --instance ""
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### collect -- One-shot counter data collection
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
cisco-perfmon collect "Cisco CallManager" # all counters
|
|
77
|
+
cisco-perfmon collect "Cisco CallManager" --counter CallsActive,CallsInProgress # specific counters
|
|
78
|
+
cisco-perfmon collect "Cisco CallManager" --instance "" # filter by instance
|
|
79
|
+
cisco-perfmon collect "Processor" --format json # JSON output
|
|
80
|
+
cisco-perfmon collect "Memory" --format csv > memory.csv # export to CSV
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### session -- Manage perfmon polling sessions
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
cisco-perfmon session open # get a session handle
|
|
87
|
+
cisco-perfmon session add <handle> --counters '[{"host":"cucm","object":"Cisco CallManager","counter":"CallsActive"}]'
|
|
88
|
+
cisco-perfmon session collect <handle> # collect session data
|
|
89
|
+
cisco-perfmon session remove <handle> --counters '[...]'
|
|
90
|
+
cisco-perfmon session close <handle>
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### watch -- Continuous monitoring with live sparklines
|
|
94
|
+
|
|
95
|
+
The watch command polls counters at a configurable interval and displays a live-updating table with sparkline visualizations showing value trends over the last 12 samples.
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
cisco-perfmon watch "Cisco CallManager" # watch all counters
|
|
99
|
+
cisco-perfmon watch "Cisco CallManager" --counter CallsActive --interval 5 # specific counter, 5s interval
|
|
100
|
+
cisco-perfmon watch "Processor" --counter "% CPU Time" --instance "_Total" # CPU monitoring
|
|
101
|
+
cisco-perfmon watch "Memory" --interval 30 --duration 300 # 5-minute memory check
|
|
102
|
+
cisco-perfmon watch "Cisco CallManager" --format json --interval 10 # JSON output for piping
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The table view shows: counter name, instance, current value, sparkline trend, min, max, and average. Press Ctrl+C to stop.
|
|
106
|
+
|
|
107
|
+
### doctor -- Configuration and connectivity health check
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
cisco-perfmon doctor # run all checks
|
|
111
|
+
cisco-perfmon doctor --insecure # with TLS skip
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Checks: active cluster config, PerfMon API connectivity, counter object availability, config file permissions, audit trail size.
|
|
115
|
+
|
|
116
|
+
## Common Workflows
|
|
117
|
+
|
|
118
|
+
### Check system health
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
cisco-perfmon doctor
|
|
122
|
+
cisco-perfmon collect "Cisco CallManager" --format json
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Monitor calls during testing
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
cisco-perfmon watch "Cisco CallManager" --counter CallsActive,CallsInProgress,CallsAttempted --interval 5
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### CPU investigation
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
cisco-perfmon watch "Processor" --counter "% CPU Time" --instance "_Total" --interval 10
|
|
135
|
+
cisco-perfmon collect "Processor" --format json
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### List what counters are available
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
cisco-perfmon list-objects --search "Cisco"
|
|
142
|
+
cisco-perfmon list-instances "Cisco CallManager"
|
|
143
|
+
cisco-perfmon describe "Cisco CallManager" --counter CallsActive
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Export counter data to CSV
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
cisco-perfmon collect "Cisco CallManager" --format csv > callmanager.csv
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Session-based polling for selective counters
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
HANDLE=$(cisco-perfmon session open --format json | jq -r '.sessionHandle')
|
|
156
|
+
cisco-perfmon session add "$HANDLE" --counters '[{"host":"cucm-pub","object":"Cisco CallManager","counter":"CallsActive"}]'
|
|
157
|
+
cisco-perfmon session collect "$HANDLE"
|
|
158
|
+
cisco-perfmon session close "$HANDLE"
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Output Formats
|
|
162
|
+
|
|
163
|
+
- `--format table` (default) -- human-readable table
|
|
164
|
+
- `--format json` -- for scripting/parsing
|
|
165
|
+
- `--format toon` -- token-efficient for AI agents (recommended)
|
|
166
|
+
- `--format csv` -- for spreadsheets
|
|
167
|
+
|
|
168
|
+
## Global Flags
|
|
169
|
+
|
|
170
|
+
- `--host <host>` -- override CUCM hostname
|
|
171
|
+
- `--username <user>` -- override CUCM username
|
|
172
|
+
- `--password <pass>` -- override CUCM password
|
|
173
|
+
- `--cluster <name>` -- use a specific named cluster
|
|
174
|
+
- `--insecure` -- skip TLS certificate verification (required for self-signed certs)
|
|
175
|
+
- `--no-audit` -- disable audit logging for this command
|
|
176
|
+
- `--debug` -- enable debug logging
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
const assert = require("assert");
|
|
2
|
+
const fs = require("fs");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const os = require("os");
|
|
5
|
+
|
|
6
|
+
let passed = 0, failed = 0, total = 0;
|
|
7
|
+
function describe(name, fn) { console.log(` ${name}`); fn(); }
|
|
8
|
+
function it(name, fn) {
|
|
9
|
+
total++;
|
|
10
|
+
try { fn(); console.log(` \u2713 ${name}`); passed++; }
|
|
11
|
+
catch (e) { console.log(` \u2717 ${name}: ${e.message}`); failed++; }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cisco-perfmon-test-config-"));
|
|
15
|
+
process.env.CISCO_PERFMON_CONFIG_DIR = tmpDir;
|
|
16
|
+
|
|
17
|
+
const {
|
|
18
|
+
loadConfig, saveConfig, addCluster, useCluster, removeCluster,
|
|
19
|
+
getActiveCluster, listClusters, maskPassword, hasSsPlaceholders,
|
|
20
|
+
getConfigDir, getConfigPath,
|
|
21
|
+
} = require("../../cli/utils/config.js");
|
|
22
|
+
|
|
23
|
+
describe("getConfigDir", () => {
|
|
24
|
+
it("returns the CISCO_PERFMON_CONFIG_DIR env value", () => {
|
|
25
|
+
assert.strictEqual(getConfigDir(), tmpDir);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("returns path under home when env is unset", () => {
|
|
29
|
+
const saved = process.env.CISCO_PERFMON_CONFIG_DIR;
|
|
30
|
+
delete process.env.CISCO_PERFMON_CONFIG_DIR;
|
|
31
|
+
const dir = getConfigDir();
|
|
32
|
+
assert.strictEqual(dir, path.join(os.homedir(), ".cisco-perfmon"));
|
|
33
|
+
process.env.CISCO_PERFMON_CONFIG_DIR = saved;
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("getConfigPath", () => {
|
|
38
|
+
it("returns config.json inside config dir", () => {
|
|
39
|
+
assert.ok(getConfigPath().endsWith("config.json"));
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("loadConfig", () => {
|
|
44
|
+
it("returns defaults when no config file exists", () => {
|
|
45
|
+
const config = loadConfig();
|
|
46
|
+
assert.strictEqual(config.activeCluster, null);
|
|
47
|
+
assert.deepStrictEqual(config.clusters, {});
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("saveConfig / loadConfig round-trip", () => {
|
|
52
|
+
it("writes and reads back the same data", () => {
|
|
53
|
+
const data = { activeCluster: "lab", clusters: { lab: { host: "10.0.0.1", username: "admin", password: "pass" } } };
|
|
54
|
+
saveConfig(data);
|
|
55
|
+
const loaded = loadConfig();
|
|
56
|
+
assert.strictEqual(loaded.activeCluster, "lab");
|
|
57
|
+
assert.strictEqual(loaded.clusters.lab.host, "10.0.0.1");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("config file exists on disk after save", () => {
|
|
61
|
+
const configPath = path.join(tmpDir, "config.json");
|
|
62
|
+
assert.ok(fs.existsSync(configPath), "config.json should exist");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("config file has restricted permissions (600)", () => {
|
|
66
|
+
const configPath = path.join(tmpDir, "config.json");
|
|
67
|
+
const stats = fs.statSync(configPath);
|
|
68
|
+
const mode = (stats.mode & 0o777).toString(8);
|
|
69
|
+
assert.strictEqual(mode, "600");
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("addCluster", () => {
|
|
74
|
+
it("adds a cluster and sets it active if none active", () => {
|
|
75
|
+
saveConfig({ activeCluster: null, clusters: {} });
|
|
76
|
+
addCluster("prod", { host: "cucm.example.com", username: "admin", password: "secret" });
|
|
77
|
+
const config = loadConfig();
|
|
78
|
+
assert.strictEqual(config.activeCluster, "prod");
|
|
79
|
+
assert.strictEqual(config.clusters.prod.host, "cucm.example.com");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("adds insecure flag when specified", () => {
|
|
83
|
+
addCluster("lab2", { host: "lab.local", username: "u", password: "p", insecure: true });
|
|
84
|
+
const config = loadConfig();
|
|
85
|
+
assert.strictEqual(config.clusters.lab2.insecure, true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("does not overwrite activeCluster if one already set", () => {
|
|
89
|
+
const config = loadConfig();
|
|
90
|
+
assert.strictEqual(config.activeCluster, "prod");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("useCluster", () => {
|
|
95
|
+
it("switches the active cluster", () => {
|
|
96
|
+
useCluster("lab2");
|
|
97
|
+
const config = loadConfig();
|
|
98
|
+
assert.strictEqual(config.activeCluster, "lab2");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("throws for unknown cluster", () => {
|
|
102
|
+
assert.throws(() => useCluster("nonexistent"), /not found/);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("removeCluster", () => {
|
|
107
|
+
it("removes a cluster", () => {
|
|
108
|
+
removeCluster("lab2");
|
|
109
|
+
const config = loadConfig();
|
|
110
|
+
assert.strictEqual(config.clusters.lab2, undefined);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("reassigns activeCluster when removing the active one", () => {
|
|
114
|
+
saveConfig({ activeCluster: "a", clusters: { a: { host: "h", username: "u", password: "p" }, b: { host: "h2", username: "u2", password: "p2" } } });
|
|
115
|
+
removeCluster("a");
|
|
116
|
+
const config = loadConfig();
|
|
117
|
+
assert.strictEqual(config.activeCluster, "b");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("sets activeCluster to null when removing last cluster", () => {
|
|
121
|
+
removeCluster("b");
|
|
122
|
+
const config = loadConfig();
|
|
123
|
+
assert.strictEqual(config.activeCluster, null);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("throws for unknown cluster", () => {
|
|
127
|
+
assert.throws(() => removeCluster("nonexistent"), /not found/);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("getActiveCluster", () => {
|
|
132
|
+
it("returns null when no clusters configured", () => {
|
|
133
|
+
saveConfig({ activeCluster: null, clusters: {} });
|
|
134
|
+
assert.strictEqual(getActiveCluster(), null);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("returns the active cluster with name merged in", () => {
|
|
138
|
+
saveConfig({ activeCluster: "prod", clusters: { prod: { host: "h", username: "u", password: "p" } } });
|
|
139
|
+
const cluster = getActiveCluster();
|
|
140
|
+
assert.strictEqual(cluster.name, "prod");
|
|
141
|
+
assert.strictEqual(cluster.host, "h");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("returns a specific cluster when name is provided", () => {
|
|
145
|
+
saveConfig({ activeCluster: "prod", clusters: { prod: { host: "h1", username: "u", password: "p" }, lab: { host: "h2", username: "u", password: "p" } } });
|
|
146
|
+
const cluster = getActiveCluster("lab");
|
|
147
|
+
assert.strictEqual(cluster.name, "lab");
|
|
148
|
+
assert.strictEqual(cluster.host, "h2");
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("listClusters", () => {
|
|
153
|
+
it("returns activeCluster and clusters map", () => {
|
|
154
|
+
const result = listClusters();
|
|
155
|
+
assert.ok("activeCluster" in result);
|
|
156
|
+
assert.ok("clusters" in result);
|
|
157
|
+
assert.strictEqual(typeof result.clusters, "object");
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("maskPassword", () => {
|
|
162
|
+
it("masks a plain password with asterisks", () => {
|
|
163
|
+
assert.strictEqual(maskPassword("secret"), "******");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("returns empty string for falsy input", () => {
|
|
167
|
+
assert.strictEqual(maskPassword(""), "");
|
|
168
|
+
assert.strictEqual(maskPassword(null), "");
|
|
169
|
+
assert.strictEqual(maskPassword(undefined), "");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("preserves ss-cli placeholders unmasked", () => {
|
|
173
|
+
const placeholder = "<ss:123:password>";
|
|
174
|
+
assert.strictEqual(maskPassword(placeholder), placeholder);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("hasSsPlaceholders", () => {
|
|
179
|
+
it("detects <ss:ID:field> patterns", () => {
|
|
180
|
+
assert.strictEqual(hasSsPlaceholders({ password: "<ss:123:password>" }), true);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("returns false for plain strings", () => {
|
|
184
|
+
assert.strictEqual(hasSsPlaceholders({ password: "plaintext" }), false);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("returns false for empty object", () => {
|
|
188
|
+
assert.strictEqual(hasSsPlaceholders({}), false);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Cleanup
|
|
193
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
194
|
+
|
|
195
|
+
console.log(`\n config.test.js: ${total} tests, ${passed} passed, ${failed} failed`);
|
|
196
|
+
if (failed > 0) process.exit(1);
|