Add experimental server info debug view (#8676)

## 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
This commit is contained in:
Devin Foley 2026-06-26 14:47:01 -07:00 committed by GitHub
parent 098a98091c
commit 765a75207a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 577 additions and 13 deletions

View File

@ -976,6 +976,8 @@ export type {
CloudUpstreamWarning,
} from "./types/cloud-upstream.js";
export type { ServerGitInfo, ServerInfoSnapshot } from "./types/server-info.js";
export {
getClosedIsolatedExecutionWorkspaceMessage,
isClosedIsolatedExecutionWorkspace,

View File

@ -55,6 +55,7 @@ export interface InstanceExperimentalSettings {
enableExperimentalFileViewer: boolean;
enableCloudSync: boolean;
enableExternalObjects: boolean;
enableServerInfoDebugView: boolean;
autoRestartDevServerWhenIdle: boolean;
enableIssueGraphLivenessAutoRecovery: boolean;
issueGraphLivenessAutoRecoveryLookbackHours: number;

View File

@ -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;
}

View File

@ -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,
});
});
});

View File

@ -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

View File

@ -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,
});
});
});

View File

@ -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",

View File

@ -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

View File

@ -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",
},
});
});
});

View File

@ -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 } : {}),
});
});

View File

@ -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 } } },
},

62
server/src/server-info.ts Normal file
View File

@ -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;
}

View File

@ -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:

View File

@ -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;
};

View File

@ -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 (
<div className="flex items-center gap-1 ml-auto shrink-0 pl-2">
<PluginSlotOutlet slotTypes={["globalToolbarButton"]} context={context} className="flex items-center gap-1" />
<PluginLauncherOutlet placementZones={["globalToolbarButton"]} context={context} className="flex items-center gap-1" />
<div className="ml-auto flex shrink-0 items-center gap-1 pl-2 empty:hidden">
{slots.length > 0 ? (
<PluginSlotOutlet slotTypes={["globalToolbarButton"]} context={context} className="flex items-center gap-1" />
) : null}
{launchers.length > 0 ? (
<PluginLauncherOutlet placementZones={["globalToolbarButton"]} context={context} className="flex items-center gap-1" />
) : null}
</div>
);
}
@ -43,7 +46,7 @@ export function BreadcrumbBar() {
[selectedCompanyId, selectedCompany?.issuePrefix],
);
const globalToolbarSlots = <GlobalToolbarPlugins context={globalToolbarSlotContext} />;
const globalToolbarSlots = <GlobalToolbar context={globalToolbarSlotContext} />;
if (isMobile && mobileToolbar) {
return (

View File

@ -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({
</span>
</button>
) : null}
<SidebarServerInfo />
</div>
</div>
</PopoverContent>

View File

@ -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(
<QueryClientProvider client={queryClient}>
<SidebarServerInfo />
</QueryClientProvider>,
);
});
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");
});
});

View File

@ -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 (
<div className="flex w-full items-start gap-3 rounded-xl px-3 py-3 text-left">
<span className="mt-0.5 rounded-lg border border-border bg-background/70 p-2 text-muted-foreground">
<Icon className="size-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-foreground">{label}</span>
{dateTime ? (
<time dateTime={dateTime} className="block break-words text-xs text-muted-foreground">
{value}
</time>
) : (
<span className="block break-words text-xs text-muted-foreground">{value}</span>
)}
</span>
</div>
);
}
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 (
<div className="mt-2 border-t border-border pt-2">
<p className="px-3 pb-1 pt-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
Server
</p>
<ServerInfoRow
icon={Clock3}
label="Last restarted"
value={lastRestartedLabel}
dateTime={!healthUnavailable && !isWaitingForHealth && restartedAtIsValid ? restartedAt : null}
/>
<ServerInfoRow icon={GitCommit} label="Running commit" value={commit} />
</div>
);
}

View File

@ -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<HTMLButtonElement>(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");
});
});

View File

@ -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() {
</div>
</section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Server Info Debug View</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Show a "Server" section in the account drawer with the current server restart time and running commit.
</p>
</div>
<ToggleSwitch
checked={enableServerInfoDebugView}
onCheckedChange={() =>
toggleMutation.mutate({
enableServerInfoDebugView: !enableServerInfoDebugView,
})
}
disabled={toggleMutation.isPending}
aria-label="Toggle server info debug view experimental setting"
/>
</div>
</section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">