Fix stale server info debug metadata (#8753)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The experimental server info debug view helps local operators inspect what code a running dev instance is actually serving > - The view was moved into the account-menu drawer, which only mounts while that drawer is open > - That made stale health-query data easier to see after restarts, and the server was also caching the running commit at process boot > - A clean commit label alone is incomplete when the checkout has uncommitted local changes > - This pull request keeps the drawer health data fresh, refreshes git metadata on demand, and adds a path-free checkout-state summary > - The benefit is that the debug view reports restart time, running commit, and dirty-checkout state without exposing local paths, secrets, logs, or environment details ## Linked Issues or Issue Description Fixes: #8752 ## What Changed - `SidebarServerInfo.tsx`: refetch the health query whenever the drawer opens and poll every 2s while the dev server is active. - `server-info.ts`: keep `processStartedAt` stable while refreshing git HEAD through a short TTL cache instead of freezing commit metadata at module boot. - Shared health contract/OpenAPI: add `serverInfo.git.localChanges` with only staged, unstaged, and untracked counts plus safe unavailable fallbacks. - `SidebarServerInfo.tsx`: add a `Checkout state` row that renders clean/dirty/unavailable copy without file paths. - Tests: cover stale drawer refresh, interval polling, TTL commit refresh, health response shape, checkout-state count parsing, and path-free UI rendering. ## Verification - `npx vitest run server/src/__tests__/server-info.test.ts server/src/__tests__/health.test.ts ui/src/components/SidebarServerInfo.test.tsx` -> 3 files / 19 tests passing. - `pnpm install --frozen-lockfile --ignore-scripts` -> refreshed stale workspace links without lockfile/source churn. - `pnpm --filter @paperclipai/shared --filter @paperclipai/server --filter @paperclipai/ui typecheck` -> passing. - `pnpm --filter @paperclipai/ui typecheck` -> passing after the Greptile test-coverage fix. - `pnpm check:tokens` -> no forbidden tokens found. - Local diff scans for obvious secrets, credentials, private URLs, local paths, and PII patterns -> no matches. - GitHub PR checks on head `56defd446` -> all green, including `verify`, canary dry run, e2e, security scans, and Greptile Review. - Greptile latest summary -> Confidence Score 5/5, 0 new comments; the prior P2 polling-coverage thread is resolved. ## Risks Low risk. The UI remains behind the experimental `enableServerInfoDebugView` flag. The extra git status call is throttled by the existing server-info TTL and reports only counts, not paths or file names. If git status is unavailable, the commit row still works and the checkout-state row shows clear fallback copy. ## Model Used Claude Opus (claude-opus-4-8), extended thinking, with tool use / code execution assisted the original stale-metadata fix. OpenAI GPT-5 via Codex local, with tool use and code execution, added the checkout-state follow-up, Greptile fix-up, and PR verification. ## 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 (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or 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 --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
5e3d6e3627
commit
a7a73d5bc7
|
|
@ -983,7 +983,7 @@ export type {
|
|||
CloudUpstreamWarning,
|
||||
} from "./types/cloud-upstream.js";
|
||||
|
||||
export type { ServerGitInfo, ServerInfoSnapshot } from "./types/server-info.js";
|
||||
export type { ServerGitInfo, ServerGitLocalChanges, ServerInfoSnapshot } from "./types/server-info.js";
|
||||
|
||||
export {
|
||||
getClosedIsolatedExecutionWorkspaceMessage,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,18 @@
|
|||
// 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 ServerGitLocalChanges =
|
||||
| {
|
||||
available: true;
|
||||
hasLocalChanges: boolean;
|
||||
stagedFileCount: number;
|
||||
unstagedFileCount: number;
|
||||
untrackedFileCount: number;
|
||||
}
|
||||
| {
|
||||
available: false;
|
||||
unavailableReason: "git_status_unavailable";
|
||||
};
|
||||
|
||||
export type ServerGitInfo =
|
||||
| {
|
||||
available: true;
|
||||
|
|
@ -7,6 +20,7 @@ export type ServerGitInfo =
|
|||
shortSha: string;
|
||||
subject: string;
|
||||
committedAt: string | null;
|
||||
localChanges: ServerGitLocalChanges;
|
||||
}
|
||||
| {
|
||||
available: false;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,13 @@ const testServerInfo = {
|
|||
shortSha: "0123456",
|
||||
subject: "Add server info debug view",
|
||||
committedAt: "2026-06-25T23:00:00.000Z",
|
||||
localChanges: {
|
||||
available: true,
|
||||
hasLocalChanges: false,
|
||||
stagedFileCount: 0,
|
||||
unstagedFileCount: 0,
|
||||
untrackedFileCount: 0,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,14 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { createServerInfoSnapshot } from "../server-info.js";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createServerInfoSnapshot,
|
||||
getServerInfoSnapshot,
|
||||
resetServerInfoCacheForTests,
|
||||
} from "../server-info.js";
|
||||
|
||||
function gitCommandFor(shortSha: string, subject: string): () => string {
|
||||
return () =>
|
||||
[shortSha.padEnd(40, "0"), shortSha, subject, "2026-06-25T17:00:00-07:00"].join("\n");
|
||||
}
|
||||
|
||||
describe("server info snapshot", () => {
|
||||
it("captures process start time and git metadata", () => {
|
||||
|
|
@ -12,6 +21,7 @@ describe("server info snapshot", () => {
|
|||
"Add server info debug view",
|
||||
"2026-06-25T17:00:00-07:00",
|
||||
].join("\n"),
|
||||
gitStatusCommand: () => "",
|
||||
});
|
||||
|
||||
expect(snapshot).toEqual({
|
||||
|
|
@ -22,6 +32,68 @@ describe("server info snapshot", () => {
|
|||
shortSha: "0123456",
|
||||
subject: "Add server info debug view",
|
||||
committedAt: "2026-06-26T00:00:00.000Z",
|
||||
localChanges: {
|
||||
available: true,
|
||||
hasLocalChanges: false,
|
||||
stagedFileCount: 0,
|
||||
unstagedFileCount: 0,
|
||||
untrackedFileCount: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("summarizes local checkout changes without exposing file paths", () => {
|
||||
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"),
|
||||
gitStatusCommand: () =>
|
||||
[
|
||||
"M packages/shared/src/types/server-info.ts",
|
||||
" M ui/src/components/SidebarServerInfo.tsx",
|
||||
"MM server/src/server-info.ts",
|
||||
"?? server/src/__tests__/server-info.test.ts",
|
||||
].join("\n"),
|
||||
});
|
||||
|
||||
expect(snapshot.git).toMatchObject({
|
||||
available: true,
|
||||
localChanges: {
|
||||
available: true,
|
||||
hasLocalChanges: true,
|
||||
stagedFileCount: 2,
|
||||
unstagedFileCount: 2,
|
||||
untrackedFileCount: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps commit metadata available when git status is unavailable", () => {
|
||||
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"),
|
||||
gitStatusCommand: () => {
|
||||
throw new Error("status unavailable");
|
||||
},
|
||||
});
|
||||
|
||||
expect(snapshot.git).toMatchObject({
|
||||
available: true,
|
||||
localChanges: {
|
||||
available: false,
|
||||
unavailableReason: "git_status_unavailable",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
@ -43,3 +115,37 @@ describe("server info snapshot", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getServerInfoSnapshot", () => {
|
||||
beforeEach(() => {
|
||||
resetServerInfoCacheForTests();
|
||||
});
|
||||
|
||||
it("re-reads the running commit after the cache TTL expires", () => {
|
||||
const first = getServerInfoSnapshot({
|
||||
now: 0,
|
||||
gitCommand: gitCommandFor("aaaaaaa", "First boot"),
|
||||
});
|
||||
expect(first.git).toMatchObject({ shortSha: "aaaaaaa", subject: "First boot" });
|
||||
|
||||
// Within the TTL window the cached commit is reused.
|
||||
const cached = getServerInfoSnapshot({
|
||||
now: 1000,
|
||||
gitCommand: gitCommandFor("bbbbbbb", "After restart"),
|
||||
});
|
||||
expect(cached.git).toMatchObject({ shortSha: "aaaaaaa", subject: "First boot" });
|
||||
|
||||
// Past the TTL the new HEAD is picked up without a process restart.
|
||||
const refreshed = getServerInfoSnapshot({
|
||||
now: 3000,
|
||||
gitCommand: gitCommandFor("bbbbbbb", "After restart"),
|
||||
});
|
||||
expect(refreshed.git).toMatchObject({ shortSha: "bbbbbbb", subject: "After restart" });
|
||||
});
|
||||
|
||||
it("keeps processStartedAt stable across refreshes", () => {
|
||||
const first = getServerInfoSnapshot({ now: 0, gitCommand: gitCommandFor("aaaaaaa", "a") });
|
||||
const second = getServerInfoSnapshot({ now: 5000, gitCommand: gitCommandFor("bbbbbbb", "b") });
|
||||
expect(second.processStartedAt).toBe(first.processStartedAt);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -777,6 +777,19 @@ registry.registerPath({
|
|||
shortSha: z.string(),
|
||||
subject: z.string(),
|
||||
committedAt: z.string().datetime().nullable(),
|
||||
localChanges: z.union([
|
||||
z.object({
|
||||
available: z.literal(true),
|
||||
hasLocalChanges: z.boolean(),
|
||||
stagedFileCount: z.number().int().nonnegative(),
|
||||
unstagedFileCount: z.number().int().nonnegative(),
|
||||
untrackedFileCount: z.number().int().nonnegative(),
|
||||
}).strict(),
|
||||
z.object({
|
||||
available: z.literal(false),
|
||||
unavailableReason: z.enum(["git_status_unavailable"]),
|
||||
}).strict(),
|
||||
]),
|
||||
}).strict(),
|
||||
z.object({
|
||||
available: z.literal(false),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { execFileSync } from "node:child_process";
|
||||
import type { ServerGitInfo, ServerInfoSnapshot } from "@paperclipai/shared";
|
||||
import type { ServerGitInfo, ServerGitLocalChanges, ServerInfoSnapshot } from "@paperclipai/shared";
|
||||
|
||||
export type { ServerGitInfo, ServerInfoSnapshot };
|
||||
|
||||
|
|
@ -20,7 +20,54 @@ function defaultGitCommand() {
|
|||
);
|
||||
}
|
||||
|
||||
function parseGitInfo(output: string): ServerGitInfo {
|
||||
function defaultGitStatusCommand() {
|
||||
return execFileSync(
|
||||
"git",
|
||||
["status", "--porcelain=v1", "--untracked-files=normal"],
|
||||
{
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
timeout: 1500,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function parseGitLocalChanges(output: string): ServerGitLocalChanges {
|
||||
let stagedFileCount = 0;
|
||||
let unstagedFileCount = 0;
|
||||
let untrackedFileCount = 0;
|
||||
|
||||
for (const line of output.split(/\r?\n/)) {
|
||||
if (!line) continue;
|
||||
const indexStatus = line[0] ?? " ";
|
||||
const worktreeStatus = line[1] ?? " ";
|
||||
|
||||
if (indexStatus === "?" && worktreeStatus === "?") {
|
||||
untrackedFileCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (indexStatus !== " " && indexStatus !== "?") stagedFileCount += 1;
|
||||
if (worktreeStatus !== " " && worktreeStatus !== "?") unstagedFileCount += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
available: true,
|
||||
hasLocalChanges: stagedFileCount + unstagedFileCount + untrackedFileCount > 0,
|
||||
stagedFileCount,
|
||||
unstagedFileCount,
|
||||
untrackedFileCount,
|
||||
};
|
||||
}
|
||||
|
||||
function getGitLocalChanges(gitStatusCommand: GitCommand): ServerGitLocalChanges {
|
||||
try {
|
||||
return parseGitLocalChanges(gitStatusCommand());
|
||||
} catch {
|
||||
return { available: false, unavailableReason: "git_status_unavailable" };
|
||||
}
|
||||
}
|
||||
|
||||
function parseGitInfo(output: string, localChanges: ServerGitLocalChanges): ServerGitInfo {
|
||||
const [fullSha = "", shortSha = "", subject = "", committedAt = ""] = output
|
||||
.trimEnd()
|
||||
.split("\n");
|
||||
|
|
@ -36,27 +83,54 @@ function parseGitInfo(output: string): ServerGitInfo {
|
|||
shortSha,
|
||||
subject: subject.trim() || "No commit subject",
|
||||
committedAt: Number.isNaN(committedAtTime) ? null : new Date(committedAtTime).toISOString(),
|
||||
localChanges,
|
||||
};
|
||||
}
|
||||
|
||||
function readGitInfo(
|
||||
gitCommand: GitCommand = defaultGitCommand,
|
||||
gitStatusCommand: GitCommand = defaultGitStatusCommand,
|
||||
): ServerGitInfo {
|
||||
try {
|
||||
const output = gitCommand();
|
||||
const localChanges = getGitLocalChanges(gitStatusCommand);
|
||||
return parseGitInfo(output, localChanges);
|
||||
} catch {
|
||||
return { available: false, unavailableReason: "git_unavailable" };
|
||||
}
|
||||
}
|
||||
|
||||
export function createServerInfoSnapshot(
|
||||
opts: { now?: Date; gitCommand?: GitCommand } = {},
|
||||
opts: { now?: Date; gitCommand?: GitCommand; gitStatusCommand?: 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,
|
||||
git: readGitInfo(opts.gitCommand, opts.gitStatusCommand),
|
||||
};
|
||||
}
|
||||
|
||||
const serverInfoSnapshot = createServerInfoSnapshot();
|
||||
// processStartedAt is a true boot constant, but the running commit can change
|
||||
// without the Node process restarting: a managed dev-server restart re-runs the
|
||||
// code while keeping this module alive, so a commit captured once at boot goes
|
||||
// stale. Re-read git HEAD on demand, throttled by a short TTL so frequent health
|
||||
// polls don't spawn git on every request.
|
||||
const GIT_INFO_CACHE_TTL_MS = 3000;
|
||||
const processStartedAt = new Date().toISOString();
|
||||
let gitInfoCache: { value: ServerGitInfo; expiresAt: number } | null = null;
|
||||
|
||||
export function getServerInfoSnapshot(): ServerInfoSnapshot {
|
||||
return serverInfoSnapshot;
|
||||
export function getServerInfoSnapshot(
|
||||
opts: { now?: number; gitCommand?: GitCommand; gitStatusCommand?: GitCommand } = {},
|
||||
): ServerInfoSnapshot {
|
||||
const now = opts.now ?? Date.now();
|
||||
if (!gitInfoCache || now >= gitInfoCache.expiresAt) {
|
||||
gitInfoCache = {
|
||||
value: readGitInfo(opts.gitCommand, opts.gitStatusCommand),
|
||||
expiresAt: now + GIT_INFO_CACHE_TTL_MS,
|
||||
};
|
||||
}
|
||||
return { processStartedAt, git: gitInfoCache.value };
|
||||
}
|
||||
|
||||
export function resetServerInfoCacheForTests(): void {
|
||||
gitInfoCache = null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,14 @@ async function flushReact() {
|
|||
flushSync(() => {});
|
||||
}
|
||||
|
||||
async function flushReactMicrotasks() {
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
await Promise.resolve();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
}
|
||||
flushSync(() => {});
|
||||
}
|
||||
|
||||
function mockEnabledSettings(enabled: boolean) {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableServerInfoDebugView: enabled,
|
||||
|
|
@ -69,6 +77,7 @@ describe("SidebarServerInfo", () => {
|
|||
container.remove();
|
||||
document.body.replaceChildren();
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("renders nothing while the experimental flag is disabled", async () => {
|
||||
|
|
@ -105,6 +114,13 @@ describe("SidebarServerInfo", () => {
|
|||
shortSha: "abcdef1",
|
||||
subject: "Add server info debug view",
|
||||
committedAt: "2026-06-25T23:00:00.000Z",
|
||||
localChanges: {
|
||||
available: true,
|
||||
hasLocalChanges: false,
|
||||
stagedFileCount: 0,
|
||||
unstagedFileCount: 0,
|
||||
untrackedFileCount: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -113,9 +129,174 @@ describe("SidebarServerInfo", () => {
|
|||
|
||||
expect(container.textContent).toContain("Last restarted");
|
||||
expect(container.textContent).toContain("Running commit");
|
||||
expect(container.textContent).toContain("Checkout state");
|
||||
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");
|
||||
expect(container.textContent).toContain("Clean checkout");
|
||||
});
|
||||
|
||||
it("polls health while the drawer is open and the dev server is active", async () => {
|
||||
vi.useFakeTimers();
|
||||
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",
|
||||
localChanges: {
|
||||
available: true,
|
||||
hasLocalChanges: false,
|
||||
stagedFileCount: 0,
|
||||
unstagedFileCount: 0,
|
||||
untrackedFileCount: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
root = createRoot(container);
|
||||
flushSync(() => {
|
||||
root!.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SidebarServerInfo />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReactMicrotasks();
|
||||
|
||||
expect(mockHealthApi.get).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await flushReactMicrotasks();
|
||||
|
||||
expect(mockHealthApi.get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("shows path-free local change counts from health data", async () => {
|
||||
mockEnabledSettings(true);
|
||||
mockHealthApi.get.mockResolvedValue({
|
||||
status: "ok",
|
||||
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",
|
||||
localChanges: {
|
||||
available: true,
|
||||
hasLocalChanges: true,
|
||||
stagedFileCount: 3,
|
||||
unstagedFileCount: 2,
|
||||
untrackedFileCount: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("Local changes present (3 staged, 2 unstaged, 1 untracked)");
|
||||
expect(container.textContent).not.toContain("server-info.ts");
|
||||
});
|
||||
|
||||
it("refetches fresh health each time the drawer reopens, even within staleTime", async () => {
|
||||
mockEnabledSettings(true);
|
||||
const baseHealth = {
|
||||
status: "ok" as const,
|
||||
serverInfo: {
|
||||
processStartedAt: "2026-06-26T00:00:00.000Z",
|
||||
git: {
|
||||
available: true,
|
||||
fullSha: "1111111111111111111111111111111111111111",
|
||||
shortSha: "1111111",
|
||||
subject: "First boot",
|
||||
committedAt: "2026-06-25T23:00:00.000Z",
|
||||
localChanges: {
|
||||
available: true,
|
||||
hasLocalChanges: false,
|
||||
stagedFileCount: 0,
|
||||
unstagedFileCount: 0,
|
||||
untrackedFileCount: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
mockHealthApi.get.mockResolvedValue(baseHealth);
|
||||
|
||||
// A long staleTime mirrors the production QueryClient: without
|
||||
// refetchOnMount "always", reopening the drawer would show cached data.
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, staleTime: 30_000 } },
|
||||
});
|
||||
|
||||
async function mountDrawer() {
|
||||
const drawerRoot = createRoot(container);
|
||||
flushSync(() => {
|
||||
drawerRoot.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SidebarServerInfo />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
return drawerRoot;
|
||||
}
|
||||
|
||||
const firstOpen = await mountDrawer();
|
||||
expect(container.textContent).toContain("First boot");
|
||||
flushSync(() => firstOpen.unmount());
|
||||
|
||||
// Server restarted: a fresh commit and process start should appear on reopen.
|
||||
mockHealthApi.get.mockResolvedValue({
|
||||
...baseHealth,
|
||||
serverInfo: {
|
||||
processStartedAt: "2026-06-26T02:00:00.000Z",
|
||||
git: {
|
||||
available: true,
|
||||
fullSha: "2222222222222222222222222222222222222222",
|
||||
shortSha: "2222222",
|
||||
subject: "After restart",
|
||||
committedAt: "2026-06-26T01:30:00.000Z",
|
||||
localChanges: {
|
||||
available: true,
|
||||
hasLocalChanges: true,
|
||||
stagedFileCount: 1,
|
||||
unstagedFileCount: 0,
|
||||
untrackedFileCount: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const secondOpen = await mountDrawer();
|
||||
expect(container.textContent).toContain("After restart");
|
||||
expect(container.textContent).toContain("Local changes present (1 staged)");
|
||||
expect(container.textContent).not.toContain("First boot");
|
||||
flushSync(() => secondOpen.unmount());
|
||||
});
|
||||
|
||||
it("falls back to process start and unavailable commit copy", async () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Clock3, GitCommit, type LucideIcon } from "lucide-react";
|
||||
import { Clock3, FileDiff, GitCommit, type LucideIcon } from "lucide-react";
|
||||
import { healthApi, type HealthStatus } from "@/api/health";
|
||||
import { instanceSettingsApi } from "@/api/instanceSettings";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
|
@ -28,6 +28,25 @@ function commitLabel(health: HealthStatus | undefined): string {
|
|||
return `${git.shortSha} · ${git.subject}`;
|
||||
}
|
||||
|
||||
function localChangesLabel(health: HealthStatus | undefined): string {
|
||||
const git = health?.serverInfo?.git;
|
||||
if (!git?.available) return "Unavailable";
|
||||
const localChanges = git.localChanges;
|
||||
if (!localChanges) return "Change status unavailable";
|
||||
if (!localChanges.available) return "Change status unavailable";
|
||||
if (!localChanges.hasLocalChanges) return "Clean checkout";
|
||||
|
||||
const parts = [
|
||||
[localChanges.stagedFileCount, "staged"],
|
||||
[localChanges.unstagedFileCount, "unstaged"],
|
||||
[localChanges.untrackedFileCount, "untracked"],
|
||||
]
|
||||
.filter(([count]) => Number(count) > 0)
|
||||
.map(([count, label]) => `${count} ${label}`);
|
||||
|
||||
return parts.length > 0 ? `Local changes present (${parts.join(", ")})` : "Local changes present";
|
||||
}
|
||||
|
||||
function ServerInfoRow({
|
||||
icon: Icon,
|
||||
label,
|
||||
|
|
@ -68,6 +87,15 @@ export function SidebarServerInfo() {
|
|||
queryKey: queryKeys.health,
|
||||
queryFn: () => healthApi.get(),
|
||||
enabled,
|
||||
// The drawer only mounts while the account popover is open, so it cannot
|
||||
// rely on Layout's background health poll (which is itself gated on
|
||||
// devServer.enabled). Always refetch on open and poll while open so a server
|
||||
// restart is reflected without leaving stale boot-time serverInfo on screen.
|
||||
refetchOnMount: "always",
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data as HealthStatus | undefined;
|
||||
return data?.devServer?.enabled ? 2000 : false;
|
||||
},
|
||||
});
|
||||
|
||||
if (!enabled) return null;
|
||||
|
|
@ -87,6 +115,11 @@ export function SidebarServerInfo() {
|
|||
: isWaitingForHealth
|
||||
? "Loading..."
|
||||
: commitLabel(health);
|
||||
const localChanges = healthUnavailable
|
||||
? "Health unavailable"
|
||||
: isWaitingForHealth
|
||||
? "Loading..."
|
||||
: localChangesLabel(health);
|
||||
|
||||
return (
|
||||
<div className="mt-2 border-t border-border pt-2">
|
||||
|
|
@ -100,6 +133,7 @@ export function SidebarServerInfo() {
|
|||
dateTime={!healthUnavailable && !isWaitingForHealth && restartedAtIsValid ? restartedAt : null}
|
||||
/>
|
||||
<ServerInfoRow icon={GitCommit} label="Running commit" value={commit} />
|
||||
<ServerInfoRow icon={FileDiff} label="Checkout state" value={localChanges} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue