diff --git a/frontend/components/investigations/InvestigationForm.test.tsx b/frontend/components/investigations/InvestigationForm.test.tsx index aacbc549..7c15e0d3 100644 --- a/frontend/components/investigations/InvestigationForm.test.tsx +++ b/frontend/components/investigations/InvestigationForm.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"; import { InvestigationForm } from "@/components/investigations/InvestigationForm"; import { api } from "@/lib/api"; @@ -155,6 +155,11 @@ describe("InvestigationForm", () => { vi.spyOn(api, "listPlaybooks").mockResolvedValue([ { id: "investigation", source: "system", locked: true, nodeCount: 1, yaml: "", syncState: synced, type: "general" }, { id: "release_verification", symptom: "Verify a release", source: "user", nodeCount: 1, yaml: "", syncState: synced, type: "general" }, + { id: "pod_crashloop", symptom: "Pods restart in a loop", source: "plugin", nodeCount: 1, yaml: "", syncState: synced, type: "investigation" }, + ]); + vi.spyOn(api, "listPlaybookTypes").mockResolvedValue([ + { name: "investigation", description: "Hunt a live incident", source: "system", tracked: true }, + { name: "general", description: "Routine operational work", source: "system", tracked: true }, ]); } @@ -175,11 +180,40 @@ describe("InvestigationForm", () => { render(); await screen.findByPlaceholderText("enter notes"); await screen.findByRole("option", { name: /release_verification/ }); - expect(screen.queryByRole("option", { name: /^investigation/ })).toBeNull(); + expect(within(screen.getByLabelText(/^Playbook/i)).queryByRole("option", { name: /^investigation/ })).toBeNull(); - fireEvent.change(screen.getByLabelText(/Playbook/i), { target: { value: "release_verification" } }); + fireEvent.change(screen.getByLabelText(/^Playbook/i), { target: { value: "release_verification" } }); fireEvent.click(screen.getByRole("button", { name: /run preflight/i })); expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ playbook: "release_verification" })); }); + + it("filters playbooks by category and describes both picks", async () => { + setup(); + render( {}} />); + await screen.findByRole("option", { name: /release_verification/ }); + + fireEvent.change(screen.getByLabelText(/Category/i), { target: { value: "general" } }); + expect(screen.getByText("Routine operational work")).toBeInTheDocument(); + expect(screen.queryByRole("option", { name: /pod_crashloop/ })).toBeNull(); + expect(screen.getByRole("option", { name: /release_verification/ })).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText(/^Playbook/i), { target: { value: "release_verification" } }); + expect(screen.getByText("Verify a release")).toBeInTheDocument(); + }); + + it("clears a chosen playbook when the category no longer contains it", async () => { + setup(); + const onSubmit = vi.fn(); + render(); + await screen.findByRole("option", { name: /release_verification/ }); + + fireEvent.change(screen.getByLabelText(/^Playbook/i), { target: { value: "release_verification" } }); + // Picking a playbook snaps the category to its type. + expect((screen.getByLabelText(/Category/i) as HTMLSelectElement).value).toBe("general"); + + fireEvent.change(screen.getByLabelText(/Category/i), { target: { value: "investigation" } }); + fireEvent.click(screen.getByRole("button", { name: /run preflight/i })); + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ playbook: undefined })); + }); }); }); diff --git a/frontend/components/investigations/InvestigationForm.tsx b/frontend/components/investigations/InvestigationForm.tsx index 9f64b3a6..ff593583 100644 --- a/frontend/components/investigations/InvestigationForm.tsx +++ b/frontend/components/investigations/InvestigationForm.tsx @@ -1,8 +1,8 @@ "use client"; import { useEffect, useState } from "react"; -import { api, type InputSchema, type PlaybookListItem, type PromOverride } from "@/lib/api"; -import { selectablePlaybooks } from "@/lib/playbook-select"; +import { api, type InputSchema, type PlaybookListItem, type PlaybookTypeItem, type PromOverride } from "@/lib/api"; +import { groupPlaybooks } from "@/lib/playbook-select"; import { ArrowRightIcon } from "@/components/shared/Icons"; import { Spinner } from "@/components/shared/Spinner"; import { TextInput } from "@/components/inputs/TextInput"; @@ -29,6 +29,8 @@ export function InvestigationForm({ onSubmit }: Props) { const [values, setValues] = useState>>({}); const [auto, setAuto] = useState(false); const [playbooks, setPlaybooks] = useState([]); + const [playbookTypes, setPlaybookTypes] = useState([]); + const [category, setCategory] = useState(""); const [playbook, setPlaybook] = useState(""); // Prom override panel state — unchanged from today. @@ -44,8 +46,14 @@ export function InvestigationForm({ onSubmit }: Props) { // can't be fetched; the operator can still start a session. api .listPlaybooks() - .then((items) => setPlaybooks(selectablePlaybooks(items))) + .then(setPlaybooks) .catch(() => setPlaybooks([])); + // Type descriptions only decorate the category picker; a failed + // fetch leaves the groups undescribed, not the picker empty. + api + .listPlaybookTypes() + .then(setPlaybookTypes) + .catch(() => setPlaybookTypes([])); // Pre-populate prom override fields with the profile's defaults so the // operator sees what's currently configured. Functional setters so a // late-arriving fetch can't overwrite values the operator typed while @@ -83,6 +91,23 @@ export function InvestigationForm({ onSubmit }: Props) { setValues((prev) => ({ ...prev, [id]: next })); } + const groups = groupPlaybooks(playbooks, playbookTypes); + const activeGroup = groups.find((g) => g.name === category); + const visiblePlaybooks = activeGroup ? activeGroup.playbooks : groups.flatMap((g) => g.playbooks); + const chosenPlaybook = visiblePlaybooks.find((p) => p.id === playbook); + + function pickCategory(next: string) { + setCategory(next); + const stillVisible = groups.find((g) => g.name === next)?.playbooks.some((p) => p.id === playbook); + if (next && !stillVisible) setPlaybook(""); + } + + function pickPlaybook(next: string) { + setPlaybook(next); + const owner = groups.find((g) => g.playbooks.some((p) => p.id === next)); + if (owner) setCategory(owner.name); + } + return ( {schema.map((s) => { @@ -137,26 +162,51 @@ export function InvestigationForm({ onSubmit }: Props) { } })} - {playbooks.length > 0 && ( - + {groups.length > 0 && ( + Playbook - + Walk a specific playbook instead of the guided investigation flow. - setPlaybook(e.target.value)} - className={inputClass + " mt-2"} - > - Guided investigation (default) - {playbooks.map((p) => ( - - {p.id} - {p.symptom ? ` — ${p.symptom}` : ""} - - ))} - - + + + pickCategory(e.target.value)} + className={inputClass} + > + All categories + {groups.map((g) => ( + + {g.name} + + ))} + + {activeGroup?.description && ( + {activeGroup.description} + )} + + + pickPlaybook(e.target.value)} + className={inputClass} + > + Guided investigation (default) + {visiblePlaybooks.map((p) => ( + + {p.id} + + ))} + + {chosenPlaybook && (chosenPlaybook.symptom || chosenPlaybook.description) && ( + + {chosenPlaybook.symptom || chosenPlaybook.description} + + )} + + + )} diff --git a/frontend/lib/playbook-select.test.ts b/frontend/lib/playbook-select.test.ts index d3aec77b..923b7f39 100644 --- a/frontend/lib/playbook-select.test.ts +++ b/frontend/lib/playbook-select.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { selectablePlaybooks } from "./playbook-select"; +import { groupPlaybooks, selectablePlaybooks } from "./playbook-select"; import type { PlaybookListItem } from "./playbook"; const synced = { status: "synced", reason: "" } as PlaybookListItem["syncState"]; @@ -29,3 +29,29 @@ describe("selectablePlaybooks", () => { expect(out.map((p) => p.id)).toEqual(["b-inv", "untyped", "a-general", "z-general"]); }); }); + +describe("groupPlaybooks", () => { + it("groups by type, investigation first, with the type's description", () => { + const groups = groupPlaybooks( + [ + item({ id: "z-general", type: "general" }), + item({ id: "b-inv", type: "investigation" }), + item({ id: "a-general", type: "general" }), + item({ id: "untyped" }), + ], + [ + { name: "general", description: "Anything else", source: "system", tracked: true }, + { name: "investigation", description: "Hunt a live incident", source: "system", tracked: true }, + ], + ); + expect(groups).toEqual([ + { name: "investigation", description: "Hunt a live incident", playbooks: [expect.objectContaining({ id: "b-inv" }), expect.objectContaining({ id: "untyped" })] }, + { name: "general", description: "Anything else", playbooks: [expect.objectContaining({ id: "a-general" }), expect.objectContaining({ id: "z-general" })] }, + ]); + }); + + it("keeps a type unknown to the catalog with an empty description", () => { + const groups = groupPlaybooks([item({ id: "x", type: "custom" })], []); + expect(groups).toEqual([{ name: "custom", description: "", playbooks: [expect.objectContaining({ id: "x" })] }]); + }); +}); diff --git a/frontend/lib/playbook-select.ts b/frontend/lib/playbook-select.ts index 39519ff5..726723b1 100644 --- a/frontend/lib/playbook-select.ts +++ b/frontend/lib/playbook-select.ts @@ -1,4 +1,5 @@ -import { DEFAULT_PLAYBOOK_TYPE, type PlaybookListItem } from "./playbook"; +import type { PlaybookTypeItem } from "./api"; +import { DEFAULT_PLAYBOOK_TYPE, type PlaybookListItem, type PlaybookType } from "./playbook"; // selectablePlaybooks narrows the playbook catalog to entries an // operator can start a session against: locked entries are the @@ -18,3 +19,32 @@ export function selectablePlaybooks(items: PlaybookListItem[]): PlaybookListItem a.id.localeCompare(b.id), ); } + +export type PlaybookGroup = { + name: PlaybookType; + description: string; + playbooks: PlaybookListItem[]; +}; + +// groupPlaybooks buckets selectable playbooks by type, in the order +// selectablePlaybooks yields them (investigation first, then the rest +// alphabetically), and attaches each type's catalog description so the +// picker can explain the grouping. A type the catalog doesn't know +// still gets a group, with an empty description. +export function groupPlaybooks( + items: PlaybookListItem[], + types: PlaybookTypeItem[], +): PlaybookGroup[] { + const describe = new Map(types.map((t) => [t.name, t.description])); + const groups: PlaybookGroup[] = []; + for (const p of selectablePlaybooks(items)) { + const name = p.type || DEFAULT_PLAYBOOK_TYPE; + let g = groups[groups.length - 1]; + if (!g || g.name !== name) { + g = { name, description: describe.get(name) ?? "", playbooks: [] }; + groups.push(g); + } + g.playbooks.push(p); + } + return groups; +}
{activeGroup.description}
+ {chosenPlaybook.symptom || chosenPlaybook.description} +