mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
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:
+306
@@ -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 & 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 & Collect
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+183
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
+144
@@ -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" };
|
||||||
|
}
|
||||||
+17
-3
@@ -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 () => {
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user