diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh index 9f2c9751..0afe1d9d 100755 --- a/scripts/docker-entrypoint.sh +++ b/scripts/docker-entrypoint.sh @@ -56,6 +56,11 @@ if [ -d /app/public-defaults ]; then echo "[phantom] Syncing public assets from image..." rm -rf /app/public/chat 2>/dev/null || true cp -r /app/public-defaults/chat /app/public/chat 2>/dev/null || true + if [ -d /app/public-defaults/dashboard ]; then + rm -rf /app/public/dashboard 2>/dev/null || true + cp -r /app/public-defaults/dashboard /app/public/dashboard 2>/dev/null || true + chown -R 999:999 /app/public/dashboard 2>/dev/null || true + fi for f in _base.html _components.html _agent-name.js index.html phantom-logo.svg; do [ -f "/app/public-defaults/$f" ] && cp "/app/public-defaults/$f" "/app/public/$f" done diff --git a/src/core/__tests__/server.test.ts b/src/core/__tests__/server.test.ts new file mode 100644 index 00000000..fdd11785 --- /dev/null +++ b/src/core/__tests__/server.test.ts @@ -0,0 +1,95 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import YAML from "yaml"; +import { hashTokenSync } from "../../mcp/config.ts"; +import type { McpConfig } from "../../mcp/types.ts"; +import { startServer } from "../server.ts"; + +/** + * End-to-end routing tests: bare-root redirect and /health content negotiation. + * A real Bun.serve instance is started on an ephemeral port so we exercise the + * same fetch pipeline production traffic sees. + */ +describe("server routing", () => { + const mcpConfigPath = "config/mcp.yaml"; + let originalMcpYaml: string | null = null; + let server: ReturnType; + let baseUrl: string; + + beforeAll(() => { + if (existsSync(mcpConfigPath)) { + originalMcpYaml = readFileSync(mcpConfigPath, "utf-8"); + } + const mcpConfig: McpConfig = { + tokens: [{ name: "admin", hash: hashTokenSync("test-admin"), scopes: ["read", "operator", "admin"] }], + rate_limit: { requests_per_minute: 60, burst: 10 }, + }; + mkdirSync("config", { recursive: true }); + writeFileSync(mcpConfigPath, YAML.stringify(mcpConfig), "utf-8"); + + server = startServer({ name: "phantom", port: 0, role: "base" } as never, Date.now()); + baseUrl = `http://localhost:${server.port}`; + }); + + afterAll(() => { + server?.stop(true); + if (originalMcpYaml !== null) { + writeFileSync(mcpConfigPath, originalMcpYaml, "utf-8"); + } + }); + + describe("GET /", () => { + test("redirects to /ui/ with 302", async () => { + const res = await fetch(`${baseUrl}/`, { redirect: "manual" }); + expect(res.status).toBe(302); + expect(res.headers.get("Location")).toBe("/ui/"); + }); + }); + + describe("GET /health", () => { + test("no Accept header returns JSON", async () => { + const res = await fetch(`${baseUrl}/health`); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toContain("application/json"); + const body = (await res.json()) as { agent: string; status: string }; + expect(body.agent).toBe("phantom"); + expect(body.status).toBe("ok"); + }); + + test("Accept: application/json returns JSON", async () => { + const res = await fetch(`${baseUrl}/health`, { headers: { Accept: "application/json" } }); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toContain("application/json"); + const body = (await res.json()) as { agent: string }; + expect(body.agent).toBe("phantom"); + }); + + test("Accept: text/html returns the HTML page", async () => { + const res = await fetch(`${baseUrl}/health`, { headers: { Accept: "text/html" } }); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toContain("text/html"); + const body = await res.text(); + expect(body).toContain(" { + const res = await fetch(`${baseUrl}/health`, { + headers: { Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" }, + }); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toContain("text/html"); + const body = await res.text(); + expect(body).toContain(" { + const res = await fetch(`${baseUrl}/health?format=json`, { headers: { Accept: "text/html" } }); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toContain("application/json"); + const body = (await res.json()) as { agent: string }; + expect(body.agent).toBe("phantom"); + }); + }); +}); diff --git a/src/core/health-page.ts b/src/core/health-page.ts new file mode 100644 index 00000000..abad4770 --- /dev/null +++ b/src/core/health-page.ts @@ -0,0 +1,497 @@ +import type { MemoryHealth } from "../memory/types.ts"; +import type { SchedulerHealthSummary } from "../scheduler/health.ts"; + +export type HealthPayload = { + status: string; + uptime: number; + version: string; + agent: string; + public_url?: string; + role: { id: string; name: string }; + channels: Record; + memory: MemoryHealth; + evolution: { generation: number }; + onboarding?: string; + peers?: Record; + scheduler?: SchedulerHealthSummary; +}; + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function humanUptime(seconds: number): string { + if (typeof seconds !== "number" || seconds < 0) return "-"; + const d = Math.floor(seconds / 86400); + const h = Math.floor((seconds % 86400) / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (d >= 1) return `${d}d ${h}h`; + if (h >= 1) return `${h}h ${m}m`; + return `${m}m`; +} + +function statusVariant(status: string): "success" | "warning" | "error" | "neutral" { + if (status === "ok") return "success"; + if (status === "degraded") return "warning"; + if (status === "down") return "error"; + return "neutral"; +} + +function memoryDot(up: boolean, configured: boolean): string { + if (!configured) return "neutral"; + return up ? "success" : "error"; +} + +function memoryLabel(up: boolean, configured: boolean): string { + if (!configured) return "not configured"; + return up ? "up" : "down"; +} + +function renderChannelChips(channels: Record): string { + const entries = Object.entries(channels); + if (entries.length === 0) { + return '

No channels configured.

'; + } + return entries + .map(([name, live]) => { + const cls = live ? "phantom-badge phantom-badge-success" : "phantom-badge"; + const label = live ? "live" : "off"; + const dot = live ? '' : ''; + return `${dot}${escapeHtml(name)}/${label}`; + }) + .join("\n"); +} + +function renderSchedulerCard(scheduler: SchedulerHealthSummary | undefined): string { + if (!scheduler) return ""; + const nextLabel = scheduler.nextFireAt ? escapeHtml(scheduler.nextFireAt) : "no scheduled runs"; + const failWarn = + scheduler.recentFailures > 0 + ? `${scheduler.recentFailures} recent failure${scheduler.recentFailures === 1 ? "" : "s"}` + : ""; + return ` +
+
+

Scheduler

+ ${failWarn} +
+
+
+

Active

+

${scheduler.active}

+

of ${scheduler.total} total

+
+
+

Paused

+

${scheduler.paused}

+

idle

+
+
+

Failed

+

${scheduler.failed}

+

lifetime

+
+
+

Next fire

+

${nextLabel}

+

UTC

+
+
+
`; +} + +function renderPeersCard(peers: HealthPayload["peers"]): string { + if (!peers || Object.keys(peers).length === 0) return ""; + const rows = Object.entries(peers) + .map(([name, info]) => { + const dot = info.healthy ? "success" : "error"; + const label = info.healthy ? "healthy" : "unreachable"; + return ` + +  ${escapeHtml(name)} + ${label} + ${info.healthy ? `${info.latencyMs}ms` : "-"} + `; + }) + .join(""); + return ` +
+

Peers

+ + + ${rows} +
NameStatusLatency
+
`; +} + +export function renderHealthHtml(payload: HealthPayload): string { + const agent = escapeHtml(payload.agent); + const agentTitle = escapeHtml(payload.agent.charAt(0).toUpperCase() + payload.agent.slice(1)); + const variant = statusVariant(payload.status); + const badgeCls = + variant === "success" + ? "phantom-badge phantom-badge-success" + : variant === "warning" + ? "phantom-badge phantom-badge-warning" + : variant === "error" + ? "phantom-badge phantom-badge-error" + : "phantom-badge"; + const badgeDot = + variant === "success" ? '' : ''; + const publicUrl = payload.public_url ? escapeHtml(payload.public_url) : ""; + const evolution = payload.evolution?.generation ?? 0; + const role = escapeHtml(payload.role?.name ?? payload.role?.id ?? ""); + + const qdrantDot = memoryDot(payload.memory.qdrant, payload.memory.configured); + const ollamaDot = memoryDot(payload.memory.ollama, payload.memory.configured); + + return ` + + + + +${agentTitle} status + + + + + + + + + + + +
+ +
+

Agent status

+

${agent} is ${escapeHtml(payload.status)}.

+

${publicUrl ? `Live at ${publicUrl}.` : "Running locally."} Auto-refreshing every 10 seconds.

+
+ +
+
+

Overview

+ + ${badgeDot} + ${escapeHtml(payload.status)} + +
+
+
+

Role

+

${role || "-"}

+

current

+
+
+

Uptime

+

${escapeHtml(humanUptime(payload.uptime))}

+

since boot

+
+
+

Version

+

${escapeHtml(payload.version)}

+

stable

+
+
+

Evolution

+

+ Gen ${evolution} +

+

current

+
+
+
+ +
+

Memory subsystems

+
+
+ +
+
Qdrant
+
${memoryLabel(payload.memory.qdrant, payload.memory.configured)}
+
+
+
+ +
+
Ollama
+
${memoryLabel(payload.memory.ollama, payload.memory.configured)}
+
+
+
+ +
+
Configured
+
${payload.memory.configured ? "yes" : "no"}
+
+
+
+
+ +
+

Channels

+
+ ${renderChannelChips(payload.channels)} +
+
+ + ${renderSchedulerCard(payload.scheduler)} + ${renderPeersCard(payload.peers)} + +
+

Quick links

+ +
+ +
+ +
+
+ Served by ${agent} + - +
+
+ + + +`; +} diff --git a/src/core/server.ts b/src/core/server.ts index 70483aa8..be06a95a 100644 --- a/src/core/server.ts +++ b/src/core/server.ts @@ -8,6 +8,7 @@ import type { PhantomMcpServer } from "../mcp/server.ts"; import type { MemoryHealth } from "../memory/types.ts"; import type { SchedulerHealthSummary } from "../scheduler/health.ts"; import { handleUiRequest } from "../ui/serve.ts"; +import { type HealthPayload, renderHealthHtml } from "./health-page.ts"; const VERSION = "0.19.1"; @@ -86,6 +87,14 @@ export function setChatHandler(handler: ChatHandler): void { let triggerAuth: AuthMiddleware | null = null; +// Content negotiation: return HTML only when the client accepts text/html. +// curl defaults to Accept: */* (no match), Docker healthcheck uses curl, MCP +// clients send application/json. Browsers lead with text/html. +function wantsHtml(acceptHeader: string | null): boolean { + if (!acceptHeader) return false; + return acceptHeader.toLowerCase().includes("text/html"); +} + export function startServer(config: PhantomConfig, startedAt: number): ReturnType { const mcpConfig = loadMcpConfig(); triggerAuth = new AuthMiddleware(mcpConfig); @@ -114,7 +123,7 @@ export function startServer(config: PhantomConfig, startedAt: number): ReturnTyp const peers = peerHealthProvider ? peerHealthProvider() : null; const scheduler = schedulerHealthProvider ? schedulerHealthProvider() : null; - return Response.json({ + const payload: HealthPayload = { status, uptime: Math.floor((Date.now() - startedAt) / 1000), version: VERSION, @@ -123,13 +132,22 @@ export function startServer(config: PhantomConfig, startedAt: number): ReturnTyp role: roleInfo ?? { id: config.role, name: config.role }, channels, memory, - evolution: { - generation: evolutionGeneration, - }, + evolution: { generation: evolutionGeneration }, ...(onboardingStatus ? { onboarding: onboardingStatus } : {}), ...(peers && Object.keys(peers).length > 0 ? { peers } : {}), ...(scheduler ? { scheduler } : {}), - }); + }; + + // ?format=json overrides content negotiation so the HTML page can + // re-fetch itself as JSON without juggling Accept headers. + const formatOverride = url.searchParams.get("format"); + if (formatOverride !== "json" && req.method === "GET" && wantsHtml(req.headers.get("Accept"))) { + return new Response(renderHealthHtml(payload), { + headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" }, + }); + } + + return Response.json(payload); } if (url.pathname === "/mcp") { @@ -168,6 +186,10 @@ export function startServer(config: PhantomConfig, startedAt: number): ReturnTyp return handleUiRequest(req); } + if (url.pathname === "/" || url.pathname === "") { + return Response.redirect("/ui/", 302); + } + return Response.json({ error: "Not found" }, { status: 404 }); }, }); diff --git a/src/ui/__tests__/serve.test.ts b/src/ui/__tests__/serve.test.ts index 71e4de43..90edbac4 100644 --- a/src/ui/__tests__/serve.test.ts +++ b/src/ui/__tests__/serve.test.ts @@ -181,6 +181,33 @@ describe("path traversal protection", () => { }); }); +describe("cache control", () => { + test("dashboard JS returns no-store cache control", async () => { + const { sessionToken } = createSession(); + const res = await handleUiRequest(req("/ui/dashboard/dashboard.js", { cookie: `phantom_session=${sessionToken}` })); + expect(res.status).toBe(200); + expect(res.headers.get("Cache-Control")).toBe("no-store, no-cache, must-revalidate"); + expect(res.headers.get("Pragma")).toBe("no-cache"); + expect(res.headers.get("Expires")).toBe("0"); + }); + + test("non-dashboard assets keep no-cache", async () => { + const { sessionToken } = createSession(); + const res = await handleUiRequest(req("/ui/index.html", { cookie: `phantom_session=${sessionToken}` })); + expect(res.status).toBe(200); + expect(res.headers.get("Cache-Control")).toBe("no-cache"); + }); + + test("dashboard non-JS assets keep no-cache", async () => { + const { sessionToken } = createSession(); + const res = await handleUiRequest( + req("/ui/dashboard/dashboard.css", { cookie: `phantom_session=${sessionToken}` }), + ); + expect(res.status).toBe(200); + expect(res.headers.get("Cache-Control")).toBe("no-cache"); + }); +}); + describe("SSE endpoint", () => { test("/ui/api/events requires auth", async () => { const res = await handleUiRequest(req("/ui/api/events", { headers: { Accept: "text/event-stream" } })); diff --git a/src/ui/serve.ts b/src/ui/serve.ts index 1edcbf26..33692bab 100644 --- a/src/ui/serve.ts +++ b/src/ui/serve.ts @@ -220,24 +220,38 @@ export async function handleUiRequest(req: Request): Promise { return new Response("Forbidden", { status: 403 }); } + const headers = buildStaticHeaders(url.pathname); + const file = Bun.file(filePath); if (await file.exists()) { - return new Response(file, { - headers: { "Cache-Control": "no-cache" }, - }); + return new Response(file, { headers }); } // Try index.html for directory-like paths const indexFile = Bun.file(resolve(filePath, "index.html")); if (await indexFile.exists()) { - return new Response(indexFile, { - headers: { "Cache-Control": "no-cache" }, - }); + return new Response(indexFile, { headers }); } return new Response("Not found", { status: 404 }); } +// Dashboard JS is image-owned and replaced on every deploy. no-store forbids +// browser caching so a new deploy reaches every session on the next navigation +// without a hard refresh. Other assets keep the existing revalidate-before-use +// no-cache policy. +function buildStaticHeaders(pathname: string): Record { + const isDashboardJs = pathname.startsWith("/ui/dashboard/") && pathname.endsWith(".js"); + if (isDashboardJs) { + return { + "Cache-Control": "no-store, no-cache, must-revalidate", + Pragma: "no-cache", + Expires: "0", + }; + } + return { "Cache-Control": "no-cache" }; +} + function handleSecretFormGet(_req: Request, url: URL, requestId: string): Response { if (!secretsDb) { return Response.json({ error: "Secrets not configured" }, { status: 500 });