FN-8406: consolidate Command Center report actions
Move the guided report flow to System while ensuring its menu reliably overlays dashboard content. - Portal the shared ReportActionMenu with a defined dropdown/modal stacking scale. - Make System the sole Command Center report home and retain Copy diagnostics as a local action. - Update regression coverage, documentation, and the published package changeset. Files changed: .changeset/fn-8406-report-home-zindex.md | 7 ++ docs/dashboard-guide.md | 4 +- .../dashboard/app/components/ReportActionMenu.css | 5 +- .../dashboard/app/components/ReportActionMenu.tsx | 72 ++++++++++++- .../components/__tests__/ReportActionMenu.test.tsx | 12 +++ .../components/command-center/CommandCenter.css | 32 ------ .../components/command-center/CommandCenter.tsx | 59 ++++------- .../__tests__/CommandCenter.test.tsx | 8 +- .../__tests__/SystemControlsArea.test.tsx | 94 +---------------- .../command-center/areas/SystemControlsArea.tsx | 112 ++++----------------- packages/dashboard/app/styles.css | 11 ++ 11 files changed, 144 insertions(+), 272 deletions(-) Fusion-Task-Id: FN-8406 Fusion-Task-Lineage: 957a77b0-fb04-4024-9506-225fec562f5d Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8406-report-home-zindex.md
Normal file
7
.changeset/fn-8406-report-home-zindex.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Fix Report menu stacking and move Command Center reports to System.
|
||||
category: fix
|
||||
dev: Define --z-dropdown/--z-modal; Overview report card removed; System uses guided ReportActionMenu.
|
||||
@@ -1264,7 +1264,7 @@ GitLab settings are collapsed by default to keep Settings less noisy. Use **Sett
|
||||
When `gitlabEnabled` is off, Fusion keeps saved GitLab URLs and tokens intact but disables outbound GitLab API work: the Import Tasks GitLab provider tab is hidden and restored GitLab import state opens on GitHub instead, API/CLI/pi import paths reject before network calls, and lifecycle comments/close/reconcile/refresh paths skip with diagnostics. Existing imported-task GitLab metadata remains viewable. GitHub imports and GitHub settings are unchanged. GitLab Signals inbound webhooks are configured separately by `FUSION_SIGNAL_GITLAB_SECRET`; they are not governed by the outbound GitLab API enable toggle.
|
||||
|
||||
- **Signals** is backed by the project-scoped `/api/command-center/signals` endpoint, which aggregates real rows from the local `incidents` table. Verified external connectors (`POST /api/signals/gitlab`, `/webhook`, `/sentry`, `/datadog`, and `/pagerduty`) create triage tasks and also write/resolve incidents, so Signals shows total/open/resolved counts, MTTR when resolved incidents have enough timestamps, and source/severity/status breakdowns from connector traffic. GitLab supports GitLab.com and self-managed project/group issue and merge-request webhooks through the environment-only `FUSION_SIGNAL_GITLAB_SECRET` and `X-Gitlab-Token` header; no GitLab CLI or server-side link fetch is used. Signals adds an open-vs-resolved status pie from the same response. Signals has no per-day series today, so it intentionally does not render a line chart or fabricate a trend. The companion `/api/command-center/signals/connectors` endpoint returns only per-provider configured booleans, allowing the empty state to distinguish "no connector configured" from "connector configured, awaiting signals" without exposing secrets.
|
||||
- **System** is the canonical system-telemetry destination. It reads local telemetry from `GET /api/system-stats` and, when multiple registered nodes exist, shows a node selector that can proxy the same system-stats payload through `GET /api/nodes/:id/system-stats` for remote nodes. It renders live radial gauges for app CPU, host memory, and heap usage, keeps a small client-side rolling buffer for CPU/memory/heap trend sparklines, adds a recharts CPU/memory/heap line from that same rolling buffer, and adds a task-by-column pie alongside the existing tasks-by-column and agents-by-state bars. Host memory uses OS-available memory (Node `process.availableMemory()` when available, with a flagged `freemem` fallback) so macOS inactive/cache pages are not reported as used. The Vitest process count, manual kill confirmation, auto-kill toggle, threshold controls, and last-auto-kill timestamp moved here unchanged; the standalone System Stats modal and its desktop Header/mobile More affordances were removed.
|
||||
- **System** is the canonical system-telemetry destination and Command Center report home. Its guided **Report** menu files Bug, Feedback, Idea, or Help reports without opening a raw prefilled GitHub issue; **Copy diagnostics** remains a separate local control. System reads local telemetry from `GET /api/system-stats` and, when multiple registered nodes exist, shows a node selector that can proxy the same system-stats payload through `GET /api/nodes/:id/system-stats` for remote nodes. It renders live radial gauges for app CPU, host memory, and heap usage, keeps a small client-side rolling buffer for CPU/memory/heap trend sparklines, adds a recharts CPU/memory/heap line from that same rolling buffer, and adds a task-by-column pie alongside the existing tasks-by-column and agents-by-state bars. Host memory uses OS-available memory (Node `process.availableMemory()` when available, with a flagged `freemem` fallback) so macOS inactive/cache pages are not reported as used. The Vitest process count, manual kill confirmation, auto-kill toggle, threshold controls, and last-auto-kill timestamp moved here unchanged; the standalone System Stats modal and its desktop Header/mobile More affordances were removed.
|
||||
- **Mission Control** shows live active sessions/runs/nodes, current sessions and nodes, an animated live activity snapshot, and a live SDLC funnel; when idle it reports that live updates resume when work starts. No additional pie or line chart is rendered because the live SDLC funnel already visualizes the panel's only quantitative distribution (`snapshot.columns`), while sessions/nodes are live control lists rather than categorical analytics. Motion-heavy accents respect reduced-motion preferences.
|
||||
- CSV exports are available from the analytics endpoints with `?format=csv`. The Tokens CSV includes `nTasks` and `nChatMessages` columns so mixed task/chat totals can be reconciled without relabeling chat turns as tasks. The Workflows CSV includes one row per workflow plus a summary row; the Activity CSV includes daily `agentRuns` values plus summary rows for `(agentRuns.total)`, `(agentRuns.active)`, `(agentRuns.completed)`, and `(agentRuns.failed)`.
|
||||
|
||||
@@ -2117,7 +2117,7 @@ In **Settings → General**, choose up to six Mobile footer quick actions from t
|
||||
|
||||
## In-app reports
|
||||
|
||||
The **Report** menu is available in **Settings → General · Project** and the Command Center **Overview** on desktop and mobile. It offers **Bug**, **Feedback**, **Idea**, and **Help**; each action begins with a short, guided prompt rather than a raw GitHub issue form.
|
||||
The **Report** menu is available in **Settings → General · Project** and the Command Center **System** tab on desktop and mobile. It offers **Bug**, **Feedback**, **Idea**, and **Help**; each action begins with a short, guided prompt rather than a raw GitHub issue form. The System control is the Command Center report home; **Copy diagnostics** remains a separate local control.
|
||||
|
||||
Fusion gathers available task/agent context, structures the prompt into a report, scrubs secrets, local paths, project names, home-directory identities, email addresses, and likely personal names, then checks **open** GitHub issues or Discussions for duplicates. Scrubbing is mandatory for every route and is repeated on the server when a reviewed draft is edited before filing. A strong duplicate receives a visible 👍 reaction and one scrubbed data-point comment instead of a new issue or Discussion. Bug and Idea reports use issues; Feedback and unresolved Help reports use repository Discussions. If preparation or filing cannot reach GitHub, Fusion preserves the draft and shows a retryable error.
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
.report-action-menu { position: relative; }
|
||||
.report-action-menu__list { position: absolute; z-index: var(--z-dropdown); right: 0; display: grid; gap: var(--space-1); min-inline-size: var(--space-48); padding: var(--space-2); border-radius: var(--radius-md); background: var(--bg-elevated); box-shadow: var(--shadow-lg); }
|
||||
.report-action-menu__list { z-index: var(--z-dropdown); display: grid; gap: var(--space-1); min-inline-size: var(--space-48); padding: var(--space-2); border-radius: var(--radius-md); background: var(--bg-elevated); box-shadow: var(--shadow-lg); }
|
||||
.report-action-menu__list--portal { position: fixed; }
|
||||
.report-action-menu__item { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-2) var(--space-3); border: 0; border-radius: var(--radius-sm); color: var(--text-primary); background: transparent; text-align: left; }
|
||||
.report-action-menu__item:hover, .report-action-menu__item:focus-visible { background: var(--bg-hover); }
|
||||
.report-action-menu__item svg { inline-size: 1em; block-size: 1em; }
|
||||
@media (max-width: 768px) { .report-action-menu__list { right: auto; left: 0; } }
|
||||
@media (max-width: 768px) { .report-action-menu__list--portal { right: auto !important; } }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Bug, Lightbulb, LifeBuoy, MessageSquare } from "lucide-react";
|
||||
import type { ReportActionType } from "@fusion/core";
|
||||
import "./ReportActionMenu.css";
|
||||
@@ -13,10 +14,71 @@ const actions: Array<{ type: ReportActionType; label: string; Icon: typeof Bug }
|
||||
/** Four guided entry points share the same report pipeline rather than issue textboxes. */
|
||||
export function ReportActionMenu({ onSelect }: { onSelect: (action: ReportActionType) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [position, setPosition] = useState<{ top: number; left: number; right: number }>();
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const reposition = useCallback(() => {
|
||||
const rect = triggerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
setPosition({ top: rect.bottom, left: rect.left, right: window.innerWidth - rect.right });
|
||||
}, []);
|
||||
|
||||
/*
|
||||
FNXC:ReportPipeline 2026-07-19-14:00:
|
||||
FN-8406 requires the shared Report menu to escape Command Center and Settings
|
||||
scroll owners. Portal it to document.body and keep it fixed to the trigger so
|
||||
the guided actions cannot be clipped beneath surrounding dashboard content.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
reposition();
|
||||
const handlePositionChange = () => reposition();
|
||||
window.addEventListener("resize", handlePositionChange);
|
||||
window.addEventListener("scroll", handlePositionChange, true);
|
||||
return () => {
|
||||
window.removeEventListener("resize", handlePositionChange);
|
||||
window.removeEventListener("scroll", handlePositionChange, true);
|
||||
};
|
||||
}, [open, reposition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const closeOnOutsideClick = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (triggerRef.current?.contains(target) || menuRef.current?.contains(target)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", closeOnOutsideClick);
|
||||
document.addEventListener("keydown", closeOnEscape);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", closeOnOutsideClick);
|
||||
document.removeEventListener("keydown", closeOnEscape);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const menu = open && position && typeof document !== "undefined"
|
||||
? createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="report-action-menu__list report-action-menu__list--portal"
|
||||
role="menu"
|
||||
style={{ top: position.top, left: position.left, right: position.right }}
|
||||
>
|
||||
{actions.map(({ type, label, Icon }) => <button className="report-action-menu__item" type="button" role="menuitem" key={type} onClick={() => { setOpen(false); onSelect(type); }}><Icon aria-hidden="true" />{label}</button>)}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null;
|
||||
|
||||
return <div className="report-action-menu">
|
||||
<button className="btn btn-secondary" type="button" aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen((value) => !value)}>Report</button>
|
||||
{open && <div className="report-action-menu__list" role="menu">
|
||||
{actions.map(({ type, label, Icon }) => <button className="report-action-menu__item" type="button" role="menuitem" key={type} onClick={() => { setOpen(false); onSelect(type); }}><Icon aria-hidden="true" />{label}</button>)}
|
||||
</div>}
|
||||
<button ref={triggerRef} className="btn btn-secondary" type="button" aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen((value) => !value)}>Report</button>
|
||||
{menu}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ReportActionMenu } from "../ReportActionMenu";
|
||||
|
||||
describe("ReportActionMenu", () => {
|
||||
it("defines the portal stacking contract instead of relying on an undefined z-index", () => {
|
||||
const styles = readFileSync(join(process.cwd(), "app/styles.css"), "utf8");
|
||||
const menuStyles = readFileSync(join(process.cwd(), "app/components/ReportActionMenu.css"), "utf8");
|
||||
|
||||
expect(styles).toMatch(/--z-dropdown:\s*\d+/);
|
||||
expect(styles).toMatch(/--z-modal:\s*\d+/);
|
||||
expect(menuStyles).toMatch(/\.report-action-menu__list\s*{[^}]*z-index:\s*var\(--z-dropdown\)/s);
|
||||
expect(menuStyles).toMatch(/\.report-action-menu__list--portal\s*{[^}]*position:\s*fixed/s);
|
||||
});
|
||||
|
||||
it("exposes all four guided actions without empty controls", () => {
|
||||
const onSelect = vi.fn();
|
||||
render(<ReportActionMenu onSelect={onSelect} />);
|
||||
|
||||
@@ -711,35 +711,3 @@ Add a deliberate vertical gap between the Start/Stop AI Engine action and the Vi
|
||||
flex: 1 1 auto;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:ReportPipeline 2026-07-18-19:35:
|
||||
FN-8348 keeps the Command Center report home in the Overview card rhythm. The
|
||||
row wraps at the shared mobile breakpoint so its guided action remains usable
|
||||
without creating a second scroll owner or a one-off button treatment.
|
||||
*/
|
||||
.cc-report-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.cc-report-actions__title {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.cc-report-actions p {
|
||||
margin: var(--space-xs) 0 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.cc-report-actions {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertCircle, Gauge } from "lucide-react";
|
||||
import type { ActivityAnalytics, ColorTheme, LiveSnapshot, ReportActionType, SignalsAnalytics, ThemeMode, TokenAnalytics, ToolAnalytics, TaskVerificationRequest } from "@fusion/core";
|
||||
import type { ActivityAnalytics, ColorTheme, LiveSnapshot, SignalsAnalytics, ThemeMode, TokenAnalytics, ToolAnalytics, TaskVerificationRequest } from "@fusion/core";
|
||||
import { api, fetchCodebaseMetrics, withProjectId, type CodebaseMetrics } from "../../api/legacy";
|
||||
import { formatBytes } from "../../utils/formatBytes";
|
||||
import { DateRangePicker, defaultPresets, rangeFromPreset, type DateRange } from "./DateRangePicker";
|
||||
import { LoadingSpinner } from "../LoadingSpinner";
|
||||
import { TaskVerificationStatus } from "../TaskVerificationStatus";
|
||||
import { ReportActionMenu } from "../ReportActionMenu";
|
||||
import { ReportModal } from "../ReportModal";
|
||||
import { resolveReportContextRefs } from "../../utils/reportContextRefs";
|
||||
import { TokensArea } from "./areas/TokensArea";
|
||||
import { ToolsArea } from "./areas/ToolsArea";
|
||||
import { ActivityArea } from "./areas/ActivityArea";
|
||||
@@ -152,30 +149,6 @@ interface CommandCenterProps {
|
||||
onChangeView?: (view: TaskView) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:ReportPipeline 2026-07-18-19:35:
|
||||
* FN-8348 makes the Command Center Overview a second canonical report home.
|
||||
* Keeping the trigger with its modal in this always-rendered controls fragment
|
||||
* preserves the guided pipeline and task/agent deep-link context in loading,
|
||||
* empty, error, and populated dashboard states.
|
||||
*/
|
||||
function ReportActionCard() {
|
||||
const { t } = useTranslation("app");
|
||||
const [reportAction, setReportAction] = useState<ReportActionType | null>(null);
|
||||
const contextRefs = typeof window === "undefined" ? undefined : resolveReportContextRefs(window.location);
|
||||
|
||||
return (
|
||||
<section className="card cc-report-actions" data-testid="command-center-report-actions">
|
||||
<div>
|
||||
<h3 className="cc-report-actions__title">{t("commandCenter.report.title", "Report")}</h3>
|
||||
<p>{t("commandCenter.report.description", "Report a bug, send feedback, share an idea, or get help.")}</p>
|
||||
</div>
|
||||
<ReportActionMenu onSelect={setReportAction} />
|
||||
{reportAction && <ReportModal actionType={reportAction} contextRefs={contextRefs} onClose={() => setReportAction(null)} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewTab({
|
||||
range,
|
||||
projectId,
|
||||
@@ -395,21 +368,23 @@ function OverviewTab({
|
||||
FNXC:CommandCenter 2026-06-22-20:55:
|
||||
The Overview's AI-engine controls are a SINGLE instance: the CommandCenterControls "AI engine" card (Stop AI Engine) now also hosts the "View Board"/"View Agents" shortcuts (threaded onChangeView). The earlier duplicate `.cc-overview-engine-panel` (a second AI Engine row) was removed — the buttons moved into the first instance.
|
||||
*/
|
||||
/*
|
||||
FNXC:CommandCenter 2026-07-19-14:00:
|
||||
FN-8406 makes System the sole Command Center report home. Overview keeps only
|
||||
operational controls and analytics, avoiding a duplicate report affordance.
|
||||
*/
|
||||
const controlsSection = (
|
||||
<>
|
||||
<CommandCenterControls
|
||||
projectId={projectId}
|
||||
colorTheme={colorTheme}
|
||||
themeMode={themeMode}
|
||||
shadcnCustomColors={shadcnCustomColors}
|
||||
resolvedThemeMode={resolvedThemeMode}
|
||||
onColorThemeChange={onColorThemeChange}
|
||||
onThemeModeChange={onThemeModeChange}
|
||||
onShadcnCustomColorsChange={onShadcnCustomColorsChange}
|
||||
onChangeView={onChangeView}
|
||||
/>
|
||||
<ReportActionCard />
|
||||
</>
|
||||
<CommandCenterControls
|
||||
projectId={projectId}
|
||||
colorTheme={colorTheme}
|
||||
themeMode={themeMode}
|
||||
shadcnCustomColors={shadcnCustomColors}
|
||||
resolvedThemeMode={resolvedThemeMode}
|
||||
onColorThemeChange={onColorThemeChange}
|
||||
onThemeModeChange={onThemeModeChange}
|
||||
onShadcnCustomColorsChange={onShadcnCustomColorsChange}
|
||||
onChangeView={onChangeView}
|
||||
/>
|
||||
);
|
||||
const throughputSection = (
|
||||
<div className="cc-overview-throughput" data-testid="command-center-throughput">
|
||||
|
||||
@@ -483,15 +483,11 @@ describe("CommandCenter shell", () => {
|
||||
expect(screen.queryByTestId("cc-controls-heartbeat")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the report menu and opens its guided modal in every Overview state", async () => {
|
||||
it("does not retain a duplicate report entry on Overview", () => {
|
||||
mockEmptyOverviewApi();
|
||||
render(<CommandCenter />);
|
||||
|
||||
expect(screen.getByTestId("command-center-report-actions")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Report" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Report bug" }));
|
||||
|
||||
expect(await screen.findByRole("dialog", { name: "bug report" })).toBeTruthy();
|
||||
expect(screen.queryByTestId("command-center-report-actions")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders throughput last while the Overview branch is loading", () => {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { CommandCenter } from "../CommandCenter";
|
||||
import { BUG_URL_MAX_ENCODED } from "../areas/SystemControlsArea";
|
||||
|
||||
const apiMock = vi.fn();
|
||||
const mockFetchSystemInfo = vi.fn();
|
||||
@@ -272,101 +271,16 @@ 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);
|
||||
it("opens the guided report modal instead of a legacy GitHub window", async () => {
|
||||
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" }));
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Report bug" }));
|
||||
|
||||
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("<details><summary>Diagnostics</summary>");
|
||||
expect(body).toContain('"recentLogs"');
|
||||
expect(body).toContain("boom");
|
||||
expect(body).not.toContain("\u2026(truncated)");
|
||||
expect(url.length).toBeLessThanOrEqual(BUG_URL_MAX_ENCODED);
|
||||
|
||||
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 against the encoded GitHub URL ceiling", async () => {
|
||||
mockFetchSystemLogs.mockResolvedValue({
|
||||
entries: Array.from({ length: 100 }, (_, i) => ({
|
||||
timestamp: "2026-07-12T00:00:00.000Z",
|
||||
level: "error" as const,
|
||||
message: `error-line-${i}-{\"quoted\":\"${"x".repeat(100)}\",\"nested\":{\"value\":\"${"y".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(url.length).toBeLessThanOrEqual(BUG_URL_MAX_ENCODED);
|
||||
expect(body).toContain("\u2026(truncated)");
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
expect(await screen.findByRole("dialog", { name: "bug report" })).toBeTruthy();
|
||||
expect(openSpy).not.toHaveBeenCalled();
|
||||
openSpy.mockRestore();
|
||||
});
|
||||
|
||||
|
||||
@@ -37,7 +37,11 @@ import {
|
||||
type UpdateCheckResponse,
|
||||
} from "../../../api/legacy";
|
||||
import { subscribeSse } from "../../../sse-bus";
|
||||
import type { ReportActionType } from "@fusion/core";
|
||||
import type { ToastType } from "../../../hooks/useToast";
|
||||
import { ReportActionMenu } from "../../ReportActionMenu";
|
||||
import { ReportModal } from "../../ReportModal";
|
||||
import { resolveReportContextRefs } from "../../../utils/reportContextRefs";
|
||||
import { copyTextToClipboard } from "../../../utils/copyToClipboard";
|
||||
import "./SystemControlsArea.css";
|
||||
|
||||
@@ -73,39 +77,12 @@ const BACK_ONLINE_RELOAD_DELAY_MS = 3000;
|
||||
// respawn, unsupervised restart that stopped) doesn't leave the panel polling
|
||||
// forever with every control disabled.
|
||||
const RESTART_WAIT_TIMEOUT_MS = 90_000;
|
||||
export const BUG_URL_MAX_ENCODED = 8000;
|
||||
const BUG_URL_TRUNCATION_MARKER = "\n…(truncated)";
|
||||
const BUG_URL_BODY_QUERY_PREFIX = "?body=";
|
||||
const GITHUB_NEW_ISSUE_URL = "https://github.com/Runfusion/Fusion/issues/new";
|
||||
const BOTTOM_FOLLOW_THRESHOLD_PX = 50;
|
||||
|
||||
function isNearBottom(container: HTMLElement): boolean {
|
||||
return container.scrollHeight - (container.scrollTop + container.clientHeight) <= BOTTOM_FOLLOW_THRESHOLD_PX;
|
||||
}
|
||||
|
||||
function buildBugReportIssueUrl(body: string): string {
|
||||
const prefix = `${GITHUB_NEW_ISSUE_URL}${BUG_URL_BODY_QUERY_PREFIX}`;
|
||||
const toUrl = (candidate: string) => `${prefix}${encodeURIComponent(candidate)}`;
|
||||
const fullUrl = toUrl(body);
|
||||
if (fullUrl.length <= BUG_URL_MAX_ENCODED) return fullUrl;
|
||||
|
||||
const encodedBudget = BUG_URL_MAX_ENCODED - prefix.length;
|
||||
const codePoints = Array.from(body);
|
||||
let low = 0;
|
||||
let high = codePoints.length;
|
||||
while (low < high) {
|
||||
const mid = Math.ceil((low + high) / 2);
|
||||
const candidate = `${codePoints.slice(0, mid).join("")}${BUG_URL_TRUNCATION_MARKER}`;
|
||||
if (encodeURIComponent(candidate).length <= encodedBudget) {
|
||||
low = mid;
|
||||
} else {
|
||||
high = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
return toUrl(`${codePoints.slice(0, low).join("")}${BUG_URL_TRUNCATION_MARKER}`);
|
||||
}
|
||||
|
||||
type RestartPhase = null | "waiting" | "back" | "timeout";
|
||||
|
||||
interface SystemControlsAreaProps {
|
||||
@@ -128,6 +105,8 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
|
||||
const [info, setInfo] = useState<SystemInfoResponse | null>(null);
|
||||
const [infoError, setInfoError] = useState<string | null>(null);
|
||||
const [busyAction, setBusyAction] = useState<string | null>(null);
|
||||
const [reportAction, setReportAction] = useState<ReportActionType | null>(null);
|
||||
const reportContextRefs = typeof window === "undefined" ? undefined : resolveReportContextRefs(window.location);
|
||||
|
||||
const [job, setJob] = useState<SystemRebuildJobSnapshot | null>(null);
|
||||
const [jobLines, setJobLines] = useState<SystemRebuildJobLine[]>([]);
|
||||
@@ -524,62 +503,11 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
|
||||
);
|
||||
|
||||
/*
|
||||
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.
|
||||
|
||||
FNXC:SystemPanel 2026-07-12-18:41:
|
||||
Requirement change (FN-7890): truncation is budgeted against the full URL that
|
||||
GitHub receives: base issue URL + ?body= + encodeURIComponent(body). FN-7883's
|
||||
diagnostics JSON includes quotes, braces, and newlines that expand during
|
||||
percent-encoding, so the old raw body-length cap could still emit a request
|
||||
URL too long for GitHub. The confirm gate, fenceSafe neutralization, and body
|
||||
sections remain unchanged while the final window.open URL is kept under the
|
||||
encoded ceiling.
|
||||
FNXC:SystemPanel 2026-07-19-14:00:
|
||||
FN-8406 consolidates Command Center reporting here. This card opens the
|
||||
guided ReportActionMenu and ReportModal; Copy diagnostics remains a separate
|
||||
control instead of embedding local system data in a window.open GitHub URL.
|
||||
*/
|
||||
const doReportBug = useCallback(
|
||||
() =>
|
||||
runAction("report-bug", async () => {
|
||||
const health = await fetchDashboardHealth().catch(() => null);
|
||||
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;
|
||||
const body = [
|
||||
"### What happened",
|
||||
"",
|
||||
"<!-- Describe the bug -->",
|
||||
"",
|
||||
"### Environment",
|
||||
`- Fusion version: ${health?.version ?? "unknown"}`,
|
||||
`- Platform: ${info?.platform ?? "unknown"} (${info?.arch ?? "?"}), Node ${info?.nodeVersion ?? "?"}`,
|
||||
`- Uptime: ${info?.uptimeSeconds ?? "?"}s, supervised: ${info?.supervised ?? false}`,
|
||||
"",
|
||||
...(diagnostics
|
||||
? [
|
||||
"### Diagnostics",
|
||||
"<details><summary>Diagnostics</summary>",
|
||||
"",
|
||||
"```json",
|
||||
fenceSafe(JSON.stringify(diagnostics, null, 2)),
|
||||
"```",
|
||||
"",
|
||||
"</details>",
|
||||
]
|
||||
: []),
|
||||
].join("\n");
|
||||
window.open(buildBugReportIssueUrl(body), "_blank", "noopener");
|
||||
}),
|
||||
[buildDiagnostics, info, runAction, t],
|
||||
);
|
||||
|
||||
// ── Control definitions ───────────────────────────────────────────────────
|
||||
const restartDisabledNote = info && !info.restartSupported
|
||||
@@ -757,16 +685,6 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
|
||||
run: () => void doCopyDiagnostics(),
|
||||
testId: "cc-syscontrol-diagnostics",
|
||||
},
|
||||
{
|
||||
key: "report-bug",
|
||||
icon: Bug,
|
||||
title: t("systemControls.reportBug", "Report a bug"),
|
||||
description: t("systemControls.reportBugDesc", "Open a prefilled GitHub issue with environment details."),
|
||||
cta: t("systemControls.reportBugCta", "Report"),
|
||||
disabled: false,
|
||||
run: () => void doReportBug(),
|
||||
testId: "cc-syscontrol-report-bug",
|
||||
},
|
||||
],
|
||||
[
|
||||
beginFnLinkLocal,
|
||||
@@ -778,7 +696,6 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
|
||||
doCopyDiagnostics,
|
||||
doEngineRestart,
|
||||
doReloadPlugins,
|
||||
doReportBug,
|
||||
doRestart,
|
||||
info,
|
||||
showFnLinkLocal,
|
||||
@@ -898,7 +815,16 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="card cc-syscontrol-card" data-testid="cc-syscontrol-report-bug">
|
||||
<div className="cc-syscontrol-head">
|
||||
<Bug size={18} className="cc-syscontrol-icon" aria-hidden />
|
||||
<span className="cc-syscontrol-title">{t("systemControls.reportBug", "Report")}</span>
|
||||
</div>
|
||||
<p className="cc-syscontrol-desc">{t("systemControls.reportBugDesc", "Report a bug, send feedback, share an idea, or get help through a guided prompt.")}</p>
|
||||
<div className="cc-syscontrol-cta"><ReportActionMenu onSelect={setReportAction} /></div>
|
||||
</div>
|
||||
</div>
|
||||
{reportAction ? <ReportModal actionType={reportAction} contextRefs={reportContextRefs} onClose={() => setReportAction(null)} /> : null}
|
||||
</div>
|
||||
|
||||
{job ? (
|
||||
|
||||
@@ -212,6 +212,17 @@ html {
|
||||
--shadow-sm: 0 1px 2px color-mix(in srgb, #000000 10%, transparent);
|
||||
--shadow-md: 0 4px 6px color-mix(in srgb, #000000 10%, transparent);
|
||||
--shadow-lg: 0 4px 24px color-mix(in srgb, #000000 40%, transparent);
|
||||
|
||||
/*
|
||||
FNXC:DashboardStyling 2026-07-19-14:00:
|
||||
FN-8406 defines the report-menu/modal stacking scale because both components
|
||||
previously referenced undefined tokens, silently resolving z-index to auto
|
||||
and letting dashboard scroll owners clip or cover guided report actions.
|
||||
*/
|
||||
--z-popover: 60;
|
||||
--z-dropdown: 1000;
|
||||
--z-modal: 1100;
|
||||
|
||||
--shadow-glow: 0 0 8px color-mix(in srgb, var(--todo) 30%, transparent);
|
||||
--glow-success: 0 0 8px color-mix(in srgb, var(--cta-border) 30%, transparent);
|
||||
--glow-warning: 0 0 8px color-mix(in srgb, #e3b541 30%, transparent);
|
||||
|
||||
Reference in New Issue
Block a user