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:
parent
098a98091c
commit
765a75207a
|
|
@ -976,6 +976,8 @@ export type {
|
|||
CloudUpstreamWarning,
|
||||
} from "./types/cloud-upstream.js";
|
||||
|
||||
export type { ServerGitInfo, ServerInfoSnapshot } from "./types/server-info.js";
|
||||
|
||||
export {
|
||||
getClosedIsolatedExecutionWorkspaceMessage,
|
||||
isClosedIsolatedExecutionWorkspace,
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ export interface InstanceExperimentalSettings {
|
|||
enableExperimentalFileViewer: boolean;
|
||||
enableCloudSync: boolean;
|
||||
enableExternalObjects: boolean;
|
||||
enableServerInfoDebugView: boolean;
|
||||
autoRestartDevServerWhenIdle: boolean;
|
||||
enableIssueGraphLivenessAutoRecovery: boolean;
|
||||
issueGraphLivenessAutoRecoveryLookbackHours: number;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 } : {}),
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 } } },
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in New Issue