diff --git a/packages/dashboard/app/components/command-center/__tests__/SystemControlsArea.test.tsx b/packages/dashboard/app/components/command-center/__tests__/SystemControlsArea.test.tsx index 7ceae7ef50..41a69ce715 100644 --- a/packages/dashboard/app/components/command-center/__tests__/SystemControlsArea.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/SystemControlsArea.test.tsx @@ -243,6 +243,102 @@ describe("SystemControlsArea layout integration", () => { expect(addToast).not.toHaveBeenCalledWith(expect.stringContaining("writeText"), "error"); }); + it("embeds the full diagnostics bundle in the bug report body when the operator confirms", async () => { + mockFetchSystemLogs.mockResolvedValue({ + entries: [{ timestamp: "2026-07-12T00:00:00.000Z", level: "error", message: "boom" }], + }); + const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); + const openSpy = vi.spyOn(window, "open").mockReturnValue(null); + await renderSystemTab(); + + const reportCard = await screen.findByTestId("cc-syscontrol-report-bug"); + fireEvent.click(within(reportCard).getByRole("button", { name: "Report" })); + + await waitFor(() => expect(openSpy).toHaveBeenCalledTimes(1)); + expect(confirmSpy).toHaveBeenCalledTimes(1); + const url = openSpy.mock.calls[0]?.[0] as string; + const body = decodeURIComponent(url.split("?body=")[1] ?? ""); + expect(url).toContain("github.com"); + expect(url).toContain("/issues/new"); + expect(body).toContain("### What happened"); + expect(body).toContain("### Environment"); + expect(body).toContain("### Diagnostics"); + expect(body).toContain("
Diagnostics"); + expect(body).toContain('"recentLogs"'); + expect(body).toContain("boom"); + + confirmSpy.mockRestore(); + openSpy.mockRestore(); + }); + + it("omits the diagnostics block from the bug report body when the operator declines", async () => { + const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false); + const openSpy = vi.spyOn(window, "open").mockReturnValue(null); + await renderSystemTab(); + + const reportCard = await screen.findByTestId("cc-syscontrol-report-bug"); + fireEvent.click(within(reportCard).getByRole("button", { name: "Report" })); + + await waitFor(() => expect(openSpy).toHaveBeenCalledTimes(1)); + const url = openSpy.mock.calls[0]?.[0] as string; + const body = decodeURIComponent(url.split("?body=")[1] ?? ""); + expect(body).toContain("### What happened"); + expect(body).toContain("### Environment"); + expect(body).not.toContain("### Diagnostics"); + + confirmSpy.mockRestore(); + openSpy.mockRestore(); + }); + + it("neutralizes embedded code fences in log messages so they cannot break out of the diagnostics fence", async () => { + mockFetchSystemLogs.mockResolvedValue({ + entries: [{ timestamp: "2026-07-12T00:00:00.000Z", level: "error", message: "```oops``` breakout" }], + }); + const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); + const openSpy = vi.spyOn(window, "open").mockReturnValue(null); + await renderSystemTab(); + + const reportCard = await screen.findByTestId("cc-syscontrol-report-bug"); + fireEvent.click(within(reportCard).getByRole("button", { name: "Report" })); + + await waitFor(() => expect(openSpy).toHaveBeenCalledTimes(1)); + const url = openSpy.mock.calls[0]?.[0] as string; + const body = decodeURIComponent(url.split("?body=")[1] ?? ""); + // The fenced JSON block should contain no raw ``` other than the fence delimiters themselves. + const jsonBlockMatch = body.match(/```json\n([\s\S]*?)\n```/); + expect(jsonBlockMatch).not.toBeNull(); + expect(jsonBlockMatch?.[1]).not.toContain("```"); + expect(jsonBlockMatch?.[1]).toContain("'''oops''' breakout"); + + confirmSpy.mockRestore(); + openSpy.mockRestore(); + }); + + it("truncates an oversized diagnostics bundle in the bug report body with the truncation cap marker", async () => { + mockFetchSystemLogs.mockResolvedValue({ + entries: Array.from({ length: 100 }, (_, i) => ({ + timestamp: "2026-07-12T00:00:00.000Z", + level: "error" as const, + message: `error-line-${i}-${"x".repeat(100)}`, + })), + }); + const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); + const openSpy = vi.spyOn(window, "open").mockReturnValue(null); + await renderSystemTab(); + + const reportCard = await screen.findByTestId("cc-syscontrol-report-bug"); + fireEvent.click(within(reportCard).getByRole("button", { name: "Report" })); + + await waitFor(() => expect(openSpy).toHaveBeenCalledTimes(1)); + const url = openSpy.mock.calls[0]?.[0] as string; + const body = decodeURIComponent(url.split("?body=")[1] ?? ""); + expect(body).toContain("\u2026(truncated)"); + expect(body.length).toBeLessThanOrEqual(5500 + "\n\u2026(truncated)".length); + + confirmSpy.mockRestore(); + openSpy.mockRestore(); + }); + it("keeps the System controls header row override active on mobile", () => { const css = readFileSync(join(process.cwd(), "app/components/command-center/areas/SystemControlsArea.css"), "utf8"); diff --git a/packages/dashboard/app/components/command-center/areas/SystemControlsArea.tsx b/packages/dashboard/app/components/command-center/areas/SystemControlsArea.tsx index ef714402ae..320df4d57e 100644 --- a/packages/dashboard/app/components/command-center/areas/SystemControlsArea.tsx +++ b/packages/dashboard/app/components/command-center/areas/SystemControlsArea.tsx @@ -371,29 +371,31 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr [buildDiagnostics, runAction, t, toast], ); + /* + FNXC:SystemPanel 2026-07-12-15:30: + Requirement change (FN-7883): the bug-report flow previously offered only the + last 5 error log lines behind confirmation. That gave maintainers too little + context for a first triage pass. Now doReportBug reuses the exact same + buildDiagnostics() bundle that "Copy diagnostics" produces (health, + runtime/system info, recent logs) and asks a single confirmation question + covering that whole bundle. The confirm gate, fenceSafe neutralization, and + BUG_URL_BODY_CAP truncation are preserved unchanged because this content is + still sent to a public github.com issue and must never be included without + explicit operator consent, must not let a log line break out of the fenced + code block, and must not produce an over-length URL. + */ const doReportBug = useCallback( () => runAction("report-bug", async () => { const health = await fetchDashboardHealth().catch(() => null); - const recentErrors = info?.logsSupported - ? await fetchSystemLogs(200) - .then((r) => r.entries.filter((entry) => entry.level === "error").slice(-5)) - .catch(() => []) - : []; - // FNXC:SystemPanel 2026-07-12-14:05: The recent-errors excerpt is - // server log content sent to github.com. Require explicit confirmation - // before including it (operator may not want internal logs public), and - // neutralize embedded ``` so a log line can't break out of the fence. - const includeErrors = - recentErrors.length > 0 && - window.confirm( - t( - "systemControls.reportBugConfirm", - "Include the last {{count}} server error log line(s) in the GitHub issue? They will be sent to github.com — review after the issue opens.", - { count: recentErrors.length }, - ), - ); const fenceSafe = (text: string) => text.replace(/`/g, "'"); + const includeDiagnostics = window.confirm( + t( + "systemControls.reportBugConfirm", + "Include diagnostic info and recent logs (health, runtime info, recent server logs) in the GitHub issue? They will be sent to github.com — review after the issue opens.", + ), + ); + const diagnostics = includeDiagnostics ? await buildDiagnostics() : null; let body = [ "### What happened", "", @@ -404,14 +406,23 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr `- Platform: ${info?.platform ?? "unknown"} (${info?.arch ?? "?"}), Node ${info?.nodeVersion ?? "?"}`, `- Uptime: ${info?.uptimeSeconds ?? "?"}s, supervised: ${info?.supervised ?? false}`, "", - ...(includeErrors - ? ["### Recent server errors", "```", ...recentErrors.map((entry) => fenceSafe(`${entry.prefix ? `[${entry.prefix}] ` : ""}${entry.message}`)), "```"] + ...(diagnostics + ? [ + "### Diagnostics", + "
Diagnostics", + "", + "```json", + fenceSafe(JSON.stringify(diagnostics, null, 2)), + "```", + "", + "
", + ] : []), ].join("\n"); if (body.length > BUG_URL_BODY_CAP) body = `${body.slice(0, BUG_URL_BODY_CAP)}\n…(truncated)`; window.open(`${GITHUB_NEW_ISSUE_URL}?body=${encodeURIComponent(body)}`, "_blank", "noopener"); }), - [info, runAction], + [buildDiagnostics, info, runAction, t], ); // ── Control definitions ───────────────────────────────────────────────────