Skip to content

Commit 3a03497

Browse files
feat: Unicode sparkline charts in counter_monitor stats
Counter monitoring results now include sparkline visualizations: CallsActive ▁▂▃▅▇█▇▅▃▁ min=0 max=47 avg=18.3 CallsCompleted ▁▂▃▄▅▆▆▇▇█ delta=238 rate=2.38/s - sparkline() function generates ▁▂▃▄▅▆▇█ from value arrays - Added to CounterStats interface (sparkline field) - README expanded with Usage Collection & Trending use cases - 7 sparkline tests, 150 total, 96% coverage
1 parent 5b9c8dd commit 3a03497

3 files changed

Lines changed: 89 additions & 25 deletions

File tree

README.md

Lines changed: 28 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,20 @@ An [MCP](https://modelcontextprotocol.io/) server that gives AI assistants direc
3232
- *"Are any CTI applications connected? What's their status?"*
3333
- *"Compare registration counts to what CUCM thinks is registered via PerfMon"*
3434

35+
### Usage Collection & Trending
36+
- *"Monitor call volume for the next 5 minutes and show me the trend"*
37+
- *"Track registration changes — are phones dropping off?"*
38+
- *"Collect CPU samples every 10 seconds and graph the usage"*
39+
- *"Start monitoring SIP trunk activity while I make test calls"*
40+
- *"How is MTP utilization trending during business hours?"*
41+
42+
Results include **Unicode sparklines** for visual trending:
43+
```
44+
CallsActive ▁▁▂▃▅▇█▇▅▃▂▁ min=0 max=47 avg=18.3
45+
CallsCompleted ▁▂▃▄▅▆▇█▇▇██ delta=234 rate=3.9/s
46+
RegisteredPhones ████████████▇ min=847 max=850 avg=849
47+
```
48+
3549
### Inventory & Reporting
3650
- *"How many phones are registered by model/firmware?"*
3751
- *"List all registered hardware phones and their IPs"*
@@ -367,36 +381,29 @@ Retrieve samples from a running or completed monitor with computed statistics pe
367381
{ "monitorId": "mon-1773846059140-5md4o5" }
368382
```
369383

370-
**Output (from live CUCM 15 cluster, 4 samples over 40 seconds):**
384+
**Output (includes Unicode sparklines for visual trending):**
371385

372386
```json
373387
{
374388
"monitorId": "mon-1773846059140-5md4o5",
375389
"status": "running",
376-
"samplesCollected": 4,
377-
"maxSamples": 10,
378-
"durationMs": 40433,
390+
"samplesCollected": 10,
391+
"maxSamples": 100,
392+
"durationMs": 100000,
379393
"stats": [
380394
{
381395
"name": "CallsActive",
382396
"type": "gauge",
383-
"min": 0, "max": 0, "avg": 0,
384-
"delta": 0, "rate": 0, "latest": 0,
385-
"timestamps": [1773846073224, 1773846083220, 1773846093271, 1773846103221]
386-
},
387-
{
388-
"name": "CallsAttempted",
389-
"type": "counter",
390-
"min": 0, "max": 0, "avg": 0,
391-
"delta": 0, "rate": 0, "latest": 0,
392-
"timestamps": [1773846073224, 1773846083220, 1773846093271, 1773846103221]
397+
"sparkline": "▁▂▃▅▇█▇▅▃▁",
398+
"min": 0, "max": 47, "avg": 18.3,
399+
"delta": 0, "rate": 0, "latest": 2
393400
},
394401
{
395402
"name": "CallsCompleted",
396403
"type": "counter",
397-
"min": 0, "max": 0, "avg": 0,
398-
"delta": 0, "rate": 0, "latest": 0,
399-
"timestamps": [1773846073224, 1773846083220, 1773846093271, 1773846103221]
404+
"sparkline": "▁▂▃▄▅▆▆▇▇█",
405+
"min": 1200, "max": 1438, "avg": 1319,
406+
"delta": 238, "rate": 2.38, "latest": 1438
400407
}
401408
]
402409
}
@@ -426,12 +433,11 @@ Stops a running monitor, closes the PerfMon session on CUCM, and returns final s
426433
{
427434
"monitorId": "mon-1773846059140-5md4o5",
428435
"status": "completed",
429-
"samplesCollected": 4,
430-
"durationMs": 45601,
436+
"samplesCollected": 10,
437+
"durationMs": 100000,
431438
"stats": [
432-
{ "name": "CallsActive", "type": "gauge", "min": 0, "max": 0, "avg": 0, "latest": 0 },
433-
{ "name": "CallsAttempted", "type": "counter", "delta": 0, "rate": 0, "latest": 0 },
434-
{ "name": "CallsCompleted", "type": "counter", "delta": 0, "rate": 0, "latest": 0 }
439+
{ "name": "CallsActive", "type": "gauge", "sparkline": "▁▂▃▅▇█▇▅▃▁", "min": 0, "max": 47, "avg": 18.3, "latest": 2 },
440+
{ "name": "CallsCompleted", "type": "counter", "sparkline": "▁▂▃▄▅▆▆▇▇█", "delta": 238, "rate": 2.38, "latest": 1438 }
435441
]
436442
}
437443
```

src/types/perfmon-types.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export interface CounterStats {
3838
type: CounterType;
3939
values: number[];
4040
timestamps: number[];
41+
sparkline: string;
4142
min: number;
4243
max: number;
4344
avg: number;
@@ -46,6 +47,21 @@ export interface CounterStats {
4647
latest: number;
4748
}
4849

50+
const SPARK_CHARS = "▁▂▃▄▅▆▇█";
51+
52+
/** Generate a Unicode sparkline from numeric values */
53+
export function sparkline(values: number[]): string {
54+
if (values.length === 0) return "";
55+
const min = Math.min(...values);
56+
const max = Math.max(...values);
57+
const range = max - min;
58+
if (range === 0) return SPARK_CHARS[0]!.repeat(values.length);
59+
return values.map(v => {
60+
const idx = Math.round(((v - min) / range) * (SPARK_CHARS.length - 1));
61+
return SPARK_CHARS[idx];
62+
}).join("");
63+
}
64+
4965
/** Known counter classifications for presets */
5066
export const GAUGE_COUNTERS = new Set([
5167
"CallsActive",
@@ -99,7 +115,7 @@ export function computeStats(samples: TimestampedSample[], counterName: string):
99115
}
100116

101117
if (values.length === 0) {
102-
return { name: counterName, type, values: [], timestamps: [], min: 0, max: 0, avg: 0, delta: 0, rate: 0, latest: 0 };
118+
return { name: counterName, type, values: [], timestamps: [], sparkline: "", min: 0, max: 0, avg: 0, delta: 0, rate: 0, latest: 0 };
103119
}
104120

105121
const min = Math.min(...values);
@@ -110,5 +126,5 @@ export function computeStats(samples: TimestampedSample[], counterName: string):
110126
const rate = durationSec > 0 ? delta / durationSec : 0;
111127
const latest = values[values.length - 1]!;
112128

113-
return { name: counterName, type, values, timestamps, min, max, avg, delta, rate, latest };
129+
return { name: counterName, type, values, timestamps, sparkline: sparkline(values), min, max, avg, delta, rate, latest };
114130
}

test/mcp-conformance.test.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, it, expect } from "vitest";
22
import { getTools } from "../src/tools/index.js";
3-
import { computeStats, classifyCounter, bareCounterName, COUNTER_PRESETS } from "../src/types/perfmon-types.js";
3+
import { computeStats, classifyCounter, bareCounterName, sparkline, COUNTER_PRESETS } from "../src/types/perfmon-types.js";
44
import type { TimestampedSample } from "../src/types/perfmon-types.js";
55

66
describe("MCP Conformance", () => {
@@ -124,6 +124,8 @@ describe("computeStats", () => {
124124
expect(stats.max).toBe(8);
125125
expect(stats.avg).toBeCloseTo(5.33, 1);
126126
expect(stats.latest).toBe(3);
127+
expect(stats.sparkline).toBeTruthy();
128+
expect(stats.sparkline.length).toBe(3);
127129
});
128130

129131
it("computes delta/rate for monotonic counters", () => {
@@ -154,5 +156,45 @@ describe("computeStats", () => {
154156
const stats = computeStats(samples, "NonExistentCounter");
155157
expect(stats.type).toBe("unknown");
156158
expect(stats.values).toHaveLength(0);
159+
expect(stats.sparkline).toBe("");
160+
});
161+
162+
it("includes sparkline in stats", () => {
163+
const stats = computeStats(samples, "CallsActive");
164+
// Values: 5, 8, 3 → normalized to mid, high, low → ▄█▁
165+
expect(stats.sparkline).toMatch(/[]{3}/);
166+
});
167+
});
168+
169+
describe("sparkline", () => {
170+
it("generates sparkline from values", () => {
171+
const result = sparkline([0, 1, 2, 3, 4, 5, 6, 7]);
172+
expect(result).toBe("▁▂▃▄▅▆▇█");
173+
});
174+
175+
it("handles flat values", () => {
176+
const result = sparkline([5, 5, 5, 5]);
177+
expect(result).toBe("▁▁▁▁");
178+
});
179+
180+
it("handles single value", () => {
181+
const result = sparkline([42]);
182+
expect(result).toBe("▁");
183+
});
184+
185+
it("handles empty array", () => {
186+
expect(sparkline([])).toBe("");
187+
});
188+
189+
it("handles ascending ramp", () => {
190+
const result = sparkline([0, 25, 50, 75, 100]);
191+
expect(result[0]).toBe("▁");
192+
expect(result[4]).toBe("█");
193+
expect(result.length).toBe(5);
194+
});
195+
196+
it("handles spike pattern", () => {
197+
const result = sparkline([0, 0, 100, 0, 0]);
198+
expect(result).toBe("▁▁█▁▁");
157199
});
158200
});

0 commit comments

Comments
 (0)