From a14cf36675a9ee83653a5d8582cb8a041f6c02fb Mon Sep 17 00:00:00 2001 From: Dheeraj Kumar Date: Tue, 23 Jun 2026 02:53:41 +0530 Subject: [PATCH] feat: control-plane web dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Open http://localhost:4700/ in a browser for a live dashboard over the control plane: service grid (port, protocol, uptime, capability badges, connection string), request-log viewer, state inspector, and per-service + whole-fleet reset buttons. Auto-refreshes every 2s. - src/dashboard.mjs: a single self-contained HTML page (vanilla JS, zero deps, no build step, no CDN). Pure client of the existing JSON API — no new server behavior. - control-plane GET / content-negotiates: HTML for browsers (Accept: text/html), JSON for fetch/curl/SDKs (Accept: */*) so the existing API contract is unchanged. GET /api always returns JSON. - Tests: HTML served to browsers, JSON preserved for API clients, /api JSON. - docs/control-plane.md + README + CHANGELOG. Followed SKILL.md (plan/implement/test/docs/changelog/hygiene). Verified the exact data contract the dashboard renders against a live launcher. Full suite: 255 files / 5495 tests green on 3 consecutive runs; no leaked processes. --- CHANGELOG.md | 8 ++ README.md | 9 +- docs/control-plane.md | 19 +++- src/control-plane.mjs | 50 ++++++--- src/dashboard.mjs | 219 +++++++++++++++++++++++++++++++++++++ test/control-plane.test.ts | 24 +++- 6 files changed, 310 insertions(+), 19 deletions(-) create mode 100644 src/dashboard.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6eb4293..12092d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ All notable changes to Parlel are documented here. The format is based on ### Added +- **Control-plane dashboard.** Open `http://localhost:4700/` in a browser for a + live dashboard: a grid of every running service (port, protocol, uptime, + capability badges, connection string), a request-log viewer, a state inspector, + and per-service + whole-fleet reset buttons, auto-refreshing every 2s. A single + self-contained HTML page (vanilla JS, zero dependencies, no build step) served + by the control plane and backed entirely by the existing JSON API. `GET /` + content-negotiates (HTML for browsers, JSON for `fetch`/curl/SDKs); `GET /api` + always returns JSON. - **Seeding & fixtures.** Optional `seed(data)` emulator-contract method (graceful degrade when absent), exposed via the control plane at `POST /services/:slug/seed`. The launcher loads a declarative diff --git a/README.md b/README.md index a3a14c6..3e322c0 100644 --- a/README.md +++ b/README.md @@ -163,8 +163,13 @@ npm run probe # boot a set and health-check every service ## Control plane Alongside the emulators, Parlel runs an additive admin server on -`localhost:4700`. List what's running, inspect state, and — most usefully — reset -every service to a clean slate between tests without restarting anything: +`localhost:4700`. Open it **in a browser** for a live dashboard — every running +service, its connection string, the request log, a state inspector, and reset +buttons, auto-refreshing every 2 seconds. + +You can also drive it programmatically. List what's running, inspect state, and — +most usefully — reset every service to a clean slate between tests without +restarting anything: ```js beforeEach(() => fetch("http://127.0.0.1:4700/reset", { method: "POST" })); diff --git a/docs/control-plane.md b/docs/control-plane.md index fa24dba..f6a9469 100644 --- a/docs/control-plane.md +++ b/docs/control-plane.md @@ -24,10 +24,27 @@ your test harness ──▶ localhost:4700 ──▶ control plane (admin: lis If the control port is already in use, the launcher logs it and continues without the admin API — the emulators still run. +## Dashboard + +Open `http://localhost:4700/` **in a browser** for a live dashboard: a grid of +every running service (slug, port, protocol, uptime, capability badges, and a +copy-ready connection string), a request-log viewer, a state inspector, and +per-service + whole-fleet **Reset** buttons. It auto-refreshes every 2 seconds. + +The page is a single self-contained HTML file (vanilla JS, no build step, no CDN, +no dependencies) served by the control plane. It is a pure client of the JSON API +below — it adds no new server behavior. + +Content negotiation on `GET /`: browsers (`Accept: text/html`) get the dashboard; +programmatic clients (`fetch`/curl/SDKs, which send `Accept: */*`) get the JSON +index. Use `GET /api` to force JSON. + ## Endpoints ### `GET /` -Index — name, service count, and the endpoint list. +The HTML dashboard for browsers; the JSON API index for programmatic clients +(see content negotiation above). `GET /api` always returns JSON — name, service +count, and the endpoint list. ### `GET /healthz` Aggregate fleet health. diff --git a/src/control-plane.mjs b/src/control-plane.mjs index 44b692e..e2c84fa 100644 --- a/src/control-plane.mjs +++ b/src/control-plane.mjs @@ -19,6 +19,7 @@ // Pure Node built-ins only — same zero-dependency rule as the emulators. import { createServer } from "node:http"; +import { dashboardHtml } from "./dashboard.mjs"; const DEFAULT_PORT = 4700; @@ -93,6 +94,24 @@ export class ControlPlaneServer { return [...this.registry.keys()].sort().map((slug) => this.describe(slug)); } + apiIndex() { + return { + name: "parlel-control-plane", + services: this.registry.size, + dashboard: "GET / (in a browser)", + endpoints: [ + "GET /healthz", + "GET /services", + "GET /services/:slug", + "GET /services/:slug/state", + "GET /services/:slug/requests", + "POST /services/:slug/reset", + "POST /services/:slug/seed", + "POST /reset", + ], + }; + } + // ── routing ───────────────────────────────────────────────────────────────── async handle(req, res) { const url = new URL(req.url || "/", `http://${this.host}:${this.port}`); @@ -101,22 +120,17 @@ export class ControlPlaneServer { if (method === "OPTIONS") return this.send(res, 204, null); - // GET / + // GET / — serve the HTML dashboard to browsers (Accept: text/html), and the + // JSON API index to programmatic clients (fetch/curl/SDKs send Accept: */*). if (method === "GET" && parts.length === 0) { - return this.send(res, 200, { - name: "parlel-control-plane", - services: this.registry.size, - endpoints: [ - "GET /healthz", - "GET /services", - "GET /services/:slug", - "GET /services/:slug/state", - "GET /services/:slug/requests", - "POST /services/:slug/reset", - "POST /services/:slug/seed", - "POST /reset", - ], - }); + const accept = req.headers.accept || ""; + if (accept.includes("text/html")) return this.sendHtml(res, 200, dashboardHtml()); + return this.send(res, 200, this.apiIndex()); + } + + // GET /api — always the JSON index, regardless of Accept. + if (method === "GET" && parts[0] === "api" && parts.length === 1) { + return this.send(res, 200, this.apiIndex()); } // GET /healthz @@ -241,6 +255,12 @@ export class ControlPlaneServer { res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(body)); } + + sendHtml(res, status, html) { + res.statusCode = status; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(html); + } } // Read and JSON-parse a request body. Empty body → {}. diff --git a/src/dashboard.mjs b/src/dashboard.mjs new file mode 100644 index 0000000..9d85f7e --- /dev/null +++ b/src/dashboard.mjs @@ -0,0 +1,219 @@ +// Parlel — control-plane dashboard. +// +// A single self-contained HTML page (vanilla JS, no build step, no CDN, no +// dependencies) served by the control plane at GET / for browsers. It is a pure +// client of the existing control-plane JSON API (/services, /reset, +// /services/:slug/{reset,state,requests}) — it adds no new server behavior. +// +// Kept as one string so the control plane stays zero-dependency and there is no +// static-asset pipeline to maintain. + +export function dashboardHtml() { + return ` + + + + +Parlel — control plane + + + +
+ +

