The two-tool architecture previously had both tools returning identical `SystemStats` data. This refactors them to properly separate concerns: - `get-system-info` (Model-visible): Returns static system configuration (hostname, platform, CPU model/count, total memory) sent once via `ontoolresult` - `poll-system-stats` (App-only): Returns dynamic metrics (per-core CPU timing, memory usage, uptime) polled every 2 seconds Moves `formatBytes()` and `formatUptime()` utilities to the client since the server now returns raw values for client-side formatting. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
458 lines
13 KiB
TypeScript
458 lines
13 KiB
TypeScript
/**
|
|
* @file System Monitor App - displays real-time OS metrics with Chart.js
|
|
*/
|
|
import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps";
|
|
import { Chart, registerables } from "chart.js";
|
|
import "./global.css";
|
|
import "./mcp-app.css";
|
|
|
|
// Register Chart.js components
|
|
Chart.register(...registerables);
|
|
|
|
// =============================================================================
|
|
// Types
|
|
// =============================================================================
|
|
|
|
// Static system info (received once via ontoolresult from LLM-facing tool)
|
|
interface SystemInfo {
|
|
hostname: string;
|
|
platform: string;
|
|
arch: string;
|
|
cpu: {
|
|
model: string;
|
|
count: number;
|
|
};
|
|
memory: {
|
|
totalBytes: number;
|
|
};
|
|
}
|
|
|
|
// Dynamic polling data (received repeatedly via poll-system-stats)
|
|
interface PollStats {
|
|
cpu: {
|
|
cores: Array<{ idle: number; total: number }>;
|
|
};
|
|
memory: {
|
|
usedBytes: number;
|
|
usedPercent: number;
|
|
freeBytes: number;
|
|
};
|
|
uptime: {
|
|
seconds: number;
|
|
};
|
|
timestamp: string;
|
|
}
|
|
|
|
interface AppState {
|
|
// Static info (set once via ontoolresult)
|
|
systemInfo: SystemInfo | null;
|
|
// Polling state
|
|
isPolling: boolean;
|
|
intervalId: number | null;
|
|
cpuHistory: number[][]; // [timestamp][coreIndex] = usage%
|
|
labels: string[];
|
|
chart: Chart | null;
|
|
previousCpuSnapshots: Array<{ idle: number; total: number }> | null;
|
|
}
|
|
|
|
// =============================================================================
|
|
// DOM References
|
|
// =============================================================================
|
|
|
|
const mainEl = document.querySelector(".main") as HTMLElement;
|
|
const pollToggleBtn = document.getElementById("poll-toggle-btn")!;
|
|
const statusIndicator = document.getElementById("status-indicator")!;
|
|
const statusText = document.getElementById("status-text")!;
|
|
const cpuChartCanvas = document.getElementById(
|
|
"cpu-chart",
|
|
) as HTMLCanvasElement;
|
|
const memoryBarFill = document.getElementById("memory-bar-fill")!;
|
|
const memoryPercent = document.getElementById("memory-percent")!;
|
|
const memoryDetail = document.getElementById("memory-detail")!;
|
|
const infoHostname = document.getElementById("info-hostname")!;
|
|
const infoPlatform = document.getElementById("info-platform")!;
|
|
const infoUptime = document.getElementById("info-uptime")!;
|
|
|
|
// =============================================================================
|
|
// Constants & State
|
|
// =============================================================================
|
|
|
|
const HISTORY_LENGTH = 30;
|
|
const POLL_INTERVAL = 2000;
|
|
|
|
// Color palette for CPU cores (distinct colors)
|
|
const CORE_COLORS = [
|
|
"rgba(59, 130, 246, 0.7)", // blue
|
|
"rgba(16, 185, 129, 0.7)", // green
|
|
"rgba(245, 158, 11, 0.7)", // amber
|
|
"rgba(239, 68, 68, 0.7)", // red
|
|
"rgba(139, 92, 246, 0.7)", // purple
|
|
"rgba(236, 72, 153, 0.7)", // pink
|
|
"rgba(20, 184, 166, 0.7)", // teal
|
|
"rgba(249, 115, 22, 0.7)", // orange
|
|
"rgba(34, 197, 94, 0.7)", // emerald
|
|
"rgba(168, 85, 247, 0.7)", // violet
|
|
"rgba(251, 146, 60, 0.7)", // orange-light
|
|
"rgba(74, 222, 128, 0.7)", // green-light
|
|
"rgba(96, 165, 250, 0.7)", // blue-light
|
|
"rgba(248, 113, 113, 0.7)", // red-light
|
|
"rgba(167, 139, 250, 0.7)", // purple-light
|
|
"rgba(244, 114, 182, 0.7)", // pink-light
|
|
];
|
|
|
|
const state: AppState = {
|
|
systemInfo: null,
|
|
isPolling: false,
|
|
intervalId: null,
|
|
cpuHistory: [],
|
|
labels: [],
|
|
chart: null,
|
|
previousCpuSnapshots: null,
|
|
};
|
|
|
|
// =============================================================================
|
|
// Formatting Utilities
|
|
// =============================================================================
|
|
|
|
function formatBytes(bytes: number): string {
|
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
let value = bytes;
|
|
let unitIndex = 0;
|
|
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
value /= 1024;
|
|
unitIndex++;
|
|
}
|
|
return `${value.toFixed(1)} ${units[unitIndex]}`;
|
|
}
|
|
|
|
function formatUptime(seconds: number): string {
|
|
const days = Math.floor(seconds / 86400);
|
|
const hours = Math.floor((seconds % 86400) / 3600);
|
|
const minutes = Math.floor((seconds % 3600) / 60);
|
|
const parts: string[] = [];
|
|
if (days > 0) parts.push(`${days}d`);
|
|
if (hours > 0) parts.push(`${hours}h`);
|
|
if (minutes > 0) parts.push(`${minutes}m`);
|
|
return parts.length > 0 ? parts.join(" ") : "< 1m";
|
|
}
|
|
|
|
// =============================================================================
|
|
// Chart.js Setup
|
|
// =============================================================================
|
|
|
|
function initChart(coreCount: number): Chart {
|
|
const isDarkMode = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
const textColor = isDarkMode ? "#9ca3af" : "#6b7280";
|
|
const gridColor = isDarkMode ? "#374151" : "#e5e7eb";
|
|
|
|
const datasets = Array.from({ length: coreCount }, (_, i) => ({
|
|
label: `P${i}`,
|
|
data: [] as number[],
|
|
fill: true,
|
|
backgroundColor: CORE_COLORS[i % CORE_COLORS.length],
|
|
borderColor: CORE_COLORS[i % CORE_COLORS.length].replace("0.7", "1"),
|
|
borderWidth: 1,
|
|
pointRadius: 0,
|
|
tension: 0.3,
|
|
}));
|
|
|
|
return new Chart(cpuChartCanvas, {
|
|
type: "line",
|
|
data: {
|
|
labels: [],
|
|
datasets,
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
animation: {
|
|
duration: 300,
|
|
},
|
|
interaction: {
|
|
intersect: false,
|
|
mode: "index",
|
|
},
|
|
plugins: {
|
|
legend: {
|
|
display: true,
|
|
position: "bottom",
|
|
labels: {
|
|
boxWidth: 12,
|
|
padding: 8,
|
|
font: { size: 10 },
|
|
color: textColor,
|
|
},
|
|
},
|
|
tooltip: {
|
|
enabled: true,
|
|
callbacks: {
|
|
label: (context) =>
|
|
`${context.dataset.label}: ${context.parsed.y}%`,
|
|
},
|
|
},
|
|
},
|
|
scales: {
|
|
x: {
|
|
display: false,
|
|
},
|
|
y: {
|
|
stacked: true,
|
|
min: 0,
|
|
max: coreCount * 100,
|
|
ticks: {
|
|
callback: (value) => `${value}%`,
|
|
color: textColor,
|
|
font: { size: 10 },
|
|
},
|
|
grid: {
|
|
color: gridColor,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
function updateChart(
|
|
cpuHistory: number[][],
|
|
labels: string[],
|
|
coreCount: number,
|
|
): void {
|
|
if (!state.chart) return;
|
|
|
|
state.chart.data.labels = labels;
|
|
|
|
// Transpose: cpuHistory[time][core] -> datasets[core].data[time]
|
|
for (let coreIdx = 0; coreIdx < coreCount; coreIdx++) {
|
|
state.chart.data.datasets[coreIdx].data = cpuHistory.map(
|
|
(snapshot) => snapshot[coreIdx] ?? 0,
|
|
);
|
|
}
|
|
|
|
// Dynamic y-axis scaling
|
|
// Calculate max stacked value (sum of all cores at each time point)
|
|
const stackedTotals = cpuHistory.map((snapshot) =>
|
|
snapshot.reduce((sum, val) => sum + val, 0),
|
|
);
|
|
const currentMax = Math.max(...stackedTotals, 0);
|
|
|
|
// Add 20% headroom, clamp to reasonable bounds
|
|
const headroom = 1.2;
|
|
const minVisible = coreCount * 15; // At least 15% per core visible
|
|
const absoluteMax = coreCount * 100;
|
|
|
|
const dynamicMax = Math.min(
|
|
Math.max(currentMax * headroom, minVisible),
|
|
absoluteMax,
|
|
);
|
|
|
|
state.chart.options.scales!.y!.max = dynamicMax;
|
|
|
|
state.chart.update("none");
|
|
}
|
|
|
|
// =============================================================================
|
|
// UI Updates
|
|
// =============================================================================
|
|
|
|
function updateMemoryBar(
|
|
memory: PollStats["memory"],
|
|
totalBytes: number,
|
|
): void {
|
|
const percent = memory.usedPercent;
|
|
|
|
memoryBarFill.style.width = `${percent}%`;
|
|
memoryBarFill.classList.remove("warning", "danger");
|
|
|
|
if (percent >= 80) {
|
|
memoryBarFill.classList.add("danger");
|
|
} else if (percent >= 60) {
|
|
memoryBarFill.classList.add("warning");
|
|
}
|
|
|
|
memoryPercent.textContent = `${percent}%`;
|
|
memoryDetail.textContent = `${formatBytes(memory.usedBytes)} / ${formatBytes(totalBytes)}`;
|
|
}
|
|
|
|
// Called once when static system info is received
|
|
function updateStaticSystemInfo(info: SystemInfo): void {
|
|
infoHostname.textContent = info.hostname;
|
|
infoPlatform.textContent = info.platform;
|
|
}
|
|
|
|
// Called on each poll for dynamic uptime
|
|
function updateDynamicUptime(uptime: PollStats["uptime"]): void {
|
|
infoUptime.textContent = formatUptime(uptime.seconds);
|
|
}
|
|
|
|
function updateStatus(text: string, isPolling = false, isError = false): void {
|
|
statusText.textContent = text;
|
|
statusIndicator.classList.remove("polling", "error");
|
|
|
|
if (isError) {
|
|
statusIndicator.classList.add("error");
|
|
} else if (isPolling) {
|
|
statusIndicator.classList.add("polling");
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// MCP App & Data Fetching
|
|
// =============================================================================
|
|
|
|
const app = new App({ name: "System Monitor", version: "1.0.0" });
|
|
|
|
function calculateCpuUsage(
|
|
current: Array<{ idle: number; total: number }>,
|
|
previous: Array<{ idle: number; total: number }> | null,
|
|
): number[] {
|
|
if (!previous || previous.length !== current.length) {
|
|
return current.map(() => 0);
|
|
}
|
|
return current.map((cur, i) => {
|
|
const prev = previous[i];
|
|
const idleDiff = cur.idle - prev.idle;
|
|
const totalDiff = cur.total - prev.total;
|
|
if (totalDiff === 0) return 0;
|
|
return Math.round((1 - idleDiff / totalDiff) * 100);
|
|
});
|
|
}
|
|
|
|
async function fetchStats(): Promise<void> {
|
|
// systemInfo is guaranteed to exist (polling starts after ontoolresult)
|
|
const { systemInfo } = state;
|
|
if (!systemInfo) return;
|
|
|
|
try {
|
|
const result = await app.callServerTool({
|
|
name: "poll-system-stats", // App-only tool for polling dynamic data
|
|
arguments: {},
|
|
});
|
|
|
|
const stats = result.structuredContent as unknown as PollStats;
|
|
const coreCount = systemInfo.cpu.count;
|
|
|
|
// Calculate CPU usage from raw timing data (client-side)
|
|
const coreUsages = calculateCpuUsage(
|
|
stats.cpu.cores,
|
|
state.previousCpuSnapshots,
|
|
);
|
|
state.previousCpuSnapshots = stats.cpu.cores;
|
|
state.cpuHistory.push(coreUsages);
|
|
state.labels.push(new Date().toLocaleTimeString());
|
|
|
|
// Trim to window size
|
|
if (state.cpuHistory.length > HISTORY_LENGTH) {
|
|
state.cpuHistory.shift();
|
|
state.labels.shift();
|
|
}
|
|
|
|
// Update UI with dynamic data
|
|
updateChart(state.cpuHistory, state.labels, coreCount);
|
|
updateMemoryBar(stats.memory, systemInfo.memory.totalBytes);
|
|
updateDynamicUptime(stats.uptime);
|
|
|
|
const time = new Date().toLocaleTimeString("en-US", { hour12: false });
|
|
updateStatus(time, true);
|
|
} catch (error) {
|
|
console.error("Failed to fetch stats:", error);
|
|
updateStatus("Error", false, true);
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Polling Control
|
|
// =============================================================================
|
|
|
|
function startPolling(): void {
|
|
if (state.isPolling) return;
|
|
|
|
state.isPolling = true;
|
|
pollToggleBtn.textContent = "Stop";
|
|
pollToggleBtn.classList.add("active");
|
|
updateStatus("Starting...", true);
|
|
|
|
// Immediate first fetch
|
|
fetchStats();
|
|
|
|
// Start interval
|
|
state.intervalId = window.setInterval(fetchStats, POLL_INTERVAL);
|
|
}
|
|
|
|
function stopPolling(): void {
|
|
if (!state.isPolling) return;
|
|
|
|
state.isPolling = false;
|
|
if (state.intervalId) {
|
|
clearInterval(state.intervalId);
|
|
state.intervalId = null;
|
|
}
|
|
|
|
pollToggleBtn.textContent = "Start";
|
|
pollToggleBtn.classList.remove("active");
|
|
updateStatus("Stopped");
|
|
}
|
|
|
|
function togglePolling(): void {
|
|
if (state.isPolling) {
|
|
stopPolling();
|
|
} else {
|
|
startPolling();
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Event Handlers & Initialization
|
|
// =============================================================================
|
|
|
|
pollToggleBtn.addEventListener("click", togglePolling);
|
|
|
|
// Handle theme changes
|
|
window
|
|
.matchMedia("(prefers-color-scheme: dark)")
|
|
.addEventListener("change", () => {
|
|
if (state.chart && state.systemInfo) {
|
|
const coreCount = state.systemInfo.cpu.count;
|
|
state.chart.destroy();
|
|
state.chart = initChart(coreCount);
|
|
updateChart(state.cpuHistory, state.labels, coreCount);
|
|
}
|
|
});
|
|
|
|
app.onerror = console.error;
|
|
|
|
// Receive static system info when host sends the initial tool result
|
|
app.ontoolresult = (result) => {
|
|
const info = result.structuredContent as unknown as SystemInfo;
|
|
|
|
if (info) {
|
|
state.systemInfo = info;
|
|
|
|
// Initialize chart with CPU count from static info
|
|
state.chart = initChart(info.cpu.count);
|
|
|
|
// Update static UI elements (hostname, platform)
|
|
updateStaticSystemInfo(info);
|
|
|
|
// Start polling after receiving static info
|
|
startPolling();
|
|
}
|
|
};
|
|
|
|
function handleHostContextChanged(ctx: McpUiHostContext) {
|
|
if (ctx.safeAreaInsets) {
|
|
mainEl.style.paddingTop = `${ctx.safeAreaInsets.top}px`;
|
|
mainEl.style.paddingRight = `${ctx.safeAreaInsets.right}px`;
|
|
mainEl.style.paddingBottom = `${ctx.safeAreaInsets.bottom}px`;
|
|
mainEl.style.paddingLeft = `${ctx.safeAreaInsets.left}px`;
|
|
}
|
|
}
|
|
|
|
app.onhostcontextchanged = handleHostContextChanged;
|
|
|
|
app.connect().then(() => {
|
|
const ctx = app.getHostContext();
|
|
if (ctx) {
|
|
handleHostContextChanged(ctx);
|
|
}
|
|
});
|