feat(web): "Collect Logs" on-demand diagnostics collection on the session detail page

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 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-07-24 18:12:49 +02:00
parent 2fee50276e
commit b382e5678e
6 changed files with 672 additions and 3 deletions
@@ -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<string | null>;
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<CollectPhase>("idle");
const [showQuickConfig, setShowQuickConfig] = useState(false);
const [quickConfigBusy, setQuickConfigBusy] = useState(false);
const baselineSequenceRef = useRef(0);
const baselineBlobRef = useRef<string | undefined>(undefined);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | 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<boolean> => {
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 (
<>
<button
onClick={handleClick}
disabled={!buttonState.enabled}
title={buttonState.tooltip}
className={`px-4 py-2 rounded-md transition-colors flex items-center gap-2 text-sm ${
buttonState.enabled
? "bg-sky-600 text-white hover:bg-sky-700"
: "bg-white border border-gray-200 text-gray-400 cursor-not-allowed"
}`}
>
{phase !== "idle" ? (
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M9 17v-2a2 2 0 012-2h2a2 2 0 012 2v2m-8 4h10a2 2 0 002-2V9a2 2 0 00-2-2h-3.586a1 1 0 01-.707-.293l-1.414-1.414A1 1 0 0012.586 5H7a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
)}
{buttonState.busyLabel ?? "Collect Logs"}
</button>
{showQuickConfig && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" onClick={() => !quickConfigBusy && setShowQuickConfig(false)}>
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4" onClick={(e) => e.stopPropagation()}>
<div className="p-6">
<div className="flex items-center mb-4">
<div className="flex-shrink-0 w-12 h-12 bg-sky-100 rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-sky-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<h3 className="ml-4 text-lg font-semibold text-gray-900">Enable Diagnostics Upload</h3>
</div>
<div className="mb-6 space-y-2">
<p className="text-sm text-gray-700">
Diagnostics upload is not configured for this tenant, so the agent has nowhere to send the logs.
</p>
<p className="text-sm text-gray-700">
<span className="font-semibold">Enable &amp; Collect</span> switches the upload destination to
{" "}<span className="font-semibold">hosted storage</span> (managed by AutopilotMonitor) with mode
{" "}<span className="font-semibold">On failure</span>, then collects the logs from this device right away.
</p>
<p className="text-sm text-gray-600">
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.
</p>
</div>
<div className="flex justify-end gap-3">
<button
onClick={() => setShowQuickConfig(false)}
disabled={quickConfigBusy}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
onClick={() => void handleQuickConfig()}
disabled={quickConfigBusy}
className="px-4 py-2 bg-sky-600 text-white rounded-md hover:bg-sky-700 transition-colors disabled:opacity-50 flex items-center gap-2"
>
{quickConfigBusy && (
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
)}
Enable &amp; Collect
</button>
</div>
</div>
</div>
</div>
)}
</>
);
}
@@ -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<string, unknown>): 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");
});
});
@@ -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<string, unknown>;
}
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" };
}
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { dedupedAuthFetch } from "@/lib/dedupedAuthFetch"; import { dedupedAuthFetch } from "@/lib/dedupedAuthFetch";
@@ -8,6 +8,10 @@ interface UseSessionTenantConfigReturn {
showScriptOutput: boolean; showScriptOutput: boolean;
enableSoftwareInventoryAnalyzer: boolean; enableSoftwareInventoryAnalyzer: boolean;
enableIntegrityBypassAnalyzer: 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 [showScriptOutput, setShowScriptOutput] = useState(true);
const [enableSoftwareInventoryAnalyzer, setEnableSoftwareInventoryAnalyzer] = useState(false); const [enableSoftwareInventoryAnalyzer, setEnableSoftwareInventoryAnalyzer] = useState(false);
const [enableIntegrityBypassAnalyzer, setEnableIntegrityBypassAnalyzer] = useState(true); const [enableIntegrityBypassAnalyzer, setEnableIntegrityBypassAnalyzer] = useState(true);
const [diagnosticsUploadConfigured, setDiagnosticsUploadConfigured] = useState(false);
useEffect(() => { useEffect(() => {
if (!sessionTenantId) return; if (!sessionTenantId) return;
let cancelled = false; let cancelled = false;
(async () => { (async () => {
try { 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 // Operators and Viewers can load session details without 403'ing on the admin-only
// full /api/config/{tenantId} response. // full /api/config/{tenantId} response.
const res = await dedupedAuthFetch(api.config.featureFlags(sessionTenantId), getAccessToken); const res = await dedupedAuthFetch(api.config.featureFlags(sessionTenantId), getAccessToken);
@@ -38,10 +43,19 @@ export function useSessionTenantConfig(
setShowScriptOutput(cfg.showScriptOutput ?? true); setShowScriptOutput(cfg.showScriptOutput ?? true);
setEnableSoftwareInventoryAnalyzer(cfg.enableSoftwareInventoryAnalyzer ?? false); setEnableSoftwareInventoryAnalyzer(cfg.enableSoftwareInventoryAnalyzer ?? false);
setEnableIntegrityBypassAnalyzer(cfg.enableIntegrityBypassAnalyzer ?? true); setEnableIntegrityBypassAnalyzer(cfg.enableIntegrityBypassAnalyzer ?? true);
setDiagnosticsUploadConfigured(cfg.diagnosticsUploadConfigured ?? false);
} catch { /* non-fatal */ } } catch { /* non-fatal */ }
})(); })();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [sessionTenantId, getAccessToken]); }, [sessionTenantId, getAccessToken]);
return { showScriptOutput, enableSoftwareInventoryAnalyzer, enableIntegrityBypassAnalyzer }; const markDiagnosticsConfigured = useCallback(() => setDiagnosticsUploadConfigured(true), []);
return {
showScriptOutput,
enableSoftwareInventoryAnalyzer,
enableIntegrityBypassAnalyzer,
diagnosticsUploadConfigured,
markDiagnosticsConfigured,
};
} }
@@ -32,6 +32,7 @@ import VulnerabilityReportSection from "./components/VulnerabilityReportSection"
import IntegrityBypassSection from "./components/IntegrityBypassSection"; import IntegrityBypassSection from "./components/IntegrityBypassSection";
import AdminOverrideModal from "./components/AdminOverrideModal"; import AdminOverrideModal from "./components/AdminOverrideModal";
import ReportSessionModal from "./components/ReportSessionModal"; import ReportSessionModal from "./components/ReportSessionModal";
import CollectLogsButton from "./components/CollectLogsButton";
import { usePageSections } from "../../../hooks/usePageSections"; import { usePageSections } from "../../../hooks/usePageSections";
import { PageSectionItem } from "../../../contexts/SidebarContext"; import { PageSectionItem } from "../../../contexts/SidebarContext";
import { InformationCircleIcon, ComputerDesktopIcon, PlayCircleIcon, SparklesIcon, ChartBarIcon, CodeBracketIcon, ArrowDownTrayIcon, ListBulletIcon, ClockIcon, ShieldCheckIcon } from "../../../lib/sidebarIcons"; import { InformationCircleIcon, ComputerDesktopIcon, PlayCircleIcon, SparklesIcon, ChartBarIcon, CodeBracketIcon, ArrowDownTrayIcon, ListBulletIcon, ClockIcon, ShieldCheckIcon } from "../../../lib/sidebarIcons";
@@ -403,6 +404,23 @@ export default function SessionDetailPage() {
</h1> </h1>
</div> </div>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
{/* 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. */}
<CollectLogsButton
sessionId={sessionId}
effectiveTenantId={sessionTenantId || tenantId}
sessionStatus={session?.status}
diagnosticsBlobName={session?.diagnosticsBlobName}
isCrossTenantView={isCrossTenantView}
isTenantAdmin={!!user?.isTenantAdmin || !!user?.isGlobalAdmin}
isOperator={user?.role === 'Operator'}
diagnosticsConfigured={tenantConfig.diagnosticsUploadConfigured}
onDiagnosticsConfigured={tenantConfig.markDiagnosticsConfigured}
events={events}
getAccessToken={getAccessToken}
addNotification={addNotification}
/>
{session?.diagnosticsBlobName && ( {session?.diagnosticsBlobName && (
<button <button
onClick={async () => { onClick={async () => {
+4
View File
@@ -54,6 +54,10 @@ export const api = {
`${API_BASE_URL}/api/sessions/${sessionId}/mark-failed${qs({ tenantId })}`, `${API_BASE_URL}/api/sessions/${sessionId}/mark-failed${qs({ tenantId })}`,
markSucceeded: (sessionId: string, tenantId?: string) => markSucceeded: (sessionId: string, tenantId?: string) =>
`${API_BASE_URL}/api/sessions/${sessionId}/mark-succeeded${qs({ tenantId })}`, `${API_BASE_URL}/api/sessions/${sessionId}/mark-succeeded${qs({ tenantId })}`,
// POST { type, reason } — queues a ServerAction delivered with the agent's next ingest
// response (e.g. request_diagnostics for the "Collect Logs" button).
queueAction: (sessionId: string, tenantId?: string) =>
`${API_BASE_URL}/api/sessions/${sessionId}/actions${qs({ tenantId })}`,
report: (sessionId: string, tenantId?: string) => report: (sessionId: string, tenantId?: string) =>
`${API_BASE_URL}/api/sessions/${sessionId}/report${qs({ tenantId })}`, `${API_BASE_URL}/api/sessions/${sessionId}/report${qs({ tenantId })}`,
quickSearch: (q: string) => quickSearch: (q: string) =>