Parlel

+ connecting… + + + +
+ +
+
+ + +
+
+ +
+ + +
+
+
+
+ +
+ + + +`; +} diff --git a/test/control-plane.test.ts b/test/control-plane.test.ts index cad9b28..49549a5 100644 --- a/test/control-plane.test.ts +++ b/test/control-plane.test.ts @@ -52,15 +52,37 @@ afterAll(async () => { }); describe("control plane — discovery", () => { - it("GET / lists the API", async () => { + it("GET / returns the JSON API index to programmatic clients (Accept: */*)", async () => { + // fetch sends Accept: */* by default — must stay JSON so SDKs/curl are unaffected. const res = await fetch(`${base()}/`); expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("application/json"); const body = await res.json(); expect(body.name).toBe("parlel-control-plane"); expect(body.services).toBe(2); expect(Array.isArray(body.endpoints)).toBe(true); }); + it("GET / serves the HTML dashboard to browsers (Accept: text/html)", async () => { + const res = await fetch(`${base()}/`, { headers: { Accept: "text/html,*/*" } }); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/html"); + const html = await res.text(); + expect(html).toContain(""); + expect(html).toContain("Parlel"); + // The dashboard must reference the real control-plane endpoints it drives. + expect(html).toContain("/services"); + expect(html).toContain("/reset"); + }); + + it("GET /api always returns JSON regardless of Accept", async () => { + const res = await fetch(`${base()}/api`, { headers: { Accept: "text/html" } }); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("application/json"); + const body = await res.json(); + expect(body.name).toBe("parlel-control-plane"); + }); + it("GET /healthz reports the fleet", async () => { const res = await fetch(`${base()}/healthz`); expect(res.status).toBe(200);