From b382e5678e35e6f71044e45ed6141aaa87f2f348 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Fri, 24 Jul 2026 18:12:49 +0200 Subject: [PATCH] feat(web): "Collect Logs" on-demand diagnostics collection on the session detail page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Queues a request_diagnostics ServerAction (delivered with the agent's next ingest response) and tracks progress live: server_action_received flips the button to "Collecting…", the DiagnosticsBlobName SignalR delta completes it (Download Diagnostics appears), server_action_failed or a 3-min quiet timeout surface as toasts. - Button is ALWAYS rendered — disabled states carry tooltips (inactive session, cross-tenant view, missing role, diagnostics not configured) as a deliberate nudge towards enabling diagnostics upload. - Tenant Admin on an unconfigured tenant gets a quick-config dialog: switches the tenant to Hosted + OnFailure (GET config -> patch two fields -> PUT verbatim), then queues rotate_config BEFORE request_diagnostics so the agent collects against the fresh config. Operators/Viewers never see the dialog. - Operator may collect when configured (new TenantAdminOrOperator backend tier). - useSessionTenantConfig picks up diagnosticsUploadConfigured from the member-readable feature-flags endpoint. - Progress matching is sequence-based (backend-assigned) so agent clock skew and at-least-once action redelivery cannot produce false matches. Pure state logic in collectLogsLogic.ts with vitest matrix (21 tests). tsc clean; web suite 627/627. Co-Authored-By: Claude Fable 5 --- .../components/CollectLogsButton.tsx | 306 ++++++++++++++++++ .../__tests__/collectLogsLogic.test.ts | 183 +++++++++++ .../components/collectLogsLogic.ts | 144 +++++++++ .../hooks/useSessionTenantConfig.ts | 20 +- .../app/sessions/[sessionId]/page.tsx | 18 ++ src/Web/autopilot-monitor-web/lib/api.ts | 4 + 6 files changed, 672 insertions(+), 3 deletions(-) create mode 100644 src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/CollectLogsButton.tsx create mode 100644 src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/__tests__/collectLogsLogic.test.ts create mode 100644 src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/collectLogsLogic.ts diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/CollectLogsButton.tsx b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/CollectLogsButton.tsx new file mode 100644 index 00000000..a416f294 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/CollectLogsButton.tsx @@ -0,0 +1,306 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { EnrollmentEvent } from "@/types"; +import { api } from "@/lib/api"; +import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { NotificationType } from "@/contexts/NotificationContext"; +import { + CollectPhase, + COLLECT_TIMEOUT_MS, + evaluateCollectProgress, + resolveCollectButtonState, +} from "./collectLogsLogic"; + +interface CollectLogsButtonProps { + sessionId: string; + effectiveTenantId?: string; + sessionStatus?: string; + /** Live from the session object (SignalR sessionUpdate delta) — a change means the package landed. */ + diagnosticsBlobName?: string; + isCrossTenantView: boolean; + isTenantAdmin: boolean; + isOperator: boolean; + diagnosticsConfigured: boolean; + /** Called after a successful quick-config so the page-level flag flips without a refetch. */ + onDiagnosticsConfigured: () => void; + events: EnrollmentEvent[]; + getAccessToken: (forceRefresh?: boolean) => Promise; + addNotification: (type: NotificationType, title: string, message: string, key?: string) => void; +} + +/** + * "Collect Logs" — queues a `request_diagnostics` ServerAction that the agent picks up with + * its next ingest response, then tracks progress via the live event stream + * (server_action_received/_executed/_failed) and the DiagnosticsBlobName session delta. + * + * Always rendered, even when disabled: the visible-but-disabled button (with a tooltip + * explaining what is missing) is the deliberate nudge towards configuring diagnostics upload. + * Tenant Admins on an unconfigured tenant get the quick-config dialog instead (enable + * Hosted + OnFailure, then collect immediately) — that dialog IS the explicit opt-in. + */ +export default function CollectLogsButton({ + sessionId, + effectiveTenantId, + sessionStatus, + diagnosticsBlobName, + isCrossTenantView, + isTenantAdmin, + isOperator, + diagnosticsConfigured, + onDiagnosticsConfigured, + events, + getAccessToken, + addNotification, +}: CollectLogsButtonProps) { + const [phase, setPhase] = useState("idle"); + const [showQuickConfig, setShowQuickConfig] = useState(false); + const [quickConfigBusy, setQuickConfigBusy] = useState(false); + + const baselineSequenceRef = useRef(0); + const baselineBlobRef = useRef(undefined); + const timeoutRef = useRef | null>(null); + + const buttonState = resolveCollectButtonState({ + sessionStatus, + isCrossTenantView, + isTenantAdmin, + isOperator, + diagnosticsConfigured, + phase, + }); + + const notifyError = useCallback((err: unknown, fallback: string) => { + if (err instanceof TokenExpiredError) { + addNotification("error", "Session Expired", err.message, "session-expired-error"); + } else { + addNotification("error", "Collect Logs", fallback, "collect-logs"); + } + }, [addNotification]); + + const queueAction = useCallback(async (type: string, reason: string): Promise => { + const res = await authenticatedFetch( + api.sessions.queueAction(sessionId, effectiveTenantId), + getAccessToken, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type, reason }), + }, + ); + return res.ok; + }, [sessionId, effectiveTenantId, getAccessToken]); + + const startCollect = useCallback(async () => { + setPhase("working"); + // Sequence baseline: only server_action events NEWER than the click count as feedback + // for THIS request (at-least-once redelivery of older actions must not match). + baselineSequenceRef.current = events.reduce((max, e) => Math.max(max, e.sequence ?? 0), 0); + baselineBlobRef.current = diagnosticsBlobName; + try { + const ok = await queueAction("request_diagnostics", "On-demand log collection from portal"); + if (!ok) { + setPhase("idle"); + addNotification("error", "Collect Logs", "Failed to queue the log collection request.", "collect-logs"); + return; + } + setPhase("queued"); + addNotification("info", "Collect Logs", + "Request queued — the agent picks it up with its next check-in and uploads a diagnostics package.", + "collect-logs"); + } catch (err) { + setPhase("idle"); + notifyError(err, "Failed to queue the log collection request."); + } + }, [events, diagnosticsBlobName, queueAction, addNotification, notifyError]); + + const handleClick = () => { + if (!buttonState.enabled) return; + if (buttonState.opensQuickConfig) { + setShowQuickConfig(true); + } else { + void startCollect(); + } + }; + + // Quick-config (Admin only): read the full tenant config, flip ONLY the two diagnostics + // fields, write it back verbatim, then rotate_config BEFORE request_diagnostics so the + // agent refetches the now-enabled config before building the package. + const handleQuickConfig = async () => { + if (!effectiveTenantId) return; + setQuickConfigBusy(true); + try { + const getRes = await authenticatedFetch(api.config.tenant(effectiveTenantId), getAccessToken); + if (!getRes.ok) throw new Error(`Failed to load tenant configuration (${getRes.status})`); + const config = await getRes.json(); + + const updated = { + ...config, + diagnosticsUploadDestination: "Hosted", + // OnFailure is the least invasive mode that unlocks on-demand collection (the + // server-requested path always uploads); keep a stricter existing mode untouched. + diagnosticsUploadMode: + !config.diagnosticsUploadMode || config.diagnosticsUploadMode === "Off" + ? "OnFailure" + : config.diagnosticsUploadMode, + }; + + const putRes = await authenticatedFetch(api.config.tenant(effectiveTenantId), getAccessToken, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(updated), + }); + if (!putRes.ok) throw new Error(`Failed to save tenant configuration (${putRes.status})`); + + onDiagnosticsConfigured(); + setShowQuickConfig(false); + + // Order matters: the agent processes queued actions sequentially, so rotate_config + // must be first for the collection to run against the new config. + const rotated = await queueAction("rotate_config", "Enable diagnostics upload (quick config from portal)"); + if (!rotated) { + addNotification("warning", "Collect Logs", + "Configuration saved, but queueing the config refresh failed — try Collect Logs again.", + "collect-logs"); + return; + } + await startCollect(); + } catch (err) { + notifyError(err, err instanceof Error ? err.message : "Quick configuration failed."); + } finally { + setQuickConfigBusy(false); + } + }; + + // Progress watcher: SignalR keeps `events` and the session delta fresh; sequence-based + // matching (see collectLogsLogic) makes this immune to agent clock skew. + useEffect(() => { + const result = evaluateCollectProgress({ + phase, + baselineSequence: baselineSequenceRef.current, + baselineBlobName: baselineBlobRef.current, + currentBlobName: diagnosticsBlobName, + events, + }); + + if (result.kind === "done") { + setPhase("idle"); + addNotification("success", "Collect Logs", + "Diagnostics package uploaded — it is now available via Download Diagnostics.", + "collect-logs"); + } else if (result.kind === "failed") { + setPhase("idle"); + addNotification("error", "Collect Logs", `The agent could not upload the package: ${result.reason}`, "collect-logs"); + } else if (result.kind === "collecting" && phase === "queued") { + setPhase("collecting"); + } + }, [events, diagnosticsBlobName, phase, addNotification]); + + // Give up after a quiet timeout — the agent may have exited. The package still appears + // via SignalR if it reconnects later; the button just returns to idle for a retry. + useEffect(() => { + if (phase === "queued" || phase === "collecting") { + if (!timeoutRef.current) { + timeoutRef.current = setTimeout(() => { + timeoutRef.current = null; + setPhase("idle"); + addNotification("warning", "Collect Logs", + "No response from the agent yet — it may no longer be running. The package will still appear if the agent checks in later.", + "collect-logs"); + }, COLLECT_TIMEOUT_MS); + } + } else if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }; + }, [phase, addNotification]); + + return ( + <> + + + {showQuickConfig && ( +
!quickConfigBusy && setShowQuickConfig(false)}> +
e.stopPropagation()}> +
+
+
+ + + + +
+

