Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 37 additions & 3 deletions frontend/components/investigations/InvestigationForm.test.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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 },
]);
}

Expand All @@ -175,11 +180,40 @@ describe("InvestigationForm", () => {
render(<InvestigationForm onSubmit={onSubmit} />);
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(<InvestigationForm onSubmit={() => {}} />);
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(<InvestigationForm onSubmit={onSubmit} />);
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 }));
});
});
});
90 changes: 70 additions & 20 deletions frontend/components/investigations/InvestigationForm.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -29,6 +29,8 @@ export function InvestigationForm({ onSubmit }: Props) {
const [values, setValues] = useState<Record<string, Record<string, unknown>>>({});
const [auto, setAuto] = useState(false);
const [playbooks, setPlaybooks] = useState<PlaybookListItem[]>([]);
const [playbookTypes, setPlaybookTypes] = useState<PlaybookTypeItem[]>([]);
const [category, setCategory] = useState("");
const [playbook, setPlaybook] = useState("");

// Prom override panel state — unchanged from today.
Expand All @@ -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
Expand Down Expand Up @@ -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 (
<form onSubmit={submit} className="space-y-5">
{schema.map((s) => {
Expand Down Expand Up @@ -137,26 +162,51 @@ export function InvestigationForm({ onSubmit }: Props) {
}
})}

{playbooks.length > 0 && (
<label className="block">
{groups.length > 0 && (
<div>
<span className="font-medium">Playbook</span>
<span className="block text-sm text-zinc-500">
<span className="block text-xs text-zinc-500">
Walk a specific playbook instead of the guided investigation flow.
</span>
<select
value={playbook}
onChange={(e) => setPlaybook(e.target.value)}
className={inputClass + " mt-2"}
>
<option value="">Guided investigation (default)</option>
{playbooks.map((p) => (
<option key={p.id} value={p.id}>
{p.id}
{p.symptom ? ` — ${p.symptom}` : ""}
</option>
))}
</select>
</label>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<Field label="Category">
<select
value={category}
onChange={(e) => pickCategory(e.target.value)}
className={inputClass}
>
<option value="">All categories</option>
{groups.map((g) => (
<option key={g.name} value={g.name}>
{g.name}
</option>
))}
</select>
{activeGroup?.description && (
<p className="mt-1 break-words text-xs text-zinc-500">{activeGroup.description}</p>
)}
</Field>
<Field label="Playbook">
<select
value={playbook}
onChange={(e) => pickPlaybook(e.target.value)}
className={inputClass}
>
<option value="">Guided investigation (default)</option>
{visiblePlaybooks.map((p) => (
<option key={p.id} value={p.id} title={p.symptom || p.description}>
{p.id}
</option>
))}
</select>
{chosenPlaybook && (chosenPlaybook.symptom || chosenPlaybook.description) && (
<p className="mt-1 break-words text-xs text-zinc-500">
{chosenPlaybook.symptom || chosenPlaybook.description}
</p>
)}
</Field>
</div>
</div>
)}

<label className="flex items-start gap-2 cursor-pointer">
Expand Down
28 changes: 27 additions & 1 deletion frontend/lib/playbook-select.test.ts
Original file line number Diff line number Diff line change
@@ -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"];
Expand Down Expand Up @@ -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" })] }]);
});
});
32 changes: 31 additions & 1 deletion frontend/lib/playbook-select.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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;
}
Loading