8a82895c65
Adds a root `ccam` plugin (`.claude-plugin/plugin.json`, `"source": "./"`) so
`/plugin marketplace add` + `/plugin install ccam@...` is enough on a machine
with nothing but Claude Code: no clone, no npm run setup, no manual npm start.
- scripts/plugin-bootstrap.js: SessionStart hook. Fast-path exit, Node >=22.5
gate (node:sqlite), mkdir lock with stale reclaim, deps installed into
~/.claude/agent-dashboard/runtime/ (never the plugin cache), legacy
checkout-hook cleanup (backed up), ~/.local/bin/ccam launcher, eager UI
build so client routes like /run work immediately, detached server spawn.
- scripts/plugin-open.js, scripts/plugin-doctor.js: /ccam-open, /ccam-doctor.
- server/index.js: DASHBOARD_CLIENT_DIST override (plugin cache is read-only).
- mcp/build/ is committed (plugin MCP servers start before any bootstrap could
build them) and kept honest by scripts/check-mcp-build.js (content hash,
not mtime), enforced by pre-commit when mcp/src changes.
- plugins/ccam-dashboard/.mcp.json moved under plugins/ccam/ with a working
${CLAUDE_PLUGIN_ROOT} path (the old relative path never resolved from a
marketplace-cached subdir).
- Docs: README, INSTALL, SETUP, ARCHITECTURE, CLAUDE.md, docs/PLUGINS.md,
docs/MCP.md, docs/CLI.md, docs/HOOKS.md.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
270 lines
12 KiB
JavaScript
270 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.
|
|
*
|
|
* ## Observability
|
|
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
*
|
|
* ## 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";
|
|
}
|
|
}
|