Enable Diagnostics Upload

+
+
+

+ Diagnostics upload is not configured for this tenant, so the agent has nowhere to send the logs. +

+

+ Enable & Collect switches the upload destination to + {" "}hosted storage (managed by AutopilotMonitor) with mode + {" "}On failure, then collects the logs from this device right away. +

+

+ You can change the destination (e.g. to your own Azure Blob Storage) or turn uploads off again at any + time under Settings → Diagnostics Package. +

+
+
+ + +
+
+
+
+ )} + + ); +} diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/__tests__/collectLogsLogic.test.ts b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/__tests__/collectLogsLogic.test.ts new file mode 100644 index 00000000..1566bb2c --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/__tests__/collectLogsLogic.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect } from "vitest"; +import { + evaluateCollectProgress, + resolveCollectButtonState, + ACTIVE_SESSION_STATUSES, + CollectWatchEvent, +} from "../collectLogsLogic"; + +// The Collect Logs button is ALWAYS rendered; these tests pin which combination of +// session state / role / tenant configuration enables it, when a click opens the +// quick-config dialog instead, and how live events + the DiagnosticsBlobName delta +// drive the progress state. Pure logic — no DOM. + +const baseInput = { + sessionStatus: "InProgress", + isCrossTenantView: false, + isTenantAdmin: false, + isOperator: false, + diagnosticsConfigured: true, + phase: "idle" as const, +}; + +describe("resolveCollectButtonState", () => { + it("enables direct collect for an Operator when diagnostics is configured", () => { + const s = resolveCollectButtonState({ ...baseInput, isOperator: true }); + expect(s.enabled).toBe(true); + expect(s.opensQuickConfig).toBe(false); + }); + + it("enables direct collect for a Tenant Admin when diagnostics is configured", () => { + const s = resolveCollectButtonState({ ...baseInput, isTenantAdmin: true }); + expect(s.enabled).toBe(true); + expect(s.opensQuickConfig).toBe(false); + }); + + it("routes an Admin on an UNCONFIGURED tenant into the quick-config dialog", () => { + const s = resolveCollectButtonState({ + ...baseInput, isTenantAdmin: true, diagnosticsConfigured: false, + }); + expect(s.enabled).toBe(true); + expect(s.opensQuickConfig).toBe(true); + }); + + it("disables (with the nudge tooltip) for an Operator on an unconfigured tenant", () => { + const s = resolveCollectButtonState({ + ...baseInput, isOperator: true, diagnosticsConfigured: false, + }); + expect(s.enabled).toBe(false); + expect(s.tooltip).toMatch(/Tenant Admin can enable/); + }); + + it("disables for a Viewer even when configured", () => { + const s = resolveCollectButtonState({ ...baseInput }); + expect(s.enabled).toBe(false); + expect(s.tooltip).toMatch(/Operator or Admin/); + }); + + it.each(["Succeeded", "Failed", "Incomplete", undefined, null])( + "disables for non-active session status %s (agent gone)", + (status) => { + const s = resolveCollectButtonState({ + ...baseInput, isTenantAdmin: true, sessionStatus: status as string | null | undefined, + }); + expect(s.enabled).toBe(false); + expect(s.tooltip).toMatch(/no longer active/); + }, + ); + + it.each(ACTIVE_SESSION_STATUSES)("treats %s as an active status", (status) => { + const s = resolveCollectButtonState({ ...baseInput, isTenantAdmin: true, sessionStatus: status }); + expect(s.enabled).toBe(true); + }); + + it("disables in cross-tenant views (actions endpoint is JWT-tenant-bound)", () => { + const s = resolveCollectButtonState({ ...baseInput, isTenantAdmin: true, isCrossTenantView: true }); + expect(s.enabled).toBe(false); + expect(s.tooltip).toMatch(/cross-tenant/); + }); + + it("shows a busy label and disables while a collection is pending", () => { + const queued = resolveCollectButtonState({ ...baseInput, isTenantAdmin: true, phase: "queued" }); + expect(queued.enabled).toBe(false); + expect(queued.busyLabel).toMatch(/agent/i); + + const collecting = resolveCollectButtonState({ ...baseInput, isTenantAdmin: true, phase: "collecting" }); + expect(collecting.busyLabel).toMatch(/Collecting/); + }); +}); + +describe("evaluateCollectProgress", () => { + const ev = (eventType: string, sequence: number, data?: Record): CollectWatchEvent => + ({ eventType, sequence, data }); + + const baseWatch = { + phase: "queued" as const, + baselineSequence: 100, + baselineBlobName: undefined as string | undefined, + currentBlobName: undefined as string | undefined, + events: [] as CollectWatchEvent[], + }; + + it("is inert while idle", () => { + expect(evaluateCollectProgress({ ...baseWatch, phase: "idle" }).kind).toBe("none"); + }); + + it("reports done when the session's blob name changes — even without visible events", () => { + const r = evaluateCollectProgress({ + ...baseWatch, + currentBlobName: "t/AgentDiagnostics-s-20260724T100000-server-requested.zip", + }); + expect(r).toEqual({ kind: "done", blobName: "t/AgentDiagnostics-s-20260724T100000-server-requested.zip" }); + }); + + it("reports done when an EXISTING blob name is replaced by a new one", () => { + const r = evaluateCollectProgress({ + ...baseWatch, + baselineBlobName: "t/old.zip", + currentBlobName: "t/new-server-requested.zip", + }); + expect(r.kind).toBe("done"); + }); + + it("does NOT report done while the blob name is unchanged from the baseline", () => { + const r = evaluateCollectProgress({ + ...baseWatch, + baselineBlobName: "t/old.zip", + currentBlobName: "t/old.zip", + }); + expect(r.kind).toBe("none"); + }); + + it("moves to collecting on server_action_received for request_diagnostics after the baseline", () => { + const r = evaluateCollectProgress({ + ...baseWatch, + events: [ev("server_action_received", 101, { actionType: "request_diagnostics" })], + }); + expect(r.kind).toBe("collecting"); + }); + + it("ignores server_action events at or before the baseline (at-least-once redelivery)", () => { + const r = evaluateCollectProgress({ + ...baseWatch, + events: [ + ev("server_action_received", 100, { actionType: "request_diagnostics" }), + ev("server_action_failed", 99, { actionType: "request_diagnostics", failureReason: "old" }), + ], + }); + expect(r.kind).toBe("none"); + }); + + it("ignores server_action events for OTHER action types", () => { + const r = evaluateCollectProgress({ + ...baseWatch, + events: [ev("server_action_received", 101, { actionType: "rotate_config" })], + }); + expect(r.kind).toBe("none"); + }); + + it("reports failed with the agent's failureReason", () => { + const r = evaluateCollectProgress({ + ...baseWatch, + events: [ev("server_action_failed", 102, { actionType: "request_diagnostics", failureReason: "url_host_rejected" })], + }); + expect(r).toEqual({ kind: "failed", reason: "url_host_rejected" }); + }); + + it("falls back to a generic reason when failureReason is missing", () => { + const r = evaluateCollectProgress({ + ...baseWatch, + events: [ev("server_action_failed", 102, { actionType: "request_diagnostics" })], + }); + expect(r.kind).toBe("failed"); + }); + + it("prefers done over a stale failure when the blob eventually landed", () => { + const r = evaluateCollectProgress({ + ...baseWatch, + currentBlobName: "t/new.zip", + events: [ev("server_action_failed", 102, { actionType: "request_diagnostics", failureReason: "transient" })], + }); + expect(r.kind).toBe("done"); + }); +}); diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/collectLogsLogic.ts b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/collectLogsLogic.ts new file mode 100644 index 00000000..c54d2c3c --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/collectLogsLogic.ts @@ -0,0 +1,144 @@ +// Pure state helpers for the session-detail "Collect Logs" button. No React/JSX so +// vitest can pin the role/config gating matrix and the progress evaluation without +// rendering (same pattern as diagnosticsSasExpiry.ts). + +export type CollectPhase = "idle" | "working" | "queued" | "collecting"; + +/** Session statuses in which the agent is (potentially) still alive and can receive actions. */ +export const ACTIVE_SESSION_STATUSES = ["InProgress", "Pending", "Stalled"]; + +/** Give up waiting for agent feedback after this long; the package still appears later if the agent reconnects. */ +export const COLLECT_TIMEOUT_MS = 3 * 60_000; + +export interface CollectButtonInput { + sessionStatus?: string | null; + /** Cross-tenant (fleet drill-in) views cannot queue actions: the backend resolves the tenant strictly from the caller's JWT. */ + isCrossTenantView: boolean; + isTenantAdmin: boolean; + isOperator: boolean; + diagnosticsConfigured: boolean; + phase: CollectPhase; +} + +export interface CollectButtonState { + enabled: boolean; + /** When true, the click opens the quick-config dialog instead of queueing directly (Admin on an unconfigured tenant). */ + opensQuickConfig: boolean; + /** Tooltip for the disabled states — the button stays visible so users learn the capability exists. */ + tooltip?: string; + /** Progress label replacing the idle caption while a collection is running. */ + busyLabel?: string; +} + +export function resolveCollectButtonState(input: CollectButtonInput): CollectButtonState { + if (input.phase !== "idle") { + return { + enabled: false, + opensQuickConfig: false, + busyLabel: + input.phase === "collecting" ? "Collecting…" + : input.phase === "queued" ? "Waiting for agent…" + : "Requesting…", + }; + } + + const isActive = !!input.sessionStatus && ACTIVE_SESSION_STATUSES.includes(input.sessionStatus); + if (!isActive) { + return { + enabled: false, + opensQuickConfig: false, + tooltip: "The agent is no longer active for this session — logs can only be collected while an enrollment is running.", + }; + } + + if (input.isCrossTenantView) { + return { + enabled: false, + opensQuickConfig: false, + tooltip: "Not available in the cross-tenant view.", + }; + } + + if (!input.diagnosticsConfigured) { + if (input.isTenantAdmin) { + // Admin path: the click opens the quick-config dialog (enable hosted upload, then collect). + return { enabled: true, opensQuickConfig: true }; + } + return { + enabled: false, + opensQuickConfig: false, + tooltip: "Diagnostics upload is not configured for this tenant — a Tenant Admin can enable it in Settings.", + }; + } + + if (input.isTenantAdmin || input.isOperator) { + return { enabled: true, opensQuickConfig: false }; + } + + return { + enabled: false, + opensQuickConfig: false, + tooltip: "Requires the Operator or Admin role.", + }; +} + +// ── Progress evaluation ──────────────────────────────────────────────────────────── + +/** Minimal event shape needed for progress tracking (subset of EnrollmentEvent). */ +export interface CollectWatchEvent { + eventType: string; + sequence: number; + data?: Record; +} + +export interface CollectWatchInput { + phase: CollectPhase; + /** Highest event sequence at the moment the collection was requested — only newer events count. */ + baselineSequence: number; + /** DiagnosticsBlobName at request time; ANY change means a fresh package landed. */ + baselineBlobName?: string; + currentBlobName?: string; + events: CollectWatchEvent[]; +} + +export type CollectWatchResult = + | { kind: "none" } + | { kind: "collecting" } + | { kind: "done"; blobName: string } + | { kind: "failed"; reason: string }; + +/** + * Evaluates the live event stream + session delta for progress on a pending collection. + * Sequence-based (backend-assigned, monotonic) rather than timestamp-based so agent + * clock skew cannot hide or resurrect matches. + */ +export function evaluateCollectProgress(input: CollectWatchInput): CollectWatchResult { + if (input.phase !== "queued" && input.phase !== "collecting") return { kind: "none" }; + + // Ultimate goal first: the Sessions row carries a new package name (delivered via the + // SignalR sessionUpdate delta) — the Download button is live regardless of which + // server_action events made it into the visible stream. + if (input.currentBlobName && input.currentBlobName !== input.baselineBlobName) { + return { kind: "done", blobName: input.currentBlobName }; + } + + const relevant = input.events.filter( + (e) => (e.sequence ?? 0) > input.baselineSequence + && e.data?.["actionType"] === "request_diagnostics", + ); + + const failed = relevant.find((e) => e.eventType === "server_action_failed"); + if (failed) { + const reason = typeof failed.data?.["failureReason"] === "string" && failed.data["failureReason"] + ? (failed.data["failureReason"] as string) + : "diagnostics upload failed"; + return { kind: "failed", reason }; + } + + const started = relevant.some( + (e) => e.eventType === "server_action_received" || e.eventType === "server_action_executed", + ); + if (started) return { kind: "collecting" }; + + return { kind: "none" }; +} diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/hooks/useSessionTenantConfig.ts b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/hooks/useSessionTenantConfig.ts index 69db223a..f455f29a 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/hooks/useSessionTenantConfig.ts +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/hooks/useSessionTenantConfig.ts @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { api } from "@/lib/api"; import { dedupedAuthFetch } from "@/lib/dedupedAuthFetch"; @@ -8,6 +8,10 @@ interface UseSessionTenantConfigReturn { showScriptOutput: boolean; enableSoftwareInventoryAnalyzer: boolean; enableIntegrityBypassAnalyzer: boolean; + /** Whether an on-demand diagnostics upload can succeed right now (drives the Collect Logs button). */ + diagnosticsUploadConfigured: boolean; + /** Optimistic flip after a successful quick-config from the session page — avoids a refetch round-trip. */ + markDiagnosticsConfigured: () => void; } /** @@ -22,13 +26,14 @@ export function useSessionTenantConfig( const [showScriptOutput, setShowScriptOutput] = useState(true); const [enableSoftwareInventoryAnalyzer, setEnableSoftwareInventoryAnalyzer] = useState(false); const [enableIntegrityBypassAnalyzer, setEnableIntegrityBypassAnalyzer] = useState(true); + const [diagnosticsUploadConfigured, setDiagnosticsUploadConfigured] = useState(false); useEffect(() => { if (!sessionTenantId) return; let cancelled = false; (async () => { try { - // These three flags are exposed via the member-readable feature-flags endpoint so that + // These flags are exposed via the member-readable feature-flags endpoint so that // Operators and Viewers can load session details without 403'ing on the admin-only // full /api/config/{tenantId} response. const res = await dedupedAuthFetch(api.config.featureFlags(sessionTenantId), getAccessToken); @@ -38,10 +43,19 @@ export function useSessionTenantConfig( setShowScriptOutput(cfg.showScriptOutput ?? true); setEnableSoftwareInventoryAnalyzer(cfg.enableSoftwareInventoryAnalyzer ?? false); setEnableIntegrityBypassAnalyzer(cfg.enableIntegrityBypassAnalyzer ?? true); + setDiagnosticsUploadConfigured(cfg.diagnosticsUploadConfigured ?? false); } catch { /* non-fatal */ } })(); return () => { cancelled = true; }; }, [sessionTenantId, getAccessToken]); - return { showScriptOutput, enableSoftwareInventoryAnalyzer, enableIntegrityBypassAnalyzer }; + const markDiagnosticsConfigured = useCallback(() => setDiagnosticsUploadConfigured(true), []); + + return { + showScriptOutput, + enableSoftwareInventoryAnalyzer, + enableIntegrityBypassAnalyzer, + diagnosticsUploadConfigured, + markDiagnosticsConfigured, + }; } diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx index 1a7bed6b..1c50d396 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx @@ -32,6 +32,7 @@ import VulnerabilityReportSection from "./components/VulnerabilityReportSection" import IntegrityBypassSection from "./components/IntegrityBypassSection"; import AdminOverrideModal from "./components/AdminOverrideModal"; import ReportSessionModal from "./components/ReportSessionModal"; +import CollectLogsButton from "./components/CollectLogsButton"; import { usePageSections } from "../../../hooks/usePageSections"; import { PageSectionItem } from "../../../contexts/SidebarContext"; import { InformationCircleIcon, ComputerDesktopIcon, PlayCircleIcon, SparklesIcon, ChartBarIcon, CodeBracketIcon, ArrowDownTrayIcon, ListBulletIcon, ClockIcon, ShieldCheckIcon } from "../../../lib/sidebarIcons"; @@ -403,6 +404,23 @@ export default function SessionDetailPage() {
+ {/* Always rendered (also disabled): the visible button is the nudge towards + configuring diagnostics upload. Admin capability includes a GA viewing a + session in their own tenant; cross-tenant is gated inside the component. */} + {session?.diagnosticsBlobName && (