feat(web): copy observability debug bundle from dashboard (#1320)
* feat(web): copy observability debug bundle from dashboard - Add CopyDebugBundleButton (desktop hero) calling GET /api/observability - Document in docs/observability.md Made-with: Cursor * fix(web): harden debug bundle copy safety Scope copied observability payloads to the selected project, scrub obvious token patterns from copied strings, and avoid copying on failed/non-JSON observability responses. Also avoid leaking query/hash URL data and add tests for failure branches. Made-with: Cursor
This commit is contained in:
parent
fed25d5d29
commit
e518562a62
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@composio/ao-web": patch
|
||||
---
|
||||
|
||||
Add a dashboard control to copy observability diagnostics and page context to the clipboard for support and issue reports.
|
||||
|
|
@ -80,7 +80,7 @@ Health records provide current status and failure context per surface:
|
|||
|
||||
## Operator-Facing Diagnostics
|
||||
|
||||
- **Dashboard**: observability banner shows overall status, SSE stream state, last correlation id, and latest failure reason.
|
||||
- **Dashboard**: use **Copy debug info** in the hero toolbar (desktop) to copy `/api/observability` plus page URL, project scope, and correlation id to the clipboard for issue reports. The observability banner shows overall status, SSE stream state, last correlation id, and latest failure reason.
|
||||
- **API**: `/api/observability` returns merged per-project diagnostics (`overallStatus`, metrics, health, recent traces, session state).
|
||||
- **Terminal websocket health**: `/health` endpoints include active sessions and websocket/terminal health counters with last error/disconnect reasons.
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,131 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useToast } from "./Toast";
|
||||
|
||||
interface CopyDebugBundleButtonProps {
|
||||
/** Currently selected project filter, if any (from dashboard URL). */
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
const SECRET_PATTERNS = [
|
||||
/\bBearer\s+[A-Za-z0-9._\-+/=]+\b/gi,
|
||||
/\bsk-[A-Za-z0-9]{10,}\b/g,
|
||||
/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g,
|
||||
];
|
||||
|
||||
function redactSecretsInString(value: string): string {
|
||||
return SECRET_PATTERNS.reduce(
|
||||
(current, pattern) => current.replace(pattern, "[REDACTED]"),
|
||||
value,
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizeForClipboard(value: unknown, seen: WeakSet<object> = new WeakSet()): unknown {
|
||||
if (typeof value === "string") {
|
||||
return redactSecretsInString(value);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => sanitizeForClipboard(entry, seen));
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
if (seen.has(value)) return "[Circular]";
|
||||
seen.add(value);
|
||||
const sanitized: Record<string, unknown> = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
sanitized[key] = sanitizeForClipboard(entry, seen);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function scopeObservabilityToProject(observability: unknown, projectId?: string): unknown {
|
||||
if (!projectId || !observability || typeof observability !== "object") {
|
||||
return observability;
|
||||
}
|
||||
|
||||
const scoped = { ...(observability as Record<string, unknown>) };
|
||||
const projects = scoped["projects"];
|
||||
if (projects && typeof projects === "object" && !Array.isArray(projects)) {
|
||||
const projectEntry = (projects as Record<string, unknown>)[projectId];
|
||||
scoped["projects"] = projectEntry === undefined ? {} : { [projectId]: projectEntry };
|
||||
}
|
||||
return scoped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies observability snapshot + page context to the clipboard for GitHub issues / support.
|
||||
*/
|
||||
export function CopyDebugBundleButton({ projectId }: CopyDebugBundleButtonProps) {
|
||||
const { showToast } = useToast();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const handleClick = useCallback(async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fetch("/api/observability", { credentials: "same-origin" });
|
||||
if (!res.ok) {
|
||||
showToast("Could not fetch observability snapshot", "error");
|
||||
return;
|
||||
}
|
||||
const correlationId = res.headers.get("x-correlation-id");
|
||||
|
||||
let observabilityRaw: unknown;
|
||||
try {
|
||||
observabilityRaw = await res.json();
|
||||
} catch {
|
||||
showToast("Could not parse observability snapshot", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const observability = sanitizeForClipboard(
|
||||
scopeObservabilityToProject(observabilityRaw, projectId),
|
||||
);
|
||||
|
||||
const bundle = {
|
||||
copiedAt: new Date().toISOString(),
|
||||
pageHref: `${window.location.origin}${window.location.pathname}`,
|
||||
projectId: projectId ?? null,
|
||||
correlationId,
|
||||
userAgent: navigator.userAgent,
|
||||
observability,
|
||||
};
|
||||
|
||||
await navigator.clipboard.writeText(JSON.stringify(bundle, null, 2));
|
||||
showToast("Debug bundle copied to clipboard", "success");
|
||||
} catch {
|
||||
showToast("Could not copy debug bundle", "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [busy, projectId, showToast]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="orchestrator-btn flex min-h-[44px] min-w-[44px] items-center gap-2 px-4 py-2 text-[12px] font-semibold text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-bg-hover)] disabled:opacity-50"
|
||||
onClick={() => void handleClick()}
|
||||
disabled={busy}
|
||||
aria-label="Copy debug bundle for issue reports"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
Copy debug info
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ import type { ProjectInfo } from "@/lib/project-name";
|
|||
import { EmptyState } from "./Skeleton";
|
||||
import { ToastProvider, useToast } from "./Toast";
|
||||
import { ConnectionBar } from "./ConnectionBar";
|
||||
import { CopyDebugBundleButton } from "./CopyDebugBundleButton";
|
||||
import { SidebarContext } from "./workspace/SidebarContext";
|
||||
|
||||
interface DashboardProps {
|
||||
|
|
@ -489,6 +490,7 @@ function DashboardInner({
|
|||
Orchestrator
|
||||
</a>
|
||||
) : null}
|
||||
{!isMobile ? <CopyDebugBundleButton projectId={projectId} /> : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { ToastProvider } from "../Toast";
|
||||
import { CopyDebugBundleButton } from "../CopyDebugBundleButton";
|
||||
|
||||
const writeText = vi.fn(() => Promise.resolve());
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
function renderButton(projectId?: string) {
|
||||
return render(
|
||||
<ToastProvider>
|
||||
<CopyDebugBundleButton projectId={projectId} />
|
||||
</ToastProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("CopyDebugBundleButton", () => {
|
||||
beforeEach(() => {
|
||||
writeText.mockClear();
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
headers: { get: (name: string) => (name === "x-correlation-id" ? "corr-test" : null) },
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
overallStatus: "ok",
|
||||
projects: {
|
||||
"my-app": { status: "warn", trace: { reason: "Bearer sk-1234567890 token failed" } },
|
||||
"other-app": { status: "ok" },
|
||||
},
|
||||
}),
|
||||
} as Response);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
Object.defineProperty(globalThis.navigator, "clipboard", {
|
||||
value: { writeText },
|
||||
configurable: true,
|
||||
});
|
||||
window.history.replaceState({}, "", "/?token=secret#frag");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("copies observability JSON and shows success toast", async () => {
|
||||
renderButton("my-app");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Copy debug bundle for issue reports/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(writeText).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const written = JSON.parse(writeText.mock.calls[0][0] as string);
|
||||
expect(written.projectId).toBe("my-app");
|
||||
expect(written.correlationId).toBe("corr-test");
|
||||
expect(written.pageHref).toMatch(/^http:\/\/localhost(?::\d+)?\/$/);
|
||||
expect(written.observability.projects).toEqual({
|
||||
"my-app": { status: "warn", trace: { reason: "[REDACTED] token failed" } },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Debug bundle copied to clipboard/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error toast and skips clipboard when observability request fails", async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 502,
|
||||
headers: { get: () => null },
|
||||
} as Response);
|
||||
|
||||
renderButton("my-app");
|
||||
fireEvent.click(screen.getByRole("button", { name: /Copy debug bundle for issue reports/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Could not fetch observability snapshot/i)).toBeInTheDocument();
|
||||
});
|
||||
expect(writeText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows error toast when clipboard write fails", async () => {
|
||||
writeText.mockRejectedValueOnce(new Error("clipboard blocked"));
|
||||
|
||||
renderButton("my-app");
|
||||
fireEvent.click(screen.getByRole("button", { name: /Copy debug bundle for issue reports/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Could not copy debug bundle/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue