From 765a75207ab162a679ccab0d9157e21bfde21896 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Fri, 26 Jun 2026 14:47:01 -0700 Subject: [PATCH] Add experimental server info debug view (#8676) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The dev/server UI exposes a `/api/health` endpoint and a lower-left account drawer, but nothing surfaces *which* build the running instance is on or when it last restarted > - When iterating on a local dev instance it is hard to tell whether the server you're looking at has actually restarted onto your latest commit, or how stale the running process is > - Developers need a lightweight, opt-in way to confirm the running instance's identity without digging through logs or shelling into the host > - This pull request adds an experimental "Server Info Debug View" setting that surfaces the running instance's last-restart time and current commit as read-only rows in the account drawer > - The benefit is a quick, in-UI sanity check of what the live server is actually running, behind an experimental flag so it ships zero cost to users who don't opt in ## Linked Issues or Issue Description No public GitHub issue exists. Describing the underlying request inline following the feature request template: **Problem or motivation:** When working against a local Paperclip dev instance there is no in-UI way to confirm what the running server is — its current commit or when it last restarted. You have to check logs or the host shell to know whether the process picked up your latest build. **Proposed solution:** An opt-in experimental setting ("Server Info Debug View") that, once enabled, renders a small read-only "Server" section at the bottom of the lower-left account drawer showing **Last restarted** (the server process start time) and **Running commit** (the current git HEAD short SHA + subject). **Alternatives considered:** A separate top-right pill/overlay (like the work-life-balance plugin). The account drawer was chosen to reuse existing menu-row styling and avoid adding new always-present chrome. **Roadmap alignment:** Small, self-contained developer-experience aid gated behind an experimental flag; does not overlap planned core roadmap work. ## What Changed - Added `server/src/server-info.ts`: captures a `serverInfo` snapshot once at boot — process start time and current git commit (SHA + subject). Git is read via `execFileSync` with SHA validation and a timeout. - `/api/health` exposes the `serverInfo` snapshot, but only on full-details health responses (board/agent in authenticated mode, or local-trusted dev). - Gated the UI surface behind a new `enableServerInfoDebugView` experimental setting, wired through the shared instance type, validator, settings normalizer, and OpenAPI schema. - UI: added `SidebarServerInfo` rendering the read-only rows in the account drawer (`BreadcrumbBar` / `SidebarAccountMenu`), plus the experimental settings toggle and a typed `health` API client. - Moved `ServerGitInfo` / `ServerInfoSnapshot` into `@paperclipai/shared` so the server and UI share one definition instead of duplicating it. - Added unit tests for the server-info snapshot, health route exposure, validator/normalizer, settings routes, the experimental settings page, and the sidebar component. ## Verification - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/health.test.ts src/__tests__/server-info.test.ts src/__tests__/instance-settings-service.test.ts src/__tests__/instance-settings-routes.test.ts` — 32 passed - `pnpm --filter @paperclipai/ui exec vitest run src/components/SidebarServerInfo.test.tsx src/pages/InstanceExperimentalSettings.test.tsx` — 9 passed - `tsc --noEmit` on both `@paperclipai/server` and `@paperclipai/ui` — clean - Manual: enable **Settings → Experimental → Server Info Debug View**, refresh the UI, open the lower-left account drawer — a "Server" section shows Last restarted and Running commit. ## Risks - Low risk. The UI surface is fully opt-in via an experimental flag and defaults off. - The `serverInfo` field on `/api/health` is access-controlled to full-details responses only (board/agent in authenticated mode, or local-trusted dev) — never anonymous authenticated callers — so the git SHA is not broadly exposed. - The only new server work is a one-time git read at boot, guarded with SHA validation and a timeout; failures degrade gracefully (the git block reports `available: false` rather than throwing). ## Model Used Claude — `claude-opus-4` (Anthropic), extended thinking with tool use, via Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change and contains no instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- packages/shared/src/index.ts | 2 + packages/shared/src/types/instance.ts | 1 + packages/shared/src/types/server-info.ts | 19 +++ .../shared/src/validators/instance.test.ts | 23 +++ packages/shared/src/validators/instance.ts | 1 + server/src/__tests__/health.test.ts | 61 +++++++- .../instance-settings-routes.test.ts | 23 +++ .../instance-settings-service.test.ts | 10 ++ server/src/__tests__/server-info.test.ts | 45 ++++++ server/src/routes/health.ts | 14 +- server/src/routes/openapi.ts | 16 ++ server/src/server-info.ts | 62 ++++++++ server/src/services/instance-settings.ts | 2 + ui/src/api/health.ts | 3 + ui/src/components/BreadcrumbBar.tsx | 15 +- ui/src/components/SidebarAccountMenu.tsx | 2 + ui/src/components/SidebarServerInfo.test.tsx | 139 ++++++++++++++++++ ui/src/components/SidebarServerInfo.tsx | 105 +++++++++++++ .../InstanceExperimentalSettings.test.tsx | 25 ++++ ui/src/pages/InstanceExperimentalSettings.tsx | 22 +++ 20 files changed, 577 insertions(+), 13 deletions(-) create mode 100644 packages/shared/src/types/server-info.ts create mode 100644 packages/shared/src/validators/instance.test.ts create mode 100644 server/src/__tests__/server-info.test.ts create mode 100644 server/src/server-info.ts create mode 100644 ui/src/components/SidebarServerInfo.test.tsx create mode 100644 ui/src/components/SidebarServerInfo.tsx diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 07f4dd4256..8e5b53d5ae 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -976,6 +976,8 @@ export type { CloudUpstreamWarning, } from "./types/cloud-upstream.js"; +export type { ServerGitInfo, ServerInfoSnapshot } from "./types/server-info.js"; + export { getClosedIsolatedExecutionWorkspaceMessage, isClosedIsolatedExecutionWorkspace, diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts index 5013a81c68..12ad91437a 100644 --- a/packages/shared/src/types/instance.ts +++ b/packages/shared/src/types/instance.ts @@ -55,6 +55,7 @@ export interface InstanceExperimentalSettings { enableExperimentalFileViewer: boolean; enableCloudSync: boolean; enableExternalObjects: boolean; + enableServerInfoDebugView: boolean; autoRestartDevServerWhenIdle: boolean; enableIssueGraphLivenessAutoRecovery: boolean; issueGraphLivenessAutoRecoveryLookbackHours: number; diff --git a/packages/shared/src/types/server-info.ts b/packages/shared/src/types/server-info.ts new file mode 100644 index 0000000000..add47f1fda --- /dev/null +++ b/packages/shared/src/types/server-info.ts @@ -0,0 +1,19 @@ +// Shared between the server (which produces the snapshot at boot) and the UI +// (which renders it), so both sides stay in sync on a single definition. +export type ServerGitInfo = + | { + available: true; + fullSha: string; + shortSha: string; + subject: string; + committedAt: string | null; + } + | { + available: false; + unavailableReason: "git_unavailable" | "invalid_git_metadata"; + }; + +export interface ServerInfoSnapshot { + processStartedAt: string; + git: ServerGitInfo; +} diff --git a/packages/shared/src/validators/instance.test.ts b/packages/shared/src/validators/instance.test.ts new file mode 100644 index 0000000000..de84c6f7fa --- /dev/null +++ b/packages/shared/src/validators/instance.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { + instanceExperimentalSettingsSchema, + patchInstanceExperimentalSettingsSchema, +} from "./instance.js"; + +describe("instance experimental settings validators", () => { + it("defaults the server info debug view off", () => { + const settings = instanceExperimentalSettingsSchema.parse({}); + + expect(settings.enableServerInfoDebugView).toBe(false); + }); + + it("accepts server info debug view patches", () => { + expect( + patchInstanceExperimentalSettingsSchema.parse({ + enableServerInfoDebugView: true, + }), + ).toEqual({ + enableServerInfoDebugView: true, + }); + }); +}); diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index e7bd518390..4831e7efa7 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -49,6 +49,7 @@ export const instanceExperimentalSettingsSchema = z.object({ enableExperimentalFileViewer: z.boolean().default(false), enableCloudSync: z.boolean().default(false), enableExternalObjects: z.boolean().default(false), + enableServerInfoDebugView: z.boolean().default(false), autoRestartDevServerWhenIdle: z.boolean().default(false), enableIssueGraphLivenessAutoRecovery: z.boolean().default(false), issueGraphLivenessAutoRecoveryLookbackHours: z diff --git a/server/src/__tests__/health.test.ts b/server/src/__tests__/health.test.ts index 66bf9a7749..42b3705772 100644 --- a/server/src/__tests__/health.test.ts +++ b/server/src/__tests__/health.test.ts @@ -7,15 +7,34 @@ import * as devServerStatus from "../dev-server-status.js"; import { serverVersion } from "../version.js"; const mockReadPersistedDevServerStatus = vi.hoisted(() => vi.fn()); +const testServerInfo = { + processStartedAt: "2026-06-26T00:00:00.000Z", + git: { + available: true, + fullSha: "0123456789abcdef0123456789abcdef01234567", + shortSha: "0123456", + subject: "Add server info debug view", + committedAt: "2026-06-25T23:00:00.000Z", + }, +} as const; vi.mock("../dev-server-status.js", () => ({ readPersistedDevServerStatus: mockReadPersistedDevServerStatus, toDevServerHealthStatus: vi.fn(), })); -function createApp(db?: Db) { +function createApp(db?: Db, serverInfo = testServerInfo) { const app = express(); - app.use("/health", healthRoutes(db)); + app.use( + "/health", + healthRoutes(db, { + deploymentMode: "local_trusted", + deploymentExposure: "private", + authReady: true, + companyDeletionEnabled: true, + serverInfo, + }), + ); return app; } @@ -32,7 +51,7 @@ describe("GET /health", () => { const app = createApp(); const res = await request(app).get("/health"); expect(res.status).toBe(200); - expect(res.body).toEqual({ status: "ok", version: serverVersion }); + expect(res.body).toEqual({ status: "ok", version: serverVersion, serverInfo: testServerInfo }); }, 15_000); it("returns 200 when the database probe succeeds", async () => { @@ -45,7 +64,11 @@ describe("GET /health", () => { expect(res.status).toBe(200); expect(db.execute).toHaveBeenCalledTimes(1); - expect(res.body).toMatchObject({ status: "ok", version: serverVersion }); + expect(res.body).toMatchObject({ + status: "ok", + version: serverVersion, + serverInfo: testServerInfo, + }); }); it("returns 503 when the database probe fails", async () => { @@ -60,7 +83,29 @@ describe("GET /health", () => { expect(res.body).toEqual({ status: "unhealthy", version: serverVersion, - error: "database_unreachable" + error: "database_unreachable", + serverInfo: testServerInfo, + }); + }); + + it("returns safe server info fallbacks when git metadata is unavailable", async () => { + const app = createApp(undefined, { + processStartedAt: "2026-06-26T00:00:00.000Z", + git: { + available: false, + unavailableReason: "git_unavailable", + }, + }); + + const res = await request(app).get("/health"); + + expect(res.status).toBe(200); + expect(res.body.serverInfo).toEqual({ + processStartedAt: "2026-06-26T00:00:00.000Z", + git: { + available: false, + unavailableReason: "git_unavailable", + }, }); }); @@ -88,6 +133,7 @@ describe("GET /health", () => { deploymentExposure: "public", authReady: true, companyDeletionEnabled: false, + serverInfo: testServerInfo, }), ); @@ -101,6 +147,7 @@ describe("GET /health", () => { bootstrapStatus: "ready", bootstrapInviteActive: false, }); + expect(res.body.serverInfo).toBeUndefined(); }); it("redacts detailed metadata when authenticated mode is reached without auth middleware", async () => { @@ -123,6 +170,7 @@ describe("GET /health", () => { deploymentExposure: "public", authReady: true, companyDeletionEnabled: false, + serverInfo: testServerInfo, }), ); @@ -136,6 +184,7 @@ describe("GET /health", () => { bootstrapStatus: "ready", bootstrapInviteActive: false, }); + expect(res.body.serverInfo).toBeUndefined(); }); it("keeps detailed metadata for authenticated requests in authenticated mode", async () => { @@ -162,6 +211,7 @@ describe("GET /health", () => { deploymentExposure: "public", authReady: true, companyDeletionEnabled: false, + serverInfo: testServerInfo, }), ); @@ -179,6 +229,7 @@ describe("GET /health", () => { features: { companyDeletionEnabled: false, }, + serverInfo: testServerInfo, }); }); }); diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index 3965dacb97..05c0210e73 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -81,6 +81,7 @@ describe("instance settings routes", () => { enableIssuePlanDecompositions: false, enableExperimentalFileViewer: false, enableCloudSync: false, + enableServerInfoDebugView: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, issueGraphLivenessAutoRecoveryLookbackHours: 24, @@ -101,6 +102,7 @@ describe("instance settings routes", () => { enableTaskWatchdogs: false, enableCloudSync: false, enableExternalObjects: false, + enableServerInfoDebugView: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, issueGraphLivenessAutoRecoveryLookbackHours: 24, @@ -119,6 +121,7 @@ describe("instance settings routes", () => { enableIssuePlanDecompositions: true, enableExperimentalFileViewer: true, enableCloudSync: true, + enableServerInfoDebugView: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, issueGraphLivenessAutoRecoveryLookbackHours: 24, @@ -144,6 +147,7 @@ describe("instance settings routes", () => { enableTaskWatchdogs: true, enableCloudSync: true, enableExternalObjects: false, + enableServerInfoDebugView: true, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, issueGraphLivenessAutoRecoveryLookbackHours: 24, @@ -197,6 +201,7 @@ describe("instance settings routes", () => { enableTaskWatchdogs: false, enableCloudSync: false, enableExternalObjects: false, + enableServerInfoDebugView: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, issueGraphLivenessAutoRecoveryLookbackHours: 24, @@ -292,6 +297,24 @@ describe("instance settings routes", () => { }); }); + it("allows local board users to update the server info debug view", async () => { + const app = await createApp({ + type: "board", + userId: "local-board", + source: "local_implicit", + isInstanceAdmin: true, + }); + + await request(app) + .patch("/api/instance/settings/experimental") + .send({ enableServerInfoDebugView: true }) + .expect(200); + + expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({ + enableServerInfoDebugView: true, + }); + }); + it("allows local board users to update issue graph liveness auto-recovery", async () => { const app = await createApp({ type: "board", diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts index 143c6fc9c7..0d00f3c0ef 100644 --- a/server/src/__tests__/instance-settings-service.test.ts +++ b/server/src/__tests__/instance-settings-service.test.ts @@ -10,6 +10,7 @@ describe("instance settings service", () => { enableExperimentalFileViewer: true, enableTaskWatchdogs: true, enableCloudSync: true, + enableServerInfoDebugView: true, autoRestartDevServerWhenIdle: true, enableIssueGraphLivenessAutoRecovery: true, issueGraphLivenessAutoRecoveryLookbackHours: 48, @@ -25,6 +26,7 @@ describe("instance settings service", () => { enableExperimentalFileViewer: true, enableTaskWatchdogs: true, enableCloudSync: true, + enableServerInfoDebugView: true, autoRestartDevServerWhenIdle: true, enableIssueGraphLivenessAutoRecovery: true, issueGraphLivenessAutoRecoveryLookbackHours: 48, @@ -48,6 +50,14 @@ describe("instance settings service", () => { ).toBe(false); }); + it("defaults enableServerInfoDebugView to false for empty and legacy stored settings", () => { + expect(normalizeExperimentalSettings(undefined).enableServerInfoDebugView).toBe(false); + expect(normalizeExperimentalSettings({}).enableServerInfoDebugView).toBe(false); + expect( + normalizeExperimentalSettings({ autoRestartDevServerWhenIdle: true }).enableServerInfoDebugView, + ).toBe(false); + }); + it("round-trips an enableConferenceRoomChat patch through the update merge", () => { // updateExperimental merges `{ ...normalize(current), ...patch }` and // re-normalizes; emulate that to prove the flag survives the roundtrip diff --git a/server/src/__tests__/server-info.test.ts b/server/src/__tests__/server-info.test.ts new file mode 100644 index 0000000000..6cb3ec1d7c --- /dev/null +++ b/server/src/__tests__/server-info.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { createServerInfoSnapshot } from "../server-info.js"; + +describe("server info snapshot", () => { + it("captures process start time and git metadata", () => { + const snapshot = createServerInfoSnapshot({ + now: new Date("2026-06-26T00:00:00.000Z"), + gitCommand: () => + [ + "0123456789abcdef0123456789abcdef01234567", + "0123456", + "Add server info debug view", + "2026-06-25T17:00:00-07:00", + ].join("\n"), + }); + + expect(snapshot).toEqual({ + processStartedAt: "2026-06-26T00:00:00.000Z", + git: { + available: true, + fullSha: "0123456789abcdef0123456789abcdef01234567", + shortSha: "0123456", + subject: "Add server info debug view", + committedAt: "2026-06-26T00:00:00.000Z", + }, + }); + }); + + it("uses sanitized fallback metadata when git is unavailable", () => { + const snapshot = createServerInfoSnapshot({ + now: new Date("2026-06-26T00:00:00.000Z"), + gitCommand: () => { + throw new Error("fatal: not a git repository"); + }, + }); + + expect(snapshot).toEqual({ + processStartedAt: "2026-06-26T00:00:00.000Z", + git: { + available: false, + unavailableReason: "git_unavailable", + }, + }); + }); +}); diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts index f5a99ce396..ecbeb51f3d 100644 --- a/server/src/routes/health.ts +++ b/server/src/routes/health.ts @@ -6,6 +6,7 @@ import { heartbeatRuns, instanceUserRoles, invites } from "@paperclipai/db"; import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared"; import { readPersistedDevServerStatus, toDevServerHealthStatus, writeDevServerRestartRequest } from "../dev-server-status.js"; import { logger } from "../middleware/logger.js"; +import { getServerInfoSnapshot, type ServerInfoSnapshot } from "../server-info.js"; import { instanceSettingsService } from "../services/instance-settings.js"; import { serverVersion } from "../version.js"; @@ -35,6 +36,7 @@ export function healthRoutes( deploymentExposure: DeploymentExposure; authReady: boolean; companyDeletionEnabled: boolean; + serverInfo?: ServerInfoSnapshot; } = { deploymentMode: "local_trusted", deploymentExposure: "private", @@ -84,13 +86,19 @@ export function healthRoutes( actorType, opts.deploymentMode, ); + // serverInfo (git SHA + process start) rides on the full-details responses + // only, so it reaches board/agent actors in authenticated mode or any caller + // in local_trusted dev — never anonymous authenticated callers. The + // enableServerInfoDebugView experimental flag gates the UI surface, not this + // already access-controlled field. + const serverInfo = opts.serverInfo ?? getServerInfoSnapshot(); const exposeDevServerDetails = exposeFullDetails || hasDevServerStatusToken(req.get("x-paperclip-dev-server-status-token")); if (!db) { res.json( exposeFullDetails - ? { status: "ok", version: serverVersion } + ? { status: "ok", version: serverVersion, serverInfo } : { status: "ok", deploymentMode: opts.deploymentMode }, ); return; @@ -103,7 +111,8 @@ export function healthRoutes( res.status(503).json({ status: "unhealthy", version: serverVersion, - error: "database_unreachable" + error: "database_unreachable", + ...(exposeFullDetails ? { serverInfo } : {}), }); return; } @@ -176,6 +185,7 @@ export function healthRoutes( features: { companyDeletionEnabled: opts.companyDeletionEnabled, }, + serverInfo, ...(devServer ? { devServer } : {}), }); }); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 08714bb109..2fae791ca0 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -768,6 +768,22 @@ registry.registerPath({ deploymentMode: z.string().optional(), bootstrapStatus: z.enum(["ready", "bootstrap_pending"]).optional(), bootstrapInviteActive: z.boolean().optional(), + serverInfo: z.object({ + processStartedAt: z.string().datetime(), + git: z.union([ + z.object({ + available: z.literal(true), + fullSha: z.string(), + shortSha: z.string(), + subject: z.string(), + committedAt: z.string().datetime().nullable(), + }).strict(), + z.object({ + available: z.literal(false), + unavailableReason: z.enum(["git_unavailable", "invalid_git_metadata"]), + }).strict(), + ]), + }).strict().optional(), })), 503: { description: "Service unavailable", content: { "application/json": { schema: ErrorSchema } } }, }, diff --git a/server/src/server-info.ts b/server/src/server-info.ts new file mode 100644 index 0000000000..dcb0f9567c --- /dev/null +++ b/server/src/server-info.ts @@ -0,0 +1,62 @@ +import { execFileSync } from "node:child_process"; +import type { ServerGitInfo, ServerInfoSnapshot } from "@paperclipai/shared"; + +export type { ServerGitInfo, ServerInfoSnapshot }; + +type GitCommand = () => string; + +const FULL_SHA_RE = /^[0-9a-f]{40}$/i; +const SHORT_SHA_RE = /^[0-9a-f]{7,40}$/i; + +function defaultGitCommand() { + return execFileSync( + "git", + ["show", "-s", "--format=%H%n%h%n%s%n%cI", "HEAD"], + { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 1500, + }, + ); +} + +function parseGitInfo(output: string): ServerGitInfo { + const [fullSha = "", shortSha = "", subject = "", committedAt = ""] = output + .trimEnd() + .split("\n"); + const committedAtTime = Date.parse(committedAt); + + if (!FULL_SHA_RE.test(fullSha) || !SHORT_SHA_RE.test(shortSha)) { + return { available: false, unavailableReason: "invalid_git_metadata" }; + } + + return { + available: true, + fullSha, + shortSha, + subject: subject.trim() || "No commit subject", + committedAt: Number.isNaN(committedAtTime) ? null : new Date(committedAtTime).toISOString(), + }; +} + +export function createServerInfoSnapshot( + opts: { now?: Date; gitCommand?: GitCommand } = {}, +): ServerInfoSnapshot { + let git: ServerGitInfo; + try { + git = parseGitInfo((opts.gitCommand ?? defaultGitCommand)()); + } catch { + git = { available: false, unavailableReason: "git_unavailable" }; + } + + return { + processStartedAt: (opts.now ?? new Date()).toISOString(), + git, + }; +} + +const serverInfoSnapshot = createServerInfoSnapshot(); + +export function getServerInfoSnapshot(): ServerInfoSnapshot { + return serverInfoSnapshot; +} diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 49fb30e1c4..8b99bdedb7 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -54,6 +54,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableTaskWatchdogs: parsed.data.enableTaskWatchdogs ?? false, enableCloudSync: parsed.data.enableCloudSync ?? false, enableExternalObjects: parsed.data.enableExternalObjects ?? false, + enableServerInfoDebugView: parsed.data.enableServerInfoDebugView ?? false, autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false, enableIssueGraphLivenessAutoRecovery: parsed.data.enableIssueGraphLivenessAutoRecovery ?? false, issueGraphLivenessAutoRecoveryLookbackHours: @@ -72,6 +73,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableExperimentalFileViewer: false, enableCloudSync: false, enableExternalObjects: false, + enableServerInfoDebugView: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: false, issueGraphLivenessAutoRecoveryLookbackHours: diff --git a/ui/src/api/health.ts b/ui/src/api/health.ts index 453662c5c8..4bc4fd1a78 100644 --- a/ui/src/api/health.ts +++ b/ui/src/api/health.ts @@ -1,3 +1,5 @@ +import type { ServerInfoSnapshot } from "@paperclipai/shared"; + export type DevServerHealthStatus = { enabled: true; restartRequired: boolean; @@ -23,6 +25,7 @@ export type HealthStatus = { features?: { companyDeletionEnabled?: boolean; }; + serverInfo?: ServerInfoSnapshot; devServer?: DevServerHealthStatus; }; diff --git a/ui/src/components/BreadcrumbBar.tsx b/ui/src/components/BreadcrumbBar.tsx index a82acdd458..cde8220b44 100644 --- a/ui/src/components/BreadcrumbBar.tsx +++ b/ui/src/components/BreadcrumbBar.tsx @@ -18,14 +18,17 @@ import { PluginLauncherOutlet, usePluginLaunchers } from "@/plugins/launchers"; type GlobalToolbarContext = { companyId: string | null; companyPrefix: string | null }; -function GlobalToolbarPlugins({ context }: { context: GlobalToolbarContext }) { +function GlobalToolbar({ context }: { context: GlobalToolbarContext }) { const { slots } = usePluginSlots({ slotTypes: ["globalToolbarButton"], companyId: context.companyId }); const { launchers } = usePluginLaunchers({ placementZones: ["globalToolbarButton"], companyId: context.companyId, enabled: !!context.companyId }); - if (slots.length === 0 && launchers.length === 0) return null; return ( -
- - +
+ {slots.length > 0 ? ( + + ) : null} + {launchers.length > 0 ? ( + + ) : null}
); } @@ -43,7 +46,7 @@ export function BreadcrumbBar() { [selectedCompanyId, selectedCompany?.issuePrefix], ); - const globalToolbarSlots = ; + const globalToolbarSlots = ; if (isMobile && mobileToolbar) { return ( diff --git a/ui/src/components/SidebarAccountMenu.tsx b/ui/src/components/SidebarAccountMenu.tsx index f151cb163a..c28ca55999 100644 --- a/ui/src/components/SidebarAccountMenu.tsx +++ b/ui/src/components/SidebarAccountMenu.tsx @@ -17,6 +17,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils"; import { ThemeToggle } from "./ThemeToggle"; +import { SidebarServerInfo } from "./SidebarServerInfo"; const PROFILE_SETTINGS_PATH = "/company/settings/instance/profile"; const DOCS_URL = "https://docs.paperclip.ing/"; @@ -237,6 +238,7 @@ export function SidebarAccountMenu({ ) : null} +
diff --git a/ui/src/components/SidebarServerInfo.test.tsx b/ui/src/components/SidebarServerInfo.test.tsx new file mode 100644 index 0000000000..665c1fd45e --- /dev/null +++ b/ui/src/components/SidebarServerInfo.test.tsx @@ -0,0 +1,139 @@ +// @vitest-environment jsdom + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SidebarServerInfo } from "./SidebarServerInfo"; + +const mockHealthApi = vi.hoisted(() => ({ + get: vi.fn(), +})); +const mockInstanceSettingsApi = vi.hoisted(() => ({ + getExperimental: vi.fn(), +})); + +vi.mock("@/api/health", () => ({ + healthApi: mockHealthApi, +})); + +vi.mock("@/api/instanceSettings", () => ({ + instanceSettingsApi: mockInstanceSettingsApi, +})); + +async function flushReact() { + for (let index = 0; index < 6; index += 1) { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + } + flushSync(() => {}); +} + +function mockEnabledSettings(enabled: boolean) { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ + enableServerInfoDebugView: enabled, + }); +} + +describe("SidebarServerInfo", () => { + let container: HTMLDivElement; + let root: Root | null = null; + + async function render() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + root = createRoot(container); + flushSync(() => { + root!.render( + + + , + ); + }); + await flushReact(); + } + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + mockHealthApi.get.mockReset(); + mockInstanceSettingsApi.getExperimental.mockReset(); + }); + + afterEach(() => { + flushSync(() => { + root?.unmount(); + }); + root = null; + container.remove(); + document.body.replaceChildren(); + vi.clearAllMocks(); + }); + + it("renders nothing while the experimental flag is disabled", async () => { + mockEnabledSettings(false); + + await render(); + + expect(container.textContent).toBe(""); + expect(mockHealthApi.get).not.toHaveBeenCalled(); + }); + + it("shows restart and commit rows from health data when enabled", async () => { + mockEnabledSettings(true); + mockHealthApi.get.mockResolvedValue({ + status: "ok", + devServer: { + enabled: true, + restartRequired: false, + reason: null, + lastChangedAt: null, + changedPathCount: 0, + changedPathsSample: [], + pendingMigrations: [], + autoRestartEnabled: false, + activeRunCount: 0, + waitingForIdle: false, + lastRestartAt: "2026-06-26T01:15:00.000Z", + }, + serverInfo: { + processStartedAt: "2026-06-26T00:00:00.000Z", + git: { + available: true, + fullSha: "abcdef1234567890abcdef1234567890abcdef12", + shortSha: "abcdef1", + subject: "Add server info debug view", + committedAt: "2026-06-25T23:00:00.000Z", + }, + }, + }); + + await render(); + + expect(container.textContent).toContain("Last restarted"); + expect(container.textContent).toContain("Running commit"); + expect(container.querySelector('time[dateTime="2026-06-26T01:15:00.000Z"]')).not.toBeNull(); + expect(container.textContent).toContain("abcdef1"); + expect(container.textContent).toContain("Add server info debug view"); + }); + + it("falls back to process start and unavailable commit copy", async () => { + mockEnabledSettings(true); + mockHealthApi.get.mockResolvedValue({ + status: "ok", + serverInfo: { + processStartedAt: "2026-06-26T00:00:00.000Z", + git: { + available: false, + unavailableReason: "git_unavailable", + }, + }, + }); + + await render(); + + expect(container.querySelector('time[dateTime="2026-06-26T00:00:00.000Z"]')).not.toBeNull(); + expect(container.textContent).toContain("Commit unavailable"); + }); +}); diff --git a/ui/src/components/SidebarServerInfo.tsx b/ui/src/components/SidebarServerInfo.tsx new file mode 100644 index 0000000000..8fc843b2e6 --- /dev/null +++ b/ui/src/components/SidebarServerInfo.tsx @@ -0,0 +1,105 @@ +import { useQuery } from "@tanstack/react-query"; +import { Clock3, GitCommit, type LucideIcon } from "lucide-react"; +import { healthApi, type HealthStatus } from "@/api/health"; +import { instanceSettingsApi } from "@/api/instanceSettings"; +import { queryKeys } from "@/lib/queryKeys"; + +function formatTimestamp(value: string | null | undefined): string { + if (!value) return "Unavailable"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "Unavailable"; + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(date); +} + +function isValidTimestamp(value: string | null | undefined): value is string { + return !!value && !Number.isNaN(new Date(value).getTime()); +} + +function restartTimestamp(health: HealthStatus | undefined): string | null { + return health?.devServer?.lastRestartAt ?? health?.serverInfo?.processStartedAt ?? null; +} + +function commitLabel(health: HealthStatus | undefined): string { + const git = health?.serverInfo?.git; + if (!git?.available) return "Commit unavailable"; + return `${git.shortSha} · ${git.subject}`; +} + +function ServerInfoRow({ + icon: Icon, + label, + value, + dateTime, +}: { + icon: LucideIcon; + label: string; + value: string; + dateTime?: string | null; +}) { + return ( +
+ + + + + {label} + {dateTime ? ( + + ) : ( + {value} + )} + +
+ ); +} + +export function SidebarServerInfo() { + const experimentalQuery = useQuery({ + queryKey: queryKeys.instance.experimentalSettings, + queryFn: () => instanceSettingsApi.getExperimental(), + }); + const enabled = experimentalQuery.data?.enableServerInfoDebugView === true; + const healthQuery = useQuery({ + queryKey: queryKeys.health, + queryFn: () => healthApi.get(), + enabled, + }); + + if (!enabled) return null; + + const health = healthQuery.data; + const isWaitingForHealth = healthQuery.isLoading && !health; + const healthUnavailable = healthQuery.isError; + const restartedAt = restartTimestamp(health); + const restartedAtIsValid = isValidTimestamp(restartedAt); + const lastRestartedLabel = healthUnavailable + ? "Health unavailable" + : isWaitingForHealth + ? "Loading..." + : formatTimestamp(restartedAt); + const commit = healthUnavailable + ? "Health unavailable" + : isWaitingForHealth + ? "Loading..." + : commitLabel(health); + + return ( +
+

+ Server +

+ + +
+ ); +} diff --git a/ui/src/pages/InstanceExperimentalSettings.test.tsx b/ui/src/pages/InstanceExperimentalSettings.test.tsx index bc4e93b381..ea05f529b2 100644 --- a/ui/src/pages/InstanceExperimentalSettings.test.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.test.tsx @@ -44,6 +44,8 @@ const STREAMLINED_TOGGLE_SELECTOR = 'button[aria-label="Toggle streamlined left navigation experimental setting"]'; const TASK_WATCHDOGS_TOGGLE_SELECTOR = 'button[aria-label="Toggle task watchdogs experimental setting"]'; +const SERVER_INFO_TOGGLE_SELECTOR = + 'button[aria-label="Toggle server info debug view experimental setting"]'; function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload { return { @@ -57,6 +59,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload { enableExternalObjects: false, enableTaskWatchdogs: false, enableCloudSync: false, + enableServerInfoDebugView: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: false, issueGraphLivenessAutoRecoveryLookbackHours: 24, @@ -201,4 +204,26 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233) enableTaskWatchdogs: false, }); }); + + it("renders and patches the Server Info Debug View experimental toggle", async () => { + await renderPage(); + + expect(container.textContent).toContain("Server Info Debug View"); + expect(container.textContent).toContain( + 'Show a "Server" section in the account drawer with the current server restart time and running commit.', + ); + + const toggle = container.querySelector(SERVER_INFO_TOGGLE_SELECTOR); + expect(toggle?.getAttribute("aria-checked")).toBe("false"); + + await act(async () => { + toggle?.click(); + }); + await flushReact(); + + expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({ + enableServerInfoDebugView: true, + }); + expect(toggle?.getAttribute("aria-checked")).toBe("true"); + }); }); diff --git a/ui/src/pages/InstanceExperimentalSettings.tsx b/ui/src/pages/InstanceExperimentalSettings.tsx index 9c769817f2..e8daeae9b3 100644 --- a/ui/src/pages/InstanceExperimentalSettings.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.tsx @@ -244,6 +244,7 @@ export function InstanceExperimentalSettings() { const enableTaskWatchdogs = experimentalQuery.data?.enableTaskWatchdogs === true; const enableCloudSync = experimentalQuery.data?.enableCloudSync === true; const enableExternalObjects = experimentalQuery.data?.enableExternalObjects === true; + const enableServerInfoDebugView = experimentalQuery.data?.enableServerInfoDebugView === true; const autoRestartDevServerWhenIdle = experimentalQuery.data?.autoRestartDevServerWhenIdle === true; const enableIssueGraphLivenessAutoRecovery = experimentalQuery.data?.enableIssueGraphLivenessAutoRecovery === true; @@ -501,6 +502,27 @@ export function InstanceExperimentalSettings() { +
+
+
+

Server Info Debug View

+

+ Show a "Server" section in the account drawer with the current server restart time and running commit. +

+
+ + toggleMutation.mutate({ + enableServerInfoDebugView: !enableServerInfoDebugView, + }) + } + disabled={toggleMutation.isPending} + aria-label="Toggle server info debug view experimental setting" + /> +
+
+