Files
Claude-Code-Monitor/mcp/build/clients/dashboard-api-client.js
T
nntrivi2001 7357070fb9 chore: remove unused desktop app, cloud deployment infra, and monitoring stack
Deletes desktop/ (Electron wrapper), deployments/ (Helm/Kustomize/
Terraform/CI for cloud deploy), and monitoring/ (Prometheus + Grafana
stack) along with DESKTOP.md, DEPLOYMENT.md, docker-compose.full.yml,
their npm scripts, and every dangling reference across README,
ARCHITECTURE, INSTALL, SETUP, docs/, and the repeated per-file
MODULE_GUIDE "Observability" boilerplate comment. The GET /api/metrics
endpoint itself is untouched — it's the dashboard's own route, not
part of the removed monitoring stack.
2026-08-11 12:16:54 +07:00

265 lines
12 KiB
JavaScript

/**
* @file dashboard-api-client.ts
* @description Client for making API requests to the MCP dashboard. This client provides methods for sending HTTP requests (GET, POST, PUT, PATCH, DELETE) to the dashboard's API endpoints, with built-in support for retries on transient errors, request timeouts, and error handling. The client constructs URLs based on a base URL from the configuration and allows for query parameters and request bodies. It also defines a custom ApiError class for consistent error representation across the application.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
*
* ## Design constraints
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
*
* ## Remote data & SSH
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Internal dependencies
* - `../config/app-config.js`
* - `../core/logger.js`
*
* ## Public surface
* - `ApiError` — exported API; see TSDoc on the symbol for behavior.
* - `DashboardApiClient` — exported API; see TSDoc on the symbol for behavior.
*
* ## Testing pointers
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
* - Server contract changes require `npm run test:server` and OpenAPI sync.
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
*
* ## Related docs
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
* - `docs/API.md` — REST reference.
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
* ============================================================================= */
/* -----------------------------------------------------------------------------
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
* -----------------------------------------------------------------------------
* **ApiError**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **DashboardApiClient**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* ----------------------------------------------------------------------------- */
import { setTimeout as sleep } from "node:timers/promises";
/**
* Error type for every failed dashboard API call — non-2xx responses,
* timeouts, and network failures all normalize to this shape.
* {@link errorResult} surfaces `code`/`status`/`details` to the MCP client
* instead of collapsing to a generic internal error.
*/
export class ApiError extends Error {
status;
/** Forwarded from the dashboard's error envelope, a synthesized
* `HTTP_<status>`, or this client's own code (`INVALID_PATH`, `TIMEOUT`,
* `REQUEST_FAILED`, `UNREACHABLE_STATE`). */
code;
details;
constructor(message, options = {}) {
super(message);
this.name = "ApiError";
this.status = options.status;
this.code = options.code;
this.details = options.details;
}
}
/** True for a DOM/Node `AbortError` from {@link DashboardApiClient.request}'s
* per-attempt timeout controller. */
function isAbortError(error) {
return (typeof error === "object" && error !== null && "name" in error && error.name === "AbortError");
}
/** Statuses treated as transient/retryable: 408, 429, or any 5xx. */
function isRetryableStatus(status) {
return status === 408 || status === 429 || status >= 500;
}
/**
* Thin HTTP client every MCP tool handler uses to reach the dashboard's
* local Express API — the sole network boundary of the server. Requests
* resolve against `config.dashboardBaseUrl` and are restricted to `/api/*`
* (see {@link buildUrl}).
*
* **Retry semantics**: only GET/DELETE mark themselves `idempotent`, so only
* they retry automatically — `config.retryCount` extra attempts (default 2)
* on a timeout or HTTP 408/429/5xx, each retry waiting
* `config.retryBackoffMs * 2^(attempt-1)` (default 250ms, 500ms, ...,
* exponential, no jitter). POST/PUT/PATCH are never retried, even for the
* same transient statuses — a duplicated write is worse than one surfaced
* failure.
*/
export class DashboardApiClient {
config;
logger;
constructor(config, logger) {
this.config = config;
this.logger = logger;
}
/** GET — idempotent, eligible for automatic retry. */
async get(path, options = {}) {
return this.request("GET", path, { ...options, idempotent: true });
}
/** POST — never retried; used for creates and mutation-gated actions. */
async post(path, options = {}) {
return this.request("POST", path, options);
}
/** PUT — full upsert semantics (e.g. pricing rules); never retried. */
async put(path, options = {}) {
return this.request("PUT", path, options);
}
/** PATCH — partial update; never retried. */
async patch(path, options = {}) {
return this.request("PATCH", path, options);
}
/** DELETE — idempotent, eligible for automatic retry. */
async delete(path, options = {}) {
return this.request("DELETE", path, options);
}
/**
* Resolves `path` against the dashboard base URL and applies query
* params, enforcing that only `/api/*` paths can ever be requested — a
* hard client-side allowlist independent of the dashboard's own routing.
* @throws {ApiError} code `INVALID_PATH` if the resolved pathname doesn't
* start with `/api/`.
*/
buildUrl(path, query) {
const url = new URL(path, this.config.dashboardBaseUrl);
if (!url.pathname.startsWith("/api/")) {
throw new ApiError(`Invalid path "${path}". MCP client can only call /api/* endpoints.`, {
code: "INVALID_PATH",
});
}
if (query) {
for (const [key, value] of Object.entries(query)) {
if (value !== undefined && value !== null) {
url.searchParams.set(key, String(value));
}
}
}
return url;
}
/**
* Core request implementation shared by all five methods. Each attempt
* gets its own {@link AbortController} armed with `config.requestTimeoutMs`
* and best-effort JSON-parses the response (see {@link tryParseJson}).
* `maxAttempts` is `config.retryCount + 1` when `options.idempotent`,
* else `1`. On error, {@link shouldRetry} decides whether to back off and
* loop or fall through to normalization: a non-ok response becomes an
* {@link ApiError} via {@link toApiError}; an abort becomes `TIMEOUT`; any
* other throw becomes `REQUEST_FAILED`.
* @throws {ApiError} on any non-2xx response, timeout, or network failure
* surviving the retry loop.
*/
async request(method, path, options) {
const maxAttempts = options.idempotent ? this.config.retryCount + 1 : 1;
const url = this.buildUrl(path, options.query);
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const abortController = new AbortController();
const timeout = setTimeout(() => abortController.abort(), this.config.requestTimeoutMs);
try {
const response = await fetch(url, {
method,
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: options.body === undefined ? undefined : JSON.stringify(options.body),
signal: abortController.signal,
});
const rawBody = await response.text();
const body = rawBody ? this.tryParseJson(rawBody) : null;
if (!response.ok) {
throw this.toApiError(method, url, response.status, body ?? rawBody);
}
return body;
}
catch (error) {
if (this.shouldRetry(error, attempt, maxAttempts)) {
const backoffMs = this.config.retryBackoffMs * Math.pow(2, attempt - 1);
this.logger.warn("Transient API error, retrying", {
method,
path: url.toString(),
attempt,
maxAttempts,
backoffMs,
error: this.getErrorMessage(error),
});
await sleep(backoffMs);
continue;
}
if (error instanceof ApiError) {
throw error;
}
if (isAbortError(error)) {
throw new ApiError(`Request timed out after ${this.config.requestTimeoutMs}ms: ${method} ${url.pathname}`, { code: "TIMEOUT" });
}
throw new ApiError(`Request failed: ${method} ${url.pathname}`, {
code: "REQUEST_FAILED",
details: this.getErrorMessage(error),
});
}
finally {
clearTimeout(timeout);
}
}
throw new ApiError("Unreachable request state", { code: "UNREACHABLE_STATE" });
}
/** Never retries on the last attempt; always retries an abort/timeout;
* for an {@link ApiError} with a status, retries only if
* {@link isRetryableStatus}; any other exception type is treated as
* transient too. */
shouldRetry(error, attempt, maxAttempts) {
if (attempt >= maxAttempts)
return false;
if (isAbortError(error))
return true;
if (error instanceof ApiError && error.status !== undefined) {
return isRetryableStatus(error.status);
}
return true;
}
/** Builds an {@link ApiError} from a non-ok response, preferring the
* dashboard's `{ error: { code, message } }` envelope when present,
* falling back to a generic `HTTP_<status>`. */
toApiError(method, url, status, body) {
const fallbackMessage = `${method} ${url.pathname} failed with HTTP ${status}`;
if (body &&
typeof body === "object" &&
"error" in body &&
body.error &&
typeof body.error === "object" &&
"message" in body.error) {
const maybeCode = "code" in body.error && typeof body.error.code === "string" ? body.error.code : undefined;
const maybeMessage = typeof body.error.message === "string" ? body.error.message : fallbackMessage;
return new ApiError(maybeMessage, { status, code: maybeCode, details: body });
}
return new ApiError(fallbackMessage, { status, code: `HTTP_${status}`, details: body });
}
/** Parses `input` as JSON, returning the raw string unchanged if invalid. */
tryParseJson(input) {
try {
return JSON.parse(input);
}
catch {
return input;
}
}
/** Normalizes any thrown value to a loggable string message. */
getErrorMessage(error) {
if (error instanceof Error)
return error.message;
if (typeof error === "string")
return error;
return "Unknown error";
}
}