-
+ {/* The item's row is a FieldWrapper (or a full-width ObjectSection),
+ both of which are grid children of a FieldsGrid — inline mode's
+ `grid-cols-subgrid` resolves to `none` without one, collapsing
+ every item into a stacked column while the rest of the form stays
+ aligned. Fixed at one column: an item is a single row, and any
+ multi-column layout belongs to the item's own object body. */}
+
{ctx.render.renderFieldRow(
{
key: `${field.key}[${i}]`,
@@ -106,7 +119,7 @@ export function ArrayControl({
childCtx,
{ labelOverride: `Item ${i + 1}` },
)}
-
+
{!readOnly && (
}) {
+ const [value, setValue] = useState(initial);
+ return (
+
+ );
+}
+
+function cards(): HTMLElement[] {
+ return [...document.querySelectorAll("article")];
+}
+
+describe("x-array-display: cards", () => {
+ it("renders one card per item, titled from x-item rather than Item N", () => {
+ render( );
+ expect(cards()).toHaveLength(2);
+ expect(screen.getByText("namespace")).toBeInTheDocument();
+ expect(screen.getByText("limit")).toBeInTheDocument();
+ expect(screen.queryByText("Item 1")).not.toBeInTheDocument();
+ });
+
+ it("falls back to the declared fallback title when the title property is empty", () => {
+ render( );
+ expect(screen.getByText("Untitled parameter")).toBeInTheDocument();
+ });
+
+ it("carries the item's tone on the card's left edge", () => {
+ // The hue is what makes a long stack scannable before it is read; it comes
+ // from x-enum-tones via the x-item glyph property, not from the display.
+ render( );
+ const [first, second] = cards();
+ expect(first?.className).toContain("border-l-slate-400");
+ expect(second?.className).toContain("border-l-violet-400");
+ });
+
+ it("shows the summary line and the required flag from x-item", () => {
+ render( );
+ const first = cards()[0]!;
+ expect(within(first).getByText("metadata.namespace")).toBeInTheDocument();
+ expect(within(first).getByTitle("Required")).toBeInTheDocument();
+ expect(within(cards()[1]!).queryByTitle("Required")).not.toBeInTheDocument();
+ });
+
+ it("keeps every item's fields open and editable", () => {
+ render( );
+ // Both cards are expanded at once — that is the difference from the
+ // accordion, which opens one row at a time.
+ expect(screen.getAllByLabelText("Field")).toHaveLength(2);
+
+ fireEvent.change(within(cards()[1]!).getByLabelText("Field"), {
+ target: { value: "spec.max" },
+ });
+ expect(within(cards()[1]!).getByLabelText("Field")).toHaveValue("spec.max");
+ });
+
+ it("adds an item using the noun the schema declared", () => {
+ render( );
+ fireEvent.click(screen.getByRole("button", { name: "Add parameter" }));
+ expect(cards()).toHaveLength(3);
+ });
+
+ it("removes and reorders by item title, not by index", () => {
+ render( );
+ fireEvent.click(screen.getByRole("button", { name: "Move limit up" }));
+ expect(cards()[0]).toHaveTextContent("limit");
+
+ fireEvent.click(screen.getByRole("button", { name: "Remove limit" }));
+ expect(cards()).toHaveLength(1);
+ expect(screen.queryByText("limit")).not.toBeInTheDocument();
+ });
+
+ it("offers no mutation controls when the form is read-only", () => {
+ render(
+ {}}
+ readOnly
+ showPreferencesMenu={false}
+ />,
+ );
+ expect(screen.queryByRole("button", { name: "Add parameter" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Remove namespace" })).not.toBeInTheDocument();
+ });
+
+ it("ignores the cards display for a plain string array", () => {
+ // A list of bare strings has no properties to summarize, so it stays on the
+ // compact tag editor rather than becoming a stack of untitled cards.
+ const schema: JsonSchemaObject = {
+ type: "object",
+ properties: {
+ tags: {
+ type: "array",
+ title: "Tags",
+ "x-array-display": "cards",
+ items: { type: "string" },
+ },
+ },
+ };
+ render(
+ {}} showPreferencesMenu={false} />,
+ );
+ expect(cards()).toHaveLength(0);
+ expect(screen.getByText("a")).toBeInTheDocument();
+ });
+});
diff --git a/packages/ui/src/components/json-schema-form-cards-array.tsx b/packages/ui/src/components/json-schema-form-cards-array.tsx
new file mode 100644
index 00000000..567cf84e
--- /dev/null
+++ b/packages/ui/src/components/json-schema-form-cards-array.tsx
@@ -0,0 +1,134 @@
+import { cn } from "../lib/utils";
+import { Icon } from "../data/Icon";
+import { UiAdd } from "../icons";
+import { Button } from "./button";
+import { appendInstancePath } from "./json-schema-form-errors";
+import {
+ addItemLabel,
+ emptyItemsCopy,
+ itemSummaryFor,
+ resolveItemSpec,
+} from "./json-schema-form-item-summary";
+import { ItemBadge, ItemRowActions, RequiredMark } from "./json-schema-form-item-row";
+import { inputSizeClass } from "./json-schema-form-size";
+import { TONE_EDGE_CLASS } from "./json-schema-form-tone";
+import {
+ duplicateIndex,
+ moveItem,
+ removeIndex,
+ seedFromSchema,
+ setIndex,
+} from "./json-schema-form-utils";
+import type { FieldControl, RenderContext } from "./json-schema-form-types";
+
+// CardsArray renders an object-item array as a stack of titled cards: every
+// item stays open, but each one is headed by what it actually is rather than
+// "Item 3", and carries the item type's hue on its left edge so a long stack is
+// scannable at a glance.
+//
+// It is the everything-visible counterpart to AccordionArray. Both read the
+// same `x-item` summary, so a consumer switches between them by changing
+// `x-array-display` alone — no second vocabulary to learn.
+export function CardsArray({
+ field,
+ ctx,
+ readOnly,
+}: {
+ field: FieldControl;
+ ctx: RenderContext;
+ readOnly: boolean;
+}) {
+ const items = Array.isArray(field.value) ? field.value : [];
+ const itemSchema = field.itemSchema ?? { type: "object" };
+ const spec = field.itemSpec ?? resolveItemSpec(field.schema, itemSchema);
+ const emptyCopy = emptyItemsCopy(spec, field.schema);
+ // The array's help mode governs its whole subtree, exactly as it does for the
+ // accordion: a card whose fields each carry a permanent two-line paragraph is
+ // back to the height the card layout exists to reclaim.
+ const childCtx: RenderContext = {
+ ...ctx,
+ readOnly,
+ depth: ctx.depth + 1,
+ ...(field.helpDisplay ? { layout: { ...ctx.layout, help: field.helpDisplay } } : {}),
+ };
+
+ function commit(next: unknown[]) {
+ field.onChange(next);
+ }
+
+ return (
+
+ {items.map((item, i) => {
+ const summary =
+ field.itemSummary?.({ item, index: i }) ??
+ itemSummaryFor({ item, index: i, spec, itemSchema });
+ return (
+
+
+
+ {i + 1}
+
+ {summary.title}
+
+ {summary.flagged && }
+ {summary.summary && (
+
+ {summary.summary}
+
+ )}
+ {!readOnly && (
+ 0 ? { onUp: () => commit(moveItem(items, i, i - 1)) } : {})}
+ {...(i < items.length - 1
+ ? { onDown: () => commit(moveItem(items, i, i + 1)) }
+ : {})}
+ onDuplicate={() => commit(duplicateIndex(items, i))}
+ onRemove={() => commit(removeIndex(items, i))}
+ />
+ )}
+
+
+ {/* Recurse through the shared pipeline so consumer pre/post
+ extensions still apply to the item and its properties — and so
+ the item's own x-columns reaches its ObjectControl. */}
+ {ctx.render.renderFieldNodes(
+ {
+ key: `${field.key}[${i}]`,
+ prop: itemSchema,
+ required: false,
+ value: item,
+ onChange: (next) => commit(setIndex(items, i, next)),
+ instancePath: appendInstancePath(ctx.instancePath, i),
+ },
+ childCtx,
+ )?.value ?? null}
+
+
+ );
+ })}
+ {items.length === 0 && emptyCopy && (
+
{emptyCopy}
+ )}
+ {!readOnly && (
+
commit([...items, seedFromSchema(itemSchema)])}
+ >
+
+ {addItemLabel(spec)}
+
+ )}
+
+ );
+}
diff --git a/packages/ui/src/components/json-schema-form-item-row.tsx b/packages/ui/src/components/json-schema-form-item-row.tsx
new file mode 100644
index 00000000..4e3f1f66
--- /dev/null
+++ b/packages/ui/src/components/json-schema-form-item-row.tsx
@@ -0,0 +1,99 @@
+import { cn } from "../lib/utils";
+import { Icon, LabelIcon } from "../data/Icon";
+import { UiAsterisk, UiChevronDown, UiChevronUp, UiCopy, UiTrash } from "../icons";
+import { controlHeightClass, type FormSize } from "./json-schema-form-size";
+import { TONE_GLYPH_CLASS } from "./json-schema-form-tone";
+import type { ArrayItemSummary } from "./json-schema-form-types";
+
+// The parts an array item's identifying row is made of, shared by every
+// object-array display. They read only from ArrayItemSummary — the derived,
+// render-ready description of one item — so a display never learns what the
+// item is, and `x-item` stays the single place a consumer says how to
+// summarize one.
+
+export function ItemGlyph({ glyph }: { glyph?: ArrayItemSummary["glyph"] }) {
+ if (!glyph) return null;
+ return (
+
+ {glyph.icon != null && }
+
+ );
+}
+
+export function ItemBadge({ badge }: { badge?: ArrayItemSummary["badge"] }) {
+ if (!badge) return null;
+ return (
+
+ {badge.icon != null && }
+ {badge.label}
+
+ );
+}
+
+export function RequiredMark() {
+ return (
+
+
+
+ );
+}
+
+export function ItemRowActions({
+ index,
+ title,
+ size,
+ onUp,
+ onDown,
+ onDuplicate,
+ onRemove,
+}: {
+ index: number;
+ title: string;
+ size: FormSize;
+ onUp?: () => void;
+ onDown?: () => void;
+ onDuplicate: () => void;
+ onRemove: () => void;
+}) {
+ // Hidden until the row is hovered or something inside it takes focus, so a
+ // long list is not a wall of permanently dim icons — but keyboard users see
+ // them the moment they arrive. Needs `group` on the item container.
+ const action = cn(
+ "inline-flex aspect-square items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-30",
+ controlHeightClass[size],
+ );
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/ui/src/components/json-schema-form-render.tsx b/packages/ui/src/components/json-schema-form-render.tsx
index 15e8e734..e6aa0d46 100644
--- a/packages/ui/src/components/json-schema-form-render.tsx
+++ b/packages/ui/src/components/json-schema-form-render.tsx
@@ -247,11 +247,13 @@ export function renderFieldRow(
// instead of cramming it into the inline value column.
// An accordion joins this list: crammed into the 600px inline value column it
// is unusable, and it needs the ObjectSection header to carry the array's own
- // title, required marker and help.
+ // title, required marker and help. Cards are the same shape of thing — a
+ // full-width stack of item panels — so they join it too.
if (
field.kind === "object" ||
field.layout === "table" ||
- field.arrayDisplay === "accordion"
+ field.arrayDisplay === "accordion" ||
+ field.arrayDisplay === "cards"
) {
return (
0)) {
return true;
}
- return prop["x-layout"] === "table" || prop["x-array-display"] === "accordion";
+ return (
+ prop["x-layout"] === "table" ||
+ prop["x-array-display"] === "accordion" ||
+ prop["x-array-display"] === "cards"
+ );
}
// renderApi is the RenderContext injection bundle: the root form stores it on
diff --git a/packages/ui/src/components/json-schema-form-resolve.ts b/packages/ui/src/components/json-schema-form-resolve.ts
index b5d44bc1..6a290645 100644
--- a/packages/ui/src/components/json-schema-form-resolve.ts
+++ b/packages/ui/src/components/json-schema-form-resolve.ts
@@ -112,7 +112,7 @@ function enumDisplay(prop: JsonSchemaProperty): EnumDisplay | undefined {
function arrayDisplay(prop: JsonSchemaProperty): ArrayDisplay | undefined {
const d = prop["x-array-display"];
- return d === "filter-pills" || d === "accordion" ? d : undefined;
+ return d === "filter-pills" || d === "accordion" || d === "cards" ? d : undefined;
}
// helpDisplay reads the per-field `x-help-display` override. Returns undefined
diff --git a/packages/ui/src/components/json-schema-form-types.ts b/packages/ui/src/components/json-schema-form-types.ts
index 0b2d336b..479c7c52 100644
--- a/packages/ui/src/components/json-schema-form-types.ts
+++ b/packages/ui/src/components/json-schema-form-types.ts
@@ -71,8 +71,10 @@ export interface JsonSchemaProperty {
// Force the enum presentation: "combobox" (default), "radio", "grid", or
// "segmented".
"x-enum-display"?: EnumDisplay;
- // Force the presentation for an enum-backed array. "filter-pills" renders
- // each enum item as a compact toggle; an empty stored array means all options.
+ // Force an array's presentation. "filter-pills" renders each enum item as a
+ // compact toggle (an empty stored array means all options); "accordion" and
+ // "cards" render object items as summary rows or titled cards, both reading
+ // `x-item` for the summary.
"x-array-display"?: ArrayDisplay;
// Force how this field's description is presented, overriding the form-level
// `FormLayout.help`. Defaults to "inline" (a paragraph under the control).
@@ -211,8 +213,11 @@ export type GridColumns = number | "auto";
// "hover" moves it behind a `?` beside the label, costing no vertical space.
export type HelpDisplay = "inline" | "hover";
-// How an array control renders when the item schema has enum options.
-export type ArrayDisplay = "filter-pills" | "accordion";
+// How an array control renders. "filter-pills" needs enum item options;
+// "accordion" and "cards" both need object items and both read `x-item` — the
+// accordion collapses every item to one line and opens one at a time, cards
+// keep every item open under a titled, hue-edged header.
+export type ArrayDisplay = "filter-pills" | "accordion" | "cards";
// ArrayItemSpec is the `x-item` extension on an ARRAY schema: it says how to
// summarize one element of this list in a collapsed row. Every value names a
diff --git a/packages/ui/src/components/unit-form-extension.test.tsx b/packages/ui/src/components/unit-form-extension.test.tsx
new file mode 100644
index 00000000..f1fa7541
--- /dev/null
+++ b/packages/ui/src/components/unit-form-extension.test.tsx
@@ -0,0 +1,163 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { JsonSchemaForm } from "./JsonSchemaForm";
+import {
+ createUnitFormExtensions,
+ formatUnitAwareValue,
+ parseUnitAwareValue,
+ type UnitInputKind,
+} from "./unit-form-extension";
+import type { JsonSchemaObject } from "./json-schema-form-types";
+
+const schema: JsonSchemaObject = {
+ type: "object",
+ properties: {
+ rows: {
+ type: "string",
+ title: "Rows",
+ pattern: "^[1-9][0-9]*$",
+ "x-clicky-unit": "count",
+ "x-input-suffix": "rows",
+ },
+ memory: {
+ type: "string",
+ title: "Memory",
+ pattern: "^[1-9][0-9]*$",
+ "x-clicky-unit": "bytes",
+ },
+ },
+};
+
+const value = { rows: "1000000", memory: "268435456" };
+const extensions = createUnitFormExtensions();
+
+describe("createUnitFormExtensions", () => {
+ it("displays canonical count and byte strings using human units", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByRole("textbox", { name: "Rows" })).toHaveValue("1M");
+ expect(screen.getByRole("textbox", { name: "Memory" })).toHaveValue("256MiB");
+ });
+
+ it.each([
+ ["Rows", "2.5M", { rows: "2500000", memory: "268435456" }],
+ ["Memory", "1.5GiB", { rows: "1000000", memory: "1610612736" }],
+ ])("commits a %s edit as a canonical integer when exactly representable", (name, input, expected) => {
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+
+ fireEvent.change(screen.getByRole("textbox", { name }), { target: { value: input } });
+
+ expect(onChange).toHaveBeenLastCalledWith(expected);
+ });
+
+ it.each([
+ ["Decrease Rows", { rows: "500000", memory: "268435456" }],
+ ["Increase Rows", { rows: "2000000", memory: "268435456" }],
+ ["Decrease Memory", { rows: "1000000", memory: "134217728" }],
+ ["Increase Memory", { rows: "1000000", memory: "536870912" }],
+ ])("commits %s as a canonical integer", (name, expected) => {
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name }));
+
+ expect(onChange).toHaveBeenLastCalledWith(expected);
+ });
+
+ it("preserves an inexact byte edit so schema validation can reject it", () => {
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+
+ fireEvent.change(screen.getByRole("textbox", { name: "Memory" }), {
+ target: { value: "1.2KiB" },
+ });
+
+ expect(onChange).toHaveBeenLastCalledWith({ rows: "1000000", memory: "1.2KiB" });
+ });
+
+ it("humanizes and disables unit fields in read-only forms", () => {
+ const { rerender } = render(
+ ,
+ );
+
+ expect(screen.getByRole("textbox", { name: "Rows" })).toHaveValue("1M");
+ expect(screen.getByRole("textbox", { name: "Rows" })).toBeDisabled();
+ expect(screen.getByRole("textbox", { name: "Memory" })).toHaveValue("256MiB");
+ expect(screen.getByRole("textbox", { name: "Memory" })).toBeDisabled();
+ expect(screen.queryByRole("button", { name: "Decrease Rows" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Increase Memory" })).not.toBeInTheDocument();
+
+ rerender(
+ ,
+ );
+ expect(screen.getByRole("button", { name: "Decrease Rows" })).toBeEnabled();
+ expect(screen.getByRole("button", { name: "Increase Memory" })).toBeEnabled();
+ });
+});
+
+describe("unit values", () => {
+ it.each<[string, UnitInputKind, string | null]>([
+ ["2.5M", "count", "2500000"],
+ ["1,500K", "count", "1500000"],
+ ["256MB", "bytes", "268435456"],
+ ["1.5GiB", "bytes", "1610612736"],
+ ["1.2KiB", "bytes", null],
+ ["0", "count", null],
+ ])("parses %s as a %s value", (input, kind, expected) => {
+ expect(parseUnitAwareValue(input, kind)).toBe(expected);
+ });
+
+ it.each<[string, UnitInputKind, string]>([
+ ["1500000", "count", "1.5M"],
+ ["1536", "bytes", "1.5KiB"],
+ ["1025", "bytes", "1025B"],
+ ])("formats canonical %s as a %s value", (input, kind, expected) => {
+ expect(formatUnitAwareValue(input, kind)).toBe(expected);
+ });
+});
diff --git a/packages/ui/src/components/unit-form-extension.tsx b/packages/ui/src/components/unit-form-extension.tsx
new file mode 100644
index 00000000..747a7f23
--- /dev/null
+++ b/packages/ui/src/components/unit-form-extension.tsx
@@ -0,0 +1,141 @@
+import { cn } from "../lib/utils";
+import { UnitStepControls } from "./UnitStepControls";
+import type { FieldControl, PreExtension } from "./json-schema-form-types";
+
+export type UnitInputKind = "count" | "bytes";
+
+type UnitScale = {
+ label: string;
+ multiplier: bigint;
+};
+
+const COUNT_SCALES: UnitScale[] = [
+ { label: "", multiplier: 1n },
+ { label: "K", multiplier: 1_000n },
+ { label: "M", multiplier: 1_000_000n },
+ { label: "B", multiplier: 1_000_000_000n },
+ { label: "T", multiplier: 1_000_000_000_000n },
+];
+
+const BYTE_SCALES: UnitScale[] = [
+ { label: "B", multiplier: 1n },
+ { label: "KiB", multiplier: 1_024n },
+ { label: "MiB", multiplier: 1_048_576n },
+ { label: "GiB", multiplier: 1_073_741_824n },
+ { label: "TiB", multiplier: 1_099_511_627_776n },
+];
+
+const scales = (kind: UnitInputKind) => kind === "bytes" ? BYTE_SCALES : COUNT_SCALES;
+
+function canonicalInteger(value: unknown): string | null {
+ if (typeof value === "number") {
+ return Number.isSafeInteger(value) && value > 0 ? String(value) : null;
+ }
+ if (typeof value !== "string" || !/^[1-9][0-9]*$/.test(value)) return null;
+ return value;
+}
+
+function scaledDecimal(value: bigint, multiplier: bigint): string | null {
+ const integer = value / multiplier;
+ let remainder = value % multiplier;
+ if (remainder === 0n) return String(integer);
+
+ let fraction = "";
+ while (remainder !== 0n && fraction.length < 3) {
+ remainder *= 10n;
+ fraction += String(remainder / multiplier);
+ remainder %= multiplier;
+ }
+ return remainder === 0n ? `${integer}.${fraction}` : null;
+}
+
+export function formatUnitAwareValue(value: unknown, kind: UnitInputKind): unknown {
+ const canonical = canonicalInteger(value);
+ if (canonical === null) return value;
+
+ const integer = BigInt(canonical);
+ for (const scale of scales(kind).toReversed()) {
+ if (integer < scale.multiplier) continue;
+ const amount = scaledDecimal(integer, scale.multiplier);
+ if (amount !== null) return `${amount}${scale.label}`;
+ }
+ return canonical;
+}
+
+function scaleForLabel(kind: UnitInputKind, label: string): UnitScale | undefined {
+ if (kind === "count") {
+ return COUNT_SCALES.find((scale) => scale.label.toLowerCase() === label.toLowerCase());
+ }
+ const normalized = label.toLowerCase();
+ const aliases: Record = {
+ "": "B",
+ k: "KiB",
+ kb: "KiB",
+ m: "MiB",
+ mb: "MiB",
+ g: "GiB",
+ gb: "GiB",
+ t: "TiB",
+ tb: "TiB",
+ };
+ const canonical = aliases[normalized] ?? label;
+ return BYTE_SCALES.find((scale) => scale.label.toLowerCase() === canonical.toLowerCase());
+}
+
+export function parseUnitAwareValue(value: string, kind: UnitInputKind): string | null {
+ const match = value.trim().replaceAll(",", "").replaceAll("_", "").match(
+ /^(\d+(?:\.\d+)?|\.\d+)\s*([a-zA-Z]*)$/,
+ );
+ if (!match) return null;
+
+ const amount = match[1];
+ const scale = scaleForLabel(kind, match[2] ?? "");
+ if (!amount || !scale) return null;
+ const [whole, fraction = ""] = amount.split(".");
+ const denominator = 10n ** BigInt(fraction.length);
+ const numerator = BigInt(`${whole || "0"}${fraction}`) * scale.multiplier;
+ if (numerator % denominator !== 0n) return null;
+
+ const canonical = numerator / denominator;
+ return canonical > 0n ? String(canonical) : null;
+}
+
+function unitKind(field: FieldControl): UnitInputKind | null {
+ const unit = field.schema["x-clicky-unit"];
+ return unit === "count" || unit === "bytes" ? unit : null;
+}
+
+function steppedValue(value: unknown, direction: "decrease" | "increase"): string | null {
+ const canonical = canonicalInteger(value);
+ if (canonical === null) return null;
+ const integer = BigInt(canonical);
+ const next = direction === "increase" ? integer * 2n : integer / 2n;
+ return next > 0n ? String(next) : null;
+}
+
+export function createUnitFormExtensions(): { pre: PreExtension[] } {
+ const pre: PreExtension = (field) => {
+ const kind = unitKind(field);
+ if (!kind) return field;
+ return {
+ ...field,
+ value: formatUnitAwareValue(field.value, kind),
+ suffix: (
+
+ ),
+ inputClassName: cn(field.inputClassName, field.suffix ? "pr-28" : "pr-20"),
+ onChange: (next) => {
+ const text = typeof next === "string" ? next : String(next ?? "");
+ field.onChange(parseUnitAwareValue(text, kind) ?? text);
+ },
+ };
+ };
+ return { pre: [pre] };
+}
diff --git a/packages/ui/src/data/DataTable.stories.tsx b/packages/ui/src/data/DataTable.stories.tsx
index 033dcbe0..96cb0e64 100644
--- a/packages/ui/src/data/DataTable.stories.tsx
+++ b/packages/ui/src/data/DataTable.stories.tsx
@@ -18,6 +18,7 @@ import {
DataTable,
type DataTableColumn,
type DataTableMenuAction,
+ type DataTableProps,
} from "./DataTable";
type Row = {
@@ -233,7 +234,7 @@ const wideColumns: DataTableColumn[] = [
{ key: "notes", label: "Notes", grow: true },
];
-function DataTableShowcase() {
+function DataTableShowcase(args: DataTableProps) {
const [timeFrom, setTimeFrom] = useState("now-24h");
const [timeTo, setTimeTo] = useState("now");
const [dateFrom, setDateFrom] = useState("");
@@ -241,10 +242,12 @@ function DataTableShowcase() {
return (
,
+ render: (args) => ,
args: {
data: rows,
columns,
- autoFilter: false,
+ loading: false,
+ loadingMessage: "Loading services…",
+ loadingRowCount: 8,
+ emptyMessage: "No services",
+ autoFilter: true,
showGlobalFilter: true,
+ globalFilterPlaceholder: "Search all columns…",
+ defaultSort: { key: "restarts", dir: "asc" },
resizableColumns: true,
hideableColumns: true,
persistColumnWidths: true,
persistColumnVisibility: true,
+ persistDensity: true,
+ showDensityControl: true,
+ showThemeControl: false,
showHeaderFilters: true,
showFullscreenControl: false,
+ fullscreenTitle: "Services",
+ fullscreenButtonLabel: "Open table full screen",
+ },
+ argTypes: {
+ data: { control: false, table: { category: "Data" } },
+ columns: { control: false, table: { category: "Data" } },
+ loading: { control: "boolean", table: { category: "State" } },
+ loadingMessage: { control: "text", table: { category: "State" } },
+ loadingRowCount: {
+ control: { type: "range", min: 1, max: 20, step: 1 },
+ table: { category: "State" },
+ },
+ emptyMessage: { control: "text", table: { category: "State" } },
+ autoFilter: { control: "boolean", table: { category: "Filtering" } },
+ showGlobalFilter: {
+ control: "boolean",
+ table: { category: "Filtering" },
+ },
+ globalFilterPlaceholder: {
+ control: "text",
+ table: { category: "Filtering" },
+ },
+ showHeaderFilters: {
+ control: "boolean",
+ table: { category: "Filtering" },
+ },
+ resizableColumns: {
+ control: "boolean",
+ table: { category: "Columns" },
+ },
+ persistColumnWidths: {
+ control: "boolean",
+ table: { category: "Columns" },
+ },
+ hideableColumns: {
+ control: "boolean",
+ table: { category: "Columns" },
+ },
+ persistColumnVisibility: {
+ control: "boolean",
+ table: { category: "Columns" },
+ },
+ persistDensity: {
+ control: "boolean",
+ table: { category: "Preferences" },
+ },
+ showDensityControl: {
+ control: "boolean",
+ table: { category: "Preferences" },
+ },
+ showThemeControl: {
+ control: "boolean",
+ table: { category: "Preferences" },
+ },
+ showFullscreenControl: {
+ control: "boolean",
+ table: { category: "Fullscreen" },
+ },
+ fullscreenTitle: { control: "text", table: { category: "Fullscreen" } },
+ fullscreenButtonLabel: {
+ control: "text",
+ table: { category: "Fullscreen" },
+ },
},
parameters: {
docs: {
@@ -972,6 +1047,19 @@ type Story = StoryObj;
export const Default: Story = {};
+export const Playground: Story = {
+ args: {
+ showFullscreenControl: true,
+ fullscreenButtonLabel: "Open controlled table",
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await expect(
+ canvas.getByRole("button", { name: "Open controlled table" }),
+ ).toBeVisible();
+ },
+};
+
export const FewColumns: Story = {
render: () => ,
};
diff --git a/packages/ui/src/data/diagnostics/ErrorDetails.test.tsx b/packages/ui/src/data/diagnostics/ErrorDetails.test.tsx
new file mode 100644
index 00000000..cd9f0248
--- /dev/null
+++ b/packages/ui/src/data/diagnostics/ErrorDetails.test.tsx
@@ -0,0 +1,75 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { ErrorDetails } from "./ErrorDetails";
+import type { ErrorDiagnostics } from "./error-diagnostics";
+
+const diagnostics: ErrorDiagnostics = {
+ message: 'invalid profile sample request: json: unknown field "_id"',
+ trace: "trace-42",
+ time: "2026-08-11T09:30:00Z",
+ context: [
+ ["Query", "SELECT * FROM telemetry.logs"],
+ ["Language", "sql"],
+ ],
+ stacktrace: "sample request failed\n at profileQuery.ts:42:7",
+};
+
+const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, "clipboard");
+
+describe("ErrorDetails", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ if (clipboardDescriptor) {
+ Object.defineProperty(navigator, "clipboard", clipboardDescriptor);
+ } else {
+ Reflect.deleteProperty(navigator, "clipboard");
+ }
+ });
+
+ it("copies the complete diagnostic report without expanding the details", async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ Object.defineProperty(navigator, "clipboard", {
+ value: { writeText },
+ configurable: true,
+ });
+ render( );
+
+ const details = screen.getByText(diagnostics.message).closest("details");
+ if (!details) throw new Error("ErrorDetails did not render a details element");
+ expect(details).not.toHaveAttribute("open");
+ expect(screen.getByText("More details")).toBeVisible();
+
+ fireEvent.click(screen.getByRole("button", { name: "Copy error details" }));
+
+ await waitFor(() => expect(writeText).toHaveBeenCalledOnce());
+ expect(writeText).toHaveBeenCalledWith(
+ [
+ `Error: ${diagnostics.message}`,
+ `Trace: ${diagnostics.trace}`,
+ `Time: ${diagnostics.time}`,
+ "",
+ "Context:",
+ "Query: SELECT * FROM telemetry.logs",
+ "Language: sql",
+ "",
+ "Stack trace:",
+ diagnostics.stacktrace,
+ ].join("\n"),
+ );
+ expect(details).not.toHaveAttribute("open");
+ expect(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument();
+ });
+
+ it("surfaces clipboard failures without expanding the details", async () => {
+ Object.defineProperty(navigator, "clipboard", {
+ value: { writeText: vi.fn().mockRejectedValue(new Error("denied")) },
+ configurable: true,
+ });
+ render( );
+
+ fireEvent.click(screen.getByRole("button", { name: "Copy error details" }));
+
+ expect(await screen.findByRole("button", { name: "Copy failed" })).toBeInTheDocument();
+ expect(screen.getByText(diagnostics.message).closest("details")).not.toHaveAttribute("open");
+ });
+});
diff --git a/packages/ui/src/data/diagnostics/ErrorDetails.tsx b/packages/ui/src/data/diagnostics/ErrorDetails.tsx
index 1d9a9d9e..2db59374 100644
--- a/packages/ui/src/data/diagnostics/ErrorDetails.tsx
+++ b/packages/ui/src/data/diagnostics/ErrorDetails.tsx
@@ -1,6 +1,13 @@
-import { type ReactNode } from "react";
+import { useState, type MouseEvent, type ReactNode } from "react";
import { Icon } from "../Icon";
-import { UiDebugStepOver, UiMethod, UiChevronRight, UiCopy, UiWarningTriangle } from "../../icons";
+import {
+ UiCheck,
+ UiChevronRight,
+ UiCopy,
+ UiDebugStepOver,
+ UiMethod,
+ UiWarningTriangle,
+} from "../../icons";
import {
compactStackPath,
isApplicationStackFrame,
@@ -19,6 +26,7 @@ export type ErrorDetailsProps = {
};
export function ErrorDetails({ diagnostics, renderJsonContext }: ErrorDetailsProps) {
+ const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle");
const scalarContext = diagnostics.context.filter(
([, value]) => !parseInlineJsonContextValue(value),
);
@@ -31,6 +39,20 @@ export function ErrorDetails({ diagnostics, renderJsonContext }: ErrorDetailsPro
.filter(
(entry): entry is { label: string; value: string; data: unknown } => entry.data !== null,
);
+ const copyDiagnostics = async (event: MouseEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+ if (!navigator.clipboard?.writeText) {
+ setCopyState("failed");
+ return;
+ }
+ try {
+ await navigator.clipboard.writeText(diagnosticReport(diagnostics));
+ setCopyState("copied");
+ } catch {
+ setCopyState("failed");
+ }
+ };
return (
@@ -41,10 +63,38 @@ export function ErrorDetails({ diagnostics, renderJsonContext }: ErrorDetailsPro
{diagnostics.message}
-
{(diagnostics.trace || diagnostics.time) && (
@@ -116,6 +166,23 @@ export function ErrorDetails({ diagnostics, renderJsonContext }: ErrorDetailsPro
);
}
+function diagnosticReport(diagnostics: ErrorDiagnostics): string {
+ const lines = [`Error: ${diagnostics.message}`];
+ if (diagnostics.trace) lines.push(`Trace: ${diagnostics.trace}`);
+ if (diagnostics.time) lines.push(`Time: ${diagnostics.time}`);
+ if (diagnostics.context.length > 0) {
+ lines.push(
+ "",
+ "Context:",
+ ...diagnostics.context.map(([label, value]) => `${label}: ${value}`),
+ );
+ }
+ if (diagnostics.stacktrace) {
+ lines.push("", "Stack trace:", diagnostics.stacktrace);
+ }
+ return lines.join("\n");
+}
+
export function PrettyStackTrace({ stacktrace }: { stacktrace: string }) {
const parsed = parseDiagnosticsStackTrace(stacktrace);
if (parsed.frames.length === 0) {
diff --git a/packages/ui/src/data/diagnostics/error-diagnostics.ts b/packages/ui/src/data/diagnostics/error-diagnostics.ts
index 45f5962e..1b35329a 100644
--- a/packages/ui/src/data/diagnostics/error-diagnostics.ts
+++ b/packages/ui/src/data/diagnostics/error-diagnostics.ts
@@ -35,9 +35,9 @@ export function normalizeErrorDiagnostics(
}
const record = objectRecord(value);
if (!record) return null;
- const nested = objectRecord(record.error) ?? objectRecord(record.diagnostics);
- if (nested && nested !== record) {
- return normalizeErrorDiagnostics(nested, fallback);
+ const nestedError = objectRecord(record.error);
+ if (nestedError && nestedError !== record) {
+ return normalizeErrorDiagnostics(nestedError, fallback);
}
const message =
firstString(record, ["error", "message", "msg", "reason", "detail", "details"]) ?? fallback;
@@ -45,7 +45,13 @@ export function normalizeErrorDiagnostics(
const stacktrace = firstString(record, ["stacktrace", "stack_trace", "stackTrace", "stack"]);
const time = firstString(record, ["time", "timestamp", "created_at"]);
const context = contextEntries(record.context);
- if (!message && !trace && !stacktrace && context.length === 0) return null;
+ if (!message && !trace && !stacktrace && !time && context.length === 0) {
+ const nestedDiagnostics = objectRecord(record.diagnostics);
+ if (nestedDiagnostics && nestedDiagnostics !== record) {
+ return normalizeErrorDiagnostics(nestedDiagnostics, fallback);
+ }
+ }
+ if (!message && !trace && !stacktrace && !time && context.length === 0) return null;
return {
message: message ?? "Action failed",
context,
diff --git a/packages/ui/src/data/query-browser/QueryBrowser.paging.test.tsx b/packages/ui/src/data/query-browser/QueryBrowser.paging.test.tsx
index a3385f4c..e7e5939d 100644
--- a/packages/ui/src/data/query-browser/QueryBrowser.paging.test.tsx
+++ b/packages/ui/src/data/query-browser/QueryBrowser.paging.test.tsx
@@ -195,4 +195,36 @@ describe("QueryBrowser paging and provider diagnostics", () => {
),
).toBeVisible();
});
+
+ it("renders Oops context returned with an execution error", async () => {
+ const execute = vi.fn().mockRejectedValue(
+ new QueryBrowserExecutionError("query failed", undefined, {
+ message: "query failed",
+ trace: "trace-query-1",
+ time: "2026-08-11T12:00:00Z",
+ context: [["connection", "tenant-x"]],
+ stacktrace: "query failed\n--- at example/query.go:42 runQuery",
+ }),
+ );
+ render(
+
,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Run" }));
+ const details = (
+ await screen.findByRole("button", { name: "Copy error details" })
+ ).closest("details");
+ expect(details).not.toBeNull();
+
+ fireEvent.click(within(details!).getByText("More details"));
+ expect(within(details!).getByText("trace-query-1")).toBeVisible();
+ expect(within(details!).getByText("tenant-x")).toBeVisible();
+ expect(within(details!).getByText("SELECT broken")).toBeVisible();
+ expect(within(details!).getByText(/example\/query\.go:42/)).toBeVisible();
+ });
});
diff --git a/packages/ui/src/data/query-browser/QueryBrowser.stories.tsx b/packages/ui/src/data/query-browser/QueryBrowser.stories.tsx
new file mode 100644
index 00000000..783efc7a
--- /dev/null
+++ b/packages/ui/src/data/query-browser/QueryBrowser.stories.tsx
@@ -0,0 +1,230 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, userEvent, within } from "storybook/test";
+import type { JsonSchemaObject } from "../../components/json-schema-form-types";
+import type { DataTableServerColumn } from "../data-table-server-filters";
+import { QueryBrowser } from "./QueryBrowser";
+import {
+ QueryBrowserExecutionError,
+ type QueryBrowserRequest,
+ type QueryBrowserResult,
+} from "./QueryBrowser.types";
+
+const rows: Record
[] = [
+ {
+ observed_at: "2026-08-11T08:14:32Z",
+ service: "Checkout API",
+ status: "healthy",
+ region: "eu-west",
+ duration_ms: 84,
+ },
+ {
+ observed_at: "2026-08-11T08:14:21Z",
+ service: "Ledger Worker",
+ status: "degraded",
+ region: "us-east",
+ duration_ms: 413,
+ },
+ {
+ observed_at: "2026-08-11T08:13:58Z",
+ service: "Identity API",
+ status: "healthy",
+ region: "eu-west",
+ duration_ms: 126,
+ },
+ {
+ observed_at: "2026-08-11T08:13:44Z",
+ service: "Reporting API",
+ status: "failed",
+ region: "ap-south",
+ duration_ms: 1305,
+ },
+ {
+ observed_at: "2026-08-11T08:13:12Z",
+ service: "Checkout API",
+ status: "healthy",
+ region: "us-east",
+ duration_ms: 91,
+ },
+ {
+ observed_at: "2026-08-11T08:12:47Z",
+ service: "Ledger Worker",
+ status: "healthy",
+ region: "eu-west",
+ duration_ms: 204,
+ },
+];
+
+const columns: DataTableServerColumn[] = [
+ { name: "observed_at", label: "Observed", kind: "timestamp" },
+ {
+ name: "service",
+ label: "Service",
+ filterKey: "service",
+ filter: {
+ kind: "terms",
+ options: ["Checkout API", "Ledger Worker", "Identity API", "Reporting API"].map(
+ (value) => ({ value }),
+ ),
+ },
+ },
+ {
+ name: "status",
+ label: "Status",
+ kind: "status",
+ filterKey: "status",
+ filter: {
+ kind: "terms",
+ options: ["healthy", "degraded", "failed"].map((value) => ({ value })),
+ },
+ },
+ { name: "region", label: "Region" },
+ { name: "duration_ms", label: "Duration (ms)" },
+];
+
+const optionsSchema: JsonSchemaObject = {
+ type: "object",
+ properties: {
+ database: {
+ type: "string",
+ title: "Database",
+ enum: ["operations", "analytics"],
+ },
+ readOnly: { type: "boolean", title: "Read only" },
+ },
+};
+
+async function executeSampleQuery(
+ request: QueryBrowserRequest,
+): Promise {
+ const filtered = rows.filter((row) =>
+ Object.entries(request.filters ?? {}).every(([key, encoded]) => {
+ const value = String(row[key] ?? "");
+ const tokens = encoded.split(",").filter(Boolean);
+ const included = tokens.filter((token) => !token.startsWith("!"));
+ const excluded = tokens.filter((token) => token.startsWith("!")).map((token) => token.slice(1));
+ return (included.length === 0 || included.includes(value)) && !excluded.includes(value);
+ }),
+ );
+ const limit = request.pagination?.limit ?? 4;
+ const offset = request.pagination?.offset ?? 0;
+ const page = filtered.slice(offset, offset + limit);
+
+ return {
+ rows: page,
+ columns,
+ durationMs: 18,
+ pagination: {
+ mode: "offset",
+ limit,
+ offset,
+ hasMore: offset + limit < filtered.length,
+ total: filtered.length,
+ totalRelation: "eq",
+ consistency: "snapshot",
+ },
+ ...(request.debug
+ ? {
+ diagnostics: {
+ provider: "postgresql",
+ request: {
+ query: request.query,
+ options: request.options,
+ details: { transaction: "read-only", plan: "Index Scan" },
+ },
+ response: {
+ durationMs: 18,
+ returnedRows: page.length,
+ contentType: "application/json",
+ preview: JSON.stringify(page),
+ },
+ },
+ }
+ : {}),
+ };
+}
+
+const meta = {
+ title: "Data/QueryBrowser",
+ component: QueryBrowser,
+ parameters: {
+ layout: "fullscreen",
+ docs: {
+ description: {
+ component:
+ "A provider-neutral query workspace with CodeMirror editing, optional schema-driven options, remembered history, source-described filters, pagination, result details and provider diagnostics. The examples use an in-memory SQL executor, so no backend is required.",
+ },
+ },
+ },
+ argTypes: {
+ execute: { table: { disable: true } },
+ lookupFilterValues: { table: { disable: true } },
+ renderResults: { table: { disable: true } },
+ navigator: { table: { disable: true } },
+ },
+ render: (args) => (
+
+
+
+ ),
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const SqlResults: Story = {
+ args: {
+ id: "storybook-query-browser-sql",
+ title: "Service health",
+ language: "sql",
+ queryLabel: "PostgreSQL query",
+ initialQuery:
+ "SELECT observed_at, service, status, region, duration_ms\nFROM service_health\nORDER BY observed_at DESC",
+ optionsSchema,
+ initialOptions: { database: "operations", readOnly: true },
+ completion: {
+ kind: "sql",
+ dialect: "postgresql",
+ defaultSchema: "public",
+ schemas: [
+ {
+ name: "public",
+ relations: [
+ {
+ name: "service_health",
+ columns: columns.map((column) => ({ name: column.name })),
+ },
+ ],
+ },
+ ],
+ },
+ execute: executeSampleQuery,
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.click(canvas.getByRole("button", { name: "Run" }));
+ await expect(canvas.findByText("Checkout API")).resolves.toBeVisible();
+ await expect(canvas.findByText("Page 1 of 2")).resolves.toBeVisible();
+ },
+};
+
+export const ProviderError: Story = {
+ args: {
+ id: "storybook-query-browser-error",
+ title: "Broken query",
+ language: "sql",
+ initialQuery: "SELECT missing_column FROM service_health",
+ execute: async () => {
+ throw new QueryBrowserExecutionError("query execution failed", {
+ provider: "postgresql",
+ request: { query: "SELECT missing_column FROM service_health" },
+ response: { details: { code: "42703" } },
+ error: "column missing_column does not exist",
+ });
+ },
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.click(canvas.getByRole("button", { name: "Run" }));
+ await expect(canvas.findByText("query execution failed")).resolves.toBeVisible();
+ },
+};
diff --git a/packages/ui/src/data/query-browser/QueryBrowser.tsx b/packages/ui/src/data/query-browser/QueryBrowser.tsx
index c1032b73..78d48755 100644
--- a/packages/ui/src/data/query-browser/QueryBrowser.tsx
+++ b/packages/ui/src/data/query-browser/QueryBrowser.tsx
@@ -17,6 +17,7 @@ import {
type DataTableServerColumn,
} from "../data-table-server-filters";
import { ErrorDetails } from "../diagnostics/ErrorDetails";
+import type { ErrorDiagnostics } from "../diagnostics/error-diagnostics";
import {
queryBrowserEditorExtensions,
queryBrowserLanguageExtension,
@@ -64,6 +65,7 @@ export function QueryBrowser({
message: string;
query: string;
diagnostics?: QueryBrowserDiagnostics;
+ errorDetails?: ErrorDiagnostics;
} | null>(null);
const [pending, setPending] = useState(false);
const [debug, setDebug] = useState(false);
@@ -109,6 +111,9 @@ export function QueryBrowser({
...(err instanceof QueryBrowserExecutionError && err.diagnostics
? { diagnostics: err.diagnostics }
: {}),
+ ...(err instanceof QueryBrowserExecutionError && err.errorDetails
+ ? { errorDetails: err.errorDetails }
+ : {}),
});
} finally {
setPending(false);
@@ -414,8 +419,12 @@ export function QueryBrowser({
;
- completion?: QueryBrowserCompletion;
- onQueryChange?: (query: string) => void;
- onOptionsChange?: (options: Record) => void;
- navigator?: ReactNode;
+ title?: string | undefined;
+ language?: QueryBrowserLanguage | undefined;
+ initialQuery?: string | undefined;
+ queryLabel?: string | undefined;
+ optionsSchema?: JsonSchemaObject | undefined;
+ initialOptions?: Record | undefined;
+ completion?: QueryBrowserCompletion | undefined;
+ onQueryChange?: ((query: string) => void) | undefined;
+ onOptionsChange?: ((options: Record) => void) | undefined;
+ navigator?: ReactNode | undefined;
execute: (request: QueryBrowserRequest) => Promise;
- lookupFilterValues?: QueryBrowserFilterLookup;
- renderResults?: (context: QueryBrowserResultContext) => ReactNode;
- className?: string;
+ lookupFilterValues?: QueryBrowserFilterLookup | undefined;
+ renderResults?: ((context: QueryBrowserResultContext) => ReactNode) | undefined;
+ className?: string | undefined;
};
diff --git a/packages/ui/src/lib/string.test.ts b/packages/ui/src/lib/string.test.ts
index 855958e6..01524b68 100644
--- a/packages/ui/src/lib/string.test.ts
+++ b/packages/ui/src/lib/string.test.ts
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
-import { stripLeadingSlashes, stripTrailingSlashes } from "./string";
+import {
+ stripLeadingSlashes,
+ stripSurroundingDashes,
+ stripTrailingSlashes,
+} from "./string";
describe("slash stripping", () => {
it("strips long leading and trailing slash runs", () => {
@@ -10,3 +14,12 @@ describe("slash stripping", () => {
expect(stripTrailingSlashes(slashes)).toBe("");
});
});
+
+describe("dash stripping", () => {
+ it("strips long leading and trailing dash runs", () => {
+ const dashes = "-".repeat(50_000);
+ expect(stripSurroundingDashes(`${dashes}slug${dashes}`)).toBe("slug");
+ expect(stripSurroundingDashes(dashes)).toBe("");
+ expect(stripSurroundingDashes("a-b")).toBe("a-b");
+ });
+});
diff --git a/packages/ui/src/lib/string.ts b/packages/ui/src/lib/string.ts
index bbd32864..80c87b8d 100644
--- a/packages/ui/src/lib/string.ts
+++ b/packages/ui/src/lib/string.ts
@@ -9,3 +9,13 @@ export function stripTrailingSlashes(value: string): string {
while (end > 0 && value[end - 1] === "/") end--;
return value.slice(0, end);
}
+
+// Linear scan instead of `.replace(/^-+|-+$/g, "")`: the anchored `-+`
+// alternatives backtrack polynomially on slugs made of many dashes.
+export function stripSurroundingDashes(value: string): string {
+ let start = 0;
+ let end = value.length;
+ while (start < end && value[start] === "-") start++;
+ while (end > start && value[end - 1] === "-") end--;
+ return value.slice(start, end);
+}
diff --git a/packages/ui/src/profiles.ts b/packages/ui/src/profiles.ts
new file mode 100644
index 00000000..2753806a
--- /dev/null
+++ b/packages/ui/src/profiles.ts
@@ -0,0 +1,73 @@
+/**
+ * Profile authoring: the editor, the wizard, and the query builder behind them.
+ *
+ * These components author a commons-db `query.Profile` — the shape shared by
+ * trace profiles, view specs and ad-hoc reports — so every app that stores
+ * profiles edits them through one UI instead of growing its own.
+ *
+ * Call configureProfiles({ schema, basePath }) once at startup: the schema is
+ * generated from commons-db's Go types and served by the host, and basePath is
+ * where that host mounts the profile service (default `/api/v1`).
+ */
+// profileEditorRaw is deliberately absent. ProfileEditor reaches it through
+// React.lazy so Monaco stays out of the initial chunk; re-exporting it here
+// would make every importer of this entry load Monaco eagerly, and Monaco is
+// an optional peer dependency a consumer may not have installed at all.
+// testSchema is a test fixture, not API.
+
+export * from "./profiles/catalogTree";
+export * from "./profiles/connectionBrowserModel";
+export * from "./profiles/connectionQueryWorkspace";
+export * from "./profiles/connectionQueryWorkspaceModel";
+export * from "./profiles/esFieldValues";
+export * from "./profiles/esParamMappingModel";
+export * from "./profiles/esParamMappingPill";
+export * from "./profiles/esParamOperandExtension";
+export * from "./profiles/esQueryBuilder";
+export * from "./profiles/esQueryBuilderExtension";
+export * from "./profiles/esQueryBuilderForm";
+export * from "./profiles/esQueryBuilderModel";
+export * from "./profiles/esQueryClauseGroup";
+export * from "./profiles/esQueryCompile";
+export * from "./profiles/esQueryConditionRow";
+export * from "./profiles/esQueryGroupModel";
+export * from "./profiles/esQueryOccur";
+export * from "./profiles/esQueryOperandEditors";
+export * from "./profiles/esQueryOperandModel";
+export * from "./profiles/esQueryOperators";
+export * from "./profiles/esQueryOutputEditor";
+export * from "./profiles/esQueryOutputModel";
+export * from "./profiles/esQueryPreview";
+export * from "./profiles/esQuerySortEditor";
+export * from "./profiles/esQuerySortModel";
+export * from "./profiles/esValueCombobox";
+export * from "./profiles/jsonPathSample";
+export * from "./profiles/jsonPathSampleRow";
+export * from "./profiles/profileApi";
+export * from "./profiles/profileBuilder";
+export * from "./profiles/profileBuilderExtension";
+export * from "./profiles/profileBuilderWorkspace";
+export * from "./profiles/profileColumnModel";
+export * from "./profiles/profileColumnPicker";
+export * from "./profiles/profileEditor";
+export * from "./profiles/profileEditorModel";
+export * from "./profiles/profileEditorPreview";
+export * from "./profiles/profileEditorRail";
+export * from "./profiles/profileEditorRoutes";
+export * from "./profiles/profileEditorSections";
+export * from "./profiles/profileFieldEditor";
+export * from "./profiles/profileFieldGrid";
+export * from "./profiles/profileFieldManager";
+export * from "./profiles/profileFieldState";
+export * from "./profiles/profileFieldTypes";
+export * from "./profiles/profileParamModel";
+export * from "./profiles/profileWizard";
+export * from "./profiles/profileWizardHelp";
+export * from "./profiles/profileWizardModel";
+export * from "./profiles/profileWizardQueryStep";
+export * from "./profiles/profileWizardSteps";
+export * from "./profiles/profileYaml";
+export * from "./profiles/prometheusResults";
+export * from "./profiles/queryRowLimits";
+export * from "./profiles/queryRowLimitsModel";
+export * from "./profiles/queryTargetPicker";
diff --git a/packages/ui/src/profiles/.widen.py b/packages/ui/src/profiles/.widen.py
new file mode 100644
index 00000000..ea0a2211
--- /dev/null
+++ b/packages/ui/src/profiles/.widen.py
@@ -0,0 +1,62 @@
+"""Widen optional properties to `?: T | undefined` in named type declarations.
+
+clicky-ui builds with exactOptionalPropertyTypes, where an optional property may
+be absent but not present-and-undefined. For the draft models and component
+props here that distinction is noise -- a React caller passing a possibly-absent
+value is ordinary -- and the package's own components already declare
+`prop?: T | undefined` (see data/CodeBlock.tsx). This aligns these declarations
+with that convention.
+
+It deliberately does NOT touch the places where absent-vs-undefined is real:
+those are the objects serialized to the server, and they were fixed to `delete`
+the key instead.
+"""
+
+import pathlib
+import re
+import sys
+
+TARGETS = {
+ "esQueryBuilderModel.ts": ["EsSearch", "EsSortBy", "EsCondition"],
+ "profileEditorModel.ts": ["ProfileSectionStatus"],
+ "profileWizardModel.ts": ["ProfileWizardDraft", "ProfileRowLimits", "ParamDraft"],
+ "esQueryPreview.tsx": ["EsCompilation"],
+ "connectionQueryWorkspace.tsx": ["ConnectionQueryWorkspaceProps"],
+}
+
+PROP = re.compile(r"^(\s+)([A-Za-z_$][\w$]*)\?: ([^;]+);$")
+
+
+def widen_block(lines, start):
+ """Widen `x?: T;` lines of the type literal opening at `start` until its `};`."""
+ depth = 0
+ changed = 0
+ for index in range(start, len(lines)):
+ depth += lines[index].count("{") - lines[index].count("}")
+ match = PROP.match(lines[index])
+ if match and "| undefined" not in match.group(3):
+ indent, name, type_text = match.groups()
+ lines[index] = f"{indent}{name}?: {type_text} | undefined;"
+ changed += 1
+ if depth <= 0 and index > start:
+ return index, changed
+ return len(lines) - 1, changed
+
+
+total = 0
+for filename, type_names in TARGETS.items():
+ path = pathlib.Path(filename)
+ if not path.exists():
+ sys.exit(f"missing {filename}")
+ lines = path.read_text().split("\n")
+ for type_name in type_names:
+ for index, line in enumerate(lines):
+ if re.match(rf"^export type {type_name} = .*\{{\s*$", line):
+ _, changed = widen_block(lines, index)
+ total += changed
+ print(f"{filename}:{type_name}: widened {changed}")
+ break
+ else:
+ print(f"{filename}:{type_name}: NOT FOUND")
+ path.write_text("\n".join(lines))
+print(f"total {total}")
diff --git a/packages/ui/src/profiles/ProfileEditor.stories.tsx b/packages/ui/src/profiles/ProfileEditor.stories.tsx
new file mode 100644
index 00000000..05ff71db
--- /dev/null
+++ b/packages/ui/src/profiles/ProfileEditor.stories.tsx
@@ -0,0 +1,145 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { useMemo, type ComponentProps } from "react";
+import { expect, userEvent, within } from "storybook/test";
+import type { ResolvedOperation } from "../rpc/types";
+import type { OperationsApiClient } from "../rpc/useOperations";
+import { ProfileEditor } from "./profileEditor";
+import { configureProfiles } from "./profileApi";
+import { testProfileSchema } from "./testSchema";
+
+configureProfiles({ schema: testProfileSchema });
+
+const client: OperationsApiClient = {
+ async getOpenAPISpec() {
+ return {
+ openapi: "3.0.0",
+ info: { title: "Profile examples", version: "1.0.0" },
+ paths: {},
+ };
+ },
+ async executeCommand() {
+ return { success: true, exit_code: 0 };
+ },
+ async submitForm() {
+ return { success: true, exit_code: 0, message: "Profile saved" };
+ },
+};
+
+const action: ResolvedOperation = {
+ path: "/api/v1/profiles/{id}",
+ method: "put",
+ operation: {
+ operationId: "profile_update",
+ summary: "Update profile",
+ responses: { "200": { description: "Updated" } },
+ },
+};
+
+const initialValue = {
+ profile: "service-health",
+ namespace: "observability",
+ render: "table",
+ provider: { type: "sql", options: {} },
+ query:
+ "SELECT observed_at, service, status, duration_ms FROM service_health ORDER BY observed_at DESC",
+ params: [
+ {
+ name: "service",
+ label: "Service",
+ type: "string",
+ role: "filter",
+ },
+ ],
+ columns: [
+ {
+ name: "observed_at",
+ label: "Observed",
+ type: "datetime",
+ kind: "timestamp",
+ },
+ {
+ name: "service",
+ label: "Service",
+ type: "string",
+ filter: { kind: "terms", lookup: true },
+ },
+ {
+ name: "status",
+ label: "Status",
+ type: "string",
+ kind: "status",
+ },
+ ],
+};
+
+function ProfileEditorStory(props: ComponentProps) {
+ const queryClient = useMemo(
+ () =>
+ new QueryClient({
+ defaultOptions: { queries: { retry: false, gcTime: 0 } },
+ }),
+ [],
+ );
+ return (
+
+
+
+ );
+}
+
+const meta = {
+ title: "Profiles/ProfileEditor",
+ component: ProfileEditor,
+ parameters: {
+ layout: "fullscreen",
+ docs: {
+ description: {
+ component:
+ "The route-sized editor for a commons-db query profile. Its section rail, field grid, inspector and preview use the shared Workspace layout. Hosts inject the generated profile schema with `configureProfiles` and provide an `OperationsApiClient` for save and lookup operations; this example supplies both in memory.",
+ },
+ },
+ },
+ args: {
+ client,
+ action,
+ surfaceKey: "profile-service-health",
+ initialValue,
+ onClose: () => undefined,
+ onSuccess: () => undefined,
+ },
+ argTypes: {
+ client: { table: { disable: true } },
+ action: { table: { disable: true } },
+ initialValue: { table: { disable: true } },
+ onClose: { table: { disable: true } },
+ onSuccess: { table: { disable: true } },
+ },
+ render: (args) => ,
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const General: Story = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await expect(canvas.getByText("Profile identity")).toBeVisible();
+ await expect(canvas.getByDisplayValue("service-health")).toBeVisible();
+ },
+};
+
+export const Columns: Story = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.click(
+ canvas.getByRole("button", { name: /^Columns Fields, labels, expressions/ }),
+ );
+ await expect(canvas.findByText("3 of 3 included")).resolves.toBeVisible();
+ await expect(
+ canvas.findByRole("textbox", { name: "Label for observed_at" }),
+ ).resolves.toBeVisible();
+ },
+};
diff --git a/packages/ui/src/profiles/catalogTree.tsx b/packages/ui/src/profiles/catalogTree.tsx
new file mode 100644
index 00000000..8fdb8448
--- /dev/null
+++ b/packages/ui/src/profiles/catalogTree.tsx
@@ -0,0 +1,153 @@
+import { Icon } from "../data/Icon";
+import { TreeNode } from "../data/TreeNode";
+import {
+ UiActivity,
+ UiDatabase,
+ UiLink,
+ UiNamespace,
+ UiSqlColumn,
+ UiSqlDatabase,
+ UiSqlIndex,
+ UiSqlView,
+ UiTable,
+} from "../icons";
+import type { CatalogNode } from "./connectionBrowserModel";
+
+export function CatalogTree({
+ nodes,
+ loading,
+ error,
+ databases,
+ database,
+ onDatabaseChange,
+ onSelect,
+}: {
+ nodes: CatalogNode[];
+ loading: boolean;
+ error: unknown;
+ databases: string[];
+ database: string;
+ onDatabaseChange: (database: string) => void;
+ onSelect: (node: CatalogNode) => void;
+}) {
+ return (
+
+
+
+ Catalog
+
+ {databases.length > 0 ? (
+
+ Database
+ onDatabaseChange(event.target.value)}
+ className="mt-1 h-8 w-full rounded-md border bg-background px-2 text-xs text-foreground"
+ >
+ {databases.map((name) => (
+
+ {name}
+
+ ))}
+
+
+ ) : null}
+ {loading && (
+
+ Loading catalog…
+
+ )}
+ {error ? (
+
+
Unable to load catalog
+
{catalogErrorMessage(error)}
+
+ ) : null}
+ {!loading && !error && nodes.length === 0 ? (
+
+ No catalog objects found.
+
+ ) : null}
+
+
+ );
+}
+
+function catalogErrorMessage(error: unknown): string {
+ if (error instanceof Error && error.message.trim()) {
+ return error.message.trim();
+ }
+ if (typeof error === "string" && error.trim()) {
+ return error.trim();
+ }
+ return "The catalog request failed. Check the connection settings and try again.";
+}
+
+function CatalogNodes({
+ nodes,
+ onSelect,
+}: {
+ nodes: CatalogNode[];
+ onSelect: (node: CatalogNode) => void;
+}) {
+ return (
+
+ {nodes.map((node) => (
+
item.id}
+ getChildren={(item) => item.children}
+ defaultOpen={(item) => item.kind === "schema"}
+ isSecondary={(item) => item.kind === "column"}
+ onSelect={(item) => {
+ if (item.query) onSelect(item);
+ }}
+ indentPx={14}
+ basePaddingPx={8}
+ renderRow={({ node: item }) => (
+
+
+ {item.label}
+
+ )}
+ />
+ ))}
+
+ );
+}
+
+function catalogIcon(kind: string) {
+ switch (kind) {
+ case "schema":
+ return UiNamespace;
+ case "table":
+ return UiTable;
+ case "view":
+ return UiSqlView;
+ case "column":
+ return UiSqlColumn;
+ case "index":
+ return UiSqlIndex;
+ case "alias":
+ return UiLink;
+ case "data_stream":
+ return UiActivity;
+ default:
+ return UiDatabase;
+ }
+}
diff --git a/packages/ui/src/profiles/connectionBrowserModel.test.ts b/packages/ui/src/profiles/connectionBrowserModel.test.ts
new file mode 100644
index 00000000..674f8b6c
--- /dev/null
+++ b/packages/ui/src/profiles/connectionBrowserModel.test.ts
@@ -0,0 +1,107 @@
+import { QueryBrowserExecutionError } from "../data/query-browser/QueryBrowser.types";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { fetchJSON, mergeProviderOptions } from "./connectionBrowserModel";
+
+afterEach(() => vi.unstubAllGlobals());
+
+it("preserves provider diagnostics from a failed JSON request", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ error: "query failed",
+ trace: "trace-query-1",
+ time: "2026-08-11T12:00:00Z",
+ context: { connection: "tenant-x" },
+ stacktrace: "query failed\n--- at example/query.go:42 runQuery",
+ diagnostics: {
+ provider: "clickhouse",
+ request: { query: "SELECT broken" },
+ error: "unknown identifier broken",
+ },
+ }),
+ { status: 422, headers: { "Content-Type": "application/json" } },
+ ),
+ ),
+ );
+
+ try {
+ await fetchJSON("/query");
+ throw new Error("expected fetchJSON to reject");
+ } catch (error) {
+ expect(error).toBeInstanceOf(QueryBrowserExecutionError);
+ expect((error as QueryBrowserExecutionError).message).toBe("query failed");
+ expect((error as QueryBrowserExecutionError).diagnostics?.request.query).toBe(
+ "SELECT broken",
+ );
+ expect((error as QueryBrowserExecutionError).errorDetails).toEqual({
+ message: "query failed",
+ trace: "trace-query-1",
+ time: "2026-08-11T12:00:00Z",
+ context: [["connection", "tenant-x"]],
+ stacktrace: "query failed\n--- at example/query.go:42 runQuery",
+ raw: {
+ error: "query failed",
+ trace: "trace-query-1",
+ time: "2026-08-11T12:00:00Z",
+ context: { connection: "tenant-x" },
+ stacktrace: "query failed\n--- at example/query.go:42 runQuery",
+ diagnostics: {
+ provider: "clickhouse",
+ request: { query: "SELECT broken" },
+ error: "unknown identifier broken",
+ },
+ },
+ });
+ }
+});
+
+describe("provider option layering", () => {
+ const stored = { index: "logs-2024", limit: "100" };
+ const catalog = { index: "logs-2025" };
+ const live = { limit: "500", targetKind: "data_stream" };
+
+ it("lets each later layer override the one before it", () => {
+ expect(
+ mergeProviderOptions({ layers: [stored, catalog, live] }),
+ ).toEqual({ index: "logs-2025", limit: "500" });
+ });
+
+ it("skips layers a host has not supplied", () => {
+ expect(mergeProviderOptions({ layers: [undefined, stored] })).toEqual(
+ stored,
+ );
+ });
+
+ // targetKind only tells the inspection endpoint which mappings to fetch. It
+ // is not a provider option, so it must not reach the stored profile.
+ it("drops targetKind from the options a query runs with", () => {
+ expect(mergeProviderOptions({ layers: [live] })).toEqual({ limit: "500" });
+ expect(
+ mergeProviderOptions({ layers: [live], keepTargetKind: true }),
+ ).toEqual(live);
+ });
+
+ it("pins the active database over whatever the layers carried", () => {
+ expect(
+ mergeProviderOptions({
+ layers: [{ database: "stale" }],
+ database: "analytics",
+ }),
+ ).toEqual({ database: "analytics" });
+ });
+
+ // An empty database means the connection's own default, which the backend
+ // resolves — sending "" would ask for a database literally named "".
+ it("leaves the database alone when none is active", () => {
+ expect(
+ mergeProviderOptions({ layers: [{ database: "app" }], database: "" }),
+ ).toEqual({ database: "app" });
+ });
+
+ it("does not mutate the layers it merges", () => {
+ mergeProviderOptions({ layers: [live], database: "analytics" });
+ expect(live).toEqual({ limit: "500", targetKind: "data_stream" });
+ });
+});
diff --git a/packages/ui/src/profiles/connectionBrowserModel.ts b/packages/ui/src/profiles/connectionBrowserModel.ts
new file mode 100644
index 00000000..3d3421f8
--- /dev/null
+++ b/packages/ui/src/profiles/connectionBrowserModel.ts
@@ -0,0 +1,393 @@
+/**
+ * The data layer behind every query browser: the descriptor and inspection
+ * shapes the server serves, and the hook that turns a selection (database,
+ * index) into the catalog and completion a browser renders. All three hosts —
+ * the connection browser, the profile wizard and the profile builder — drive
+ * the same plumbing from here so the surfaces cannot drift apart.
+ */
+
+import type { ComboboxOption } from "../components/Combobox";
+import type { JsonSchemaObject } from "../components/json-schema-form-types";
+import { normalizeErrorDiagnostics } from "../data/diagnostics/error-diagnostics";
+import type { QueryBrowserCompletion } from "../data/query-browser/QueryBrowser.completion";
+import type { QueryBrowserDiagnostics } from "../data/query-browser/QueryBrowser.types";
+import { profileApiPath } from "./profileApi";
+import { QueryBrowserExecutionError } from "../data/query-browser/QueryBrowser.types";
+import { useQuery } from "@tanstack/react-query";
+import { useMemo, type ReactNode } from "react";
+
+export type BrowserDescriptor = {
+ kind: "query" | "cache";
+ provider?: string;
+ language?: "sql" | "json" | "text";
+ queryLabel?: string;
+ defaultQuery?: string;
+ resultView?: "table" | "logs" | "timeseries";
+ optionsSchema?: JsonSchemaObject;
+ initialOptions?: Record;
+ catalog?: boolean;
+ /**
+ * What a query runs against when the source picks one flat target — the
+ * `index` option. Set, the browser offers a target combobox instead of a
+ * catalog tree.
+ */
+ targetLabel?: string;
+ /**
+ * The row caps that apply when a profile declares none of its own: the page a
+ * caller gets by default, the largest page it may ask for, and where an
+ * all-row export stops. The query's own `limit` option is none of them.
+ */
+ rowLimits?: BrowserRowLimits;
+};
+
+/** The defaults the server serves, shown as what an unset cap inherits. */
+export type BrowserRowLimits = {
+ pageSize: number;
+ maxPageSize: number;
+ maxExportRows: number;
+};
+
+/** The caps a profile sets for itself; each unset one takes its default. */
+export type ProfileRowLimits = {
+ pageSize?: number;
+ maxPageSize?: number;
+ maxExportRows?: number;
+};
+
+export type TargetKind = "index" | "alias" | "data_stream" | "pattern";
+
+export type InspectionTarget = {
+ name: string;
+ kind: TargetKind;
+ /** The rotation wildcard this concrete index rolls up into. */
+ pattern?: string;
+ /** How many rotations a `pattern` target covers. */
+ count?: number;
+};
+
+export type CatalogNode = {
+ id: string;
+ label: string;
+ kind: string;
+ query?: string;
+ options?: Record;
+ children?: CatalogNode[];
+};
+
+export type InspectionField = {
+ name: string;
+ dataType?: string;
+ types?: string[];
+ searchable?: boolean;
+ aggregatable?: boolean;
+ conflicting?: boolean;
+};
+
+export type BrowserInspection = {
+ kind: "sql" | "opensearch";
+ dialect?: "postgresql" | "mysql" | "mssql" | "standard";
+ database?: string;
+ databases?: string[];
+ defaultSchema?: string;
+ schemas?: {
+ name: string;
+ relations: {
+ name: string;
+ type?: "table" | "view";
+ columns: InspectionField[];
+ }[];
+ }[];
+ targets?: InspectionTarget[];
+ nodes?: CatalogNode[];
+ selected?: {
+ target: InspectionTarget;
+ fields: InspectionField[];
+ };
+ truncated?: boolean;
+ truncateReason?: string;
+};
+
+export type ConnectionProfileActionRenderer = (context: {
+ connectionName: string;
+ providerType: string;
+ providerOptions?: Record;
+}) => ReactNode;
+
+/**
+ * savedConnectionID reads the id out of a `connection://` reference. An
+ * inline URL has no id, and so no catalog to browse — hence null rather than a
+ * guess.
+ */
+export function savedConnectionID(value: string | undefined): string | null {
+ const prefix = "connection://";
+ if (!value?.startsWith(prefix)) return null;
+ return value.slice(prefix.length).trim() || null;
+}
+
+export function browserBaseUrl(connectionID: string): string {
+ return profileApiPath(`connection/${encodeURIComponent(connectionID)}/browser`);
+}
+
+export async function fetchJSON(url: string, init?: RequestInit): Promise {
+ const response = await fetch(url, init);
+ if (!response.ok) {
+ const body = await response.text();
+ const fallback = body.trim() || `request failed: ${response.status}`;
+ try {
+ const parsed = JSON.parse(body) as {
+ error?: unknown;
+ diagnostics?: QueryBrowserDiagnostics;
+ };
+ if (typeof parsed.error === "string") {
+ const errorDetails = normalizeErrorDiagnostics(parsed, parsed.error);
+ throw new QueryBrowserExecutionError(
+ errorDetails?.message ?? parsed.error,
+ parsed.diagnostics,
+ errorDetails ?? undefined,
+ );
+ }
+ } catch (error) {
+ if (error instanceof QueryBrowserExecutionError) throw error;
+ }
+ throw new Error(fallback);
+ }
+ return response.json() as Promise;
+}
+
+/**
+ * Rotations lead: a cluster with fifty-three daily jaeger indexes has one
+ * target an author actually means, and it is `jaeger-span-*`. The concrete
+ * indexes stay listed last so a single day is still reachable.
+ */
+const targetGroups: { kind: TargetKind; label: string }[] = [
+ { kind: "pattern", label: "Index patterns" },
+ { kind: "alias", label: "Aliases" },
+ { kind: "data_stream", label: "Data streams" },
+ { kind: "index", label: "Indexes" },
+];
+
+export function openSearchIndexOptions(
+ inspection?: BrowserInspection,
+): ComboboxOption[] {
+ if (inspection?.kind !== "opensearch") return [];
+ const targets = inspection.targets ?? [];
+ return targetGroups.flatMap(({ kind, label }) =>
+ targets
+ .filter((target) => target.kind === kind)
+ .map((target) => ({
+ value: target.name,
+ label: target.count ? `${target.name} · ${target.count} indexes` : target.name,
+ selectedLabel: target.name,
+ group: label,
+ title: target.count
+ ? `${target.name} · ${target.count} rotated indexes`
+ : `${target.name} · ${target.kind.replace("_", " ")}`,
+ })),
+ );
+}
+
+/**
+ * openSearchTargetKind resolves how to inspect a picked target. An undiscovered
+ * name containing a wildcard is a pattern by construction — the server inspects
+ * it without requiring it to have been enumerated.
+ */
+export function openSearchTargetKind(
+ inspection: BrowserInspection | undefined,
+ name: string,
+): string {
+ const discovered = (inspection?.targets ?? []).find(
+ (target) => target.name === name,
+ );
+ if (discovered) return discovered.kind;
+ return name.includes("*") ? "pattern" : "";
+}
+
+/**
+ * withTarget applies a picked target over a host's options, clearing both keys
+ * when the picker is emptied so a stale index cannot survive the selection.
+ */
+export function withTarget(
+ options: Record,
+ target: { index: string; targetKind: string } | undefined,
+): Record {
+ if (!target) return options;
+ const next = { ...options };
+ if (target.index) {
+ next.index = target.index;
+ next.targetKind = target.targetKind;
+ } else {
+ delete next.index;
+ delete next.targetKind;
+ }
+ return next;
+}
+
+/**
+ * queryBrowserOptionsSchema is what the inline options form edits — the leftover
+ * options, once the navigator has claimed the ones that belong with the query.
+ * The target has its own combobox, and where the source has a structured search
+ * the builder owns both the search and the limit it returns, so none of the
+ * three is rendered a second time as a generic field.
+ */
+export function queryBrowserOptionsSchema(
+ descriptor: BrowserDescriptor,
+): JsonSchemaObject | undefined {
+ if (!descriptor.optionsSchema) return undefined;
+ const properties = { ...descriptor.optionsSchema.properties };
+ if (properties.search) {
+ delete properties.search;
+ delete properties.limit;
+ }
+ if (descriptor.targetLabel) delete properties.index;
+ return { ...descriptor.optionsSchema, properties };
+}
+
+export function completionForInspection(
+ inspection?: BrowserInspection,
+ selectedInspection?: BrowserInspection,
+): QueryBrowserCompletion | undefined {
+ if (inspection?.kind === "sql" && inspection.dialect) {
+ return {
+ kind: "sql",
+ dialect: inspection.dialect,
+ ...(inspection.defaultSchema
+ ? { defaultSchema: inspection.defaultSchema }
+ : {}),
+ schemas: (inspection.schemas ?? []).map((schema) => ({
+ name: schema.name,
+ relations: schema.relations.map((relation) => ({
+ name: relation.name,
+ ...(relation.type ? { type: relation.type } : {}),
+ columns: relation.columns.map((column) => ({
+ name: column.name,
+ types: column.dataType ? [column.dataType] : [],
+ })),
+ })),
+ })),
+ };
+ }
+ if (
+ selectedInspection?.kind === "opensearch" &&
+ selectedInspection.selected
+ ) {
+ return {
+ kind: "json-fields",
+ vocabulary: "opensearch",
+ fields: selectedInspection.selected.fields,
+ };
+ }
+ return undefined;
+}
+
+/**
+ * mergeProviderOptions layers the option sources a browser draws on, in
+ * increasing precedence, and pins the active database when there is one.
+ * `targetKind` only tells the inspection endpoint which field mappings to
+ * fetch, so it is dropped unless the caller is feeding the browser itself.
+ */
+export function mergeProviderOptions(input: {
+ layers: Array | undefined>;
+ database?: string;
+ keepTargetKind?: boolean;
+}): Record {
+ const merged: Record = {};
+ for (const layer of input.layers) Object.assign(merged, layer ?? {});
+ if (input.database) merged.database = input.database;
+ if (!input.keepTargetKind) delete merged.targetKind;
+ return merged;
+}
+
+export type InspectionScope = {
+ /** Query-cache namespace, so each host keeps its own inspection cache. */
+ cacheKey: string;
+ id: string;
+ baseUrl: string;
+ enabled: boolean;
+ /** The database the author picked; empty means the connection's default. */
+ database: string;
+ /** A database carried by the stored provider options, tried before the default. */
+ fallbackDatabase?: string;
+ /** The selected index, alias or data stream. */
+ target: string;
+ /** An explicit target kind; resolved from the catalog when absent. */
+ targetKind?: string;
+};
+
+export type Inspection = {
+ data?: BrowserInspection | undefined;
+ nodes: CatalogNode[];
+ databases: string[];
+ activeDatabase: string;
+ /** The database to send with a query — empty unless the source is SQL. */
+ sqlDatabase: string;
+ targetKind: string;
+ loading: boolean;
+ error: unknown;
+ completion?: QueryBrowserCompletion | undefined;
+};
+
+/**
+ * useInspection resolves the catalog for a browser: the base inspection, the
+ * per-database one a SQL author switched to, and the per-target field mappings
+ * an OpenSearch author needs for completion.
+ */
+export function useInspection(scope: InspectionScope): Inspection {
+ const { cacheKey, id, baseUrl } = scope;
+ const base = useQuery({
+ queryKey: [cacheKey, id],
+ queryFn: () => fetchJSON(`${baseUrl}/inspect`),
+ enabled: scope.enabled,
+ retry: 0,
+ staleTime: 5 * 60_000,
+ });
+ const switchedDatabase =
+ scope.database !== "" && scope.database !== base.data?.database;
+ const database = useQuery({
+ queryKey: [cacheKey, id, scope.database],
+ queryFn: () => {
+ const params = new URLSearchParams({ database: scope.database });
+ return fetchJSON(`${baseUrl}/inspect?${params}`);
+ },
+ enabled: base.data?.kind === "sql" && switchedDatabase,
+ retry: 0,
+ staleTime: 5 * 60_000,
+ });
+ const active = switchedDatabase ? database : base;
+ const data = active.data ?? base.data;
+
+ const targetKind =
+ scope.targetKind ??
+ data?.targets?.find((target) => target.name === scope.target)?.kind ??
+ "";
+ const target = useQuery({
+ queryKey: [cacheKey, id, targetKind, scope.target],
+ queryFn: () => {
+ const params = new URLSearchParams({
+ target: scope.target,
+ targetKind,
+ });
+ return fetchJSON(`${baseUrl}/inspect?${params}`);
+ },
+ enabled: data?.kind === "opensearch" && scope.target !== "" && targetKind !== "",
+ retry: 0,
+ staleTime: 5 * 60_000,
+ });
+
+ const activeDatabase =
+ scope.database || scope.fallbackDatabase || data?.database || "";
+ const completion = useMemo(
+ () => completionForInspection(data, target.data),
+ [data, target.data],
+ );
+ return {
+ data,
+ nodes: data?.nodes ?? [],
+ databases: base.data?.databases ?? [],
+ activeDatabase,
+ sqlDatabase: data?.kind === "sql" ? activeDatabase : "",
+ targetKind,
+ loading: active.isLoading,
+ error: active.error,
+ completion,
+ };
+}
diff --git a/packages/ui/src/profiles/connectionQueryWorkspace.test.tsx b/packages/ui/src/profiles/connectionQueryWorkspace.test.tsx
new file mode 100644
index 00000000..b5537935
--- /dev/null
+++ b/packages/ui/src/profiles/connectionQueryWorkspace.test.tsx
@@ -0,0 +1,224 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it, vi } from "vitest";
+import type { BrowserDescriptor, Inspection } from "./connectionBrowserModel";
+import { ConnectionQueryWorkspace } from "./connectionQueryWorkspace";
+import { initialNavigatorTab, navigatorTabs, supportsQueryBuilder } from "./connectionQueryWorkspaceModel";
+import { EsCompileRequest } from "./esQueryCompile";
+
+// The compile request only leaves the browser once effects run, which server
+// rendering never does — so the wiring is asserted on what the hook was handed.
+const compileInputs = vi.hoisted(() => [] as EsCompileRequest[]);
+vi.mock("./esQueryCompile", async (importOriginal) => {
+ const original = await importOriginal();
+ return {
+ ...original,
+ useCompiledSearch: (input: EsCompileRequest) => {
+ compileInputs.push(input);
+ return original.useCompiledSearch(input);
+ },
+ };
+});
+
+const searchSchema = {
+ type: "object" as const,
+ properties: {
+ search: {
+ type: "object" as const,
+ "x-clicky-component": "es-query-builder",
+ "x-es-operators": [
+ { op: "term", label: "term", arity: "single", fieldTypes: ["keyword"] },
+ ],
+ },
+ },
+};
+
+const openSearch: BrowserDescriptor = {
+ kind: "query",
+ provider: "opensearch",
+ language: "json",
+ catalog: true,
+ targetLabel: "Index",
+ optionsSchema: searchSchema,
+};
+
+const sql: BrowserDescriptor = {
+ kind: "query",
+ provider: "sql",
+ language: "sql",
+ catalog: true,
+};
+
+describe("supportsQueryBuilder", () => {
+ it("accepts a source whose options schema carries an operator catalog", () => {
+ expect(supportsQueryBuilder(openSearch)).toBe(true);
+ });
+
+ it("rejects a source with no options schema", () => {
+ expect(supportsQueryBuilder(sql)).toBe(false);
+ });
+
+ it("rejects an options schema that describes no structured search", () => {
+ expect(
+ supportsQueryBuilder({
+ ...openSearch,
+ optionsSchema: { type: "object", properties: { index: { type: "string" } } },
+ }),
+ ).toBe(false);
+ });
+});
+
+describe("navigatorTabs", () => {
+ it("offers the two authoring modes as tabs, form first", () => {
+ expect(navigatorTabs({ descriptor: openSearch, builder: true })).toEqual([
+ { id: "form", label: "Form" },
+ { id: "json", label: "JSON" },
+ ]);
+ });
+
+ it("keeps the catalog tab for a hierarchical source with a builder", () => {
+ expect(
+ navigatorTabs({
+ descriptor: { ...openSearch, targetLabel: undefined },
+ builder: true,
+ }),
+ ).toEqual([
+ { id: "catalog", label: "Catalog" },
+ { id: "form", label: "Form" },
+ { id: "json", label: "JSON" },
+ ]);
+ });
+
+ it("offers Catalog alone for a source with no structured search", () => {
+ expect(navigatorTabs({ descriptor: sql, builder: false })).toEqual([
+ { id: "catalog", label: "Catalog" },
+ ]);
+ });
+
+ it("offers no tabs when the target picker is the whole navigator", () => {
+ expect(navigatorTabs({ descriptor: openSearch, builder: false })).toEqual([]);
+ });
+
+ it("offers no navigator when there is neither a catalog nor a builder", () => {
+ expect(
+ navigatorTabs({ descriptor: { ...sql, catalog: false }, builder: false }),
+ ).toEqual([]);
+ });
+});
+
+describe("initialNavigatorTab", () => {
+ it("starts in the form so filters are always built, not opted into", () => {
+ expect(
+ initialNavigatorTab({
+ tabs: navigatorTabs({ descriptor: openSearch, builder: true }),
+ search: undefined,
+ query: "",
+ }),
+ ).toBe("form");
+ });
+
+ it("opens a stored specification in the form", () => {
+ expect(
+ initialNavigatorTab({
+ tabs: navigatorTabs({ descriptor: openSearch, builder: true }),
+ search: {},
+ query: "",
+ }),
+ ).toBe("form");
+ });
+
+ it("opens a stored raw query in JSON rather than discarding it", () => {
+ expect(
+ initialNavigatorTab({
+ tabs: navigatorTabs({ descriptor: openSearch, builder: true }),
+ search: undefined,
+ query: '{"query":{"term":{"level":"error"}}}',
+ }),
+ ).toBe("json");
+ });
+
+ it("treats the descriptor's own starter query as nothing to preserve", () => {
+ expect(
+ initialNavigatorTab({
+ tabs: navigatorTabs({ descriptor: openSearch, builder: true }),
+ search: undefined,
+ query: '{"query":{"match_all":{}}}',
+ defaultQuery: '{"query":{"match_all":{}}}',
+ }),
+ ).toBe("form");
+ });
+
+ it("falls back to the only tab a builder-less source has", () => {
+ expect(
+ initialNavigatorTab({
+ tabs: navigatorTabs({ descriptor: sql, builder: false }),
+ search: undefined,
+ query: "SELECT 1",
+ }),
+ ).toBe("catalog");
+ });
+});
+
+// The preview is compiled server-side, so an operand that interpolates
+// {{.params.…}} resolves only if the host's parameter values travel with the
+// specification. Without them the panel shows the compiler's refusal to guess.
+describe("ConnectionQueryWorkspace compilation", () => {
+ const inspection: Inspection = {
+ nodes: [],
+ databases: [],
+ activeDatabase: "",
+ sqlDatabase: "",
+ targetKind: "index",
+ loading: false,
+ error: undefined,
+ };
+
+ const renderWorkspace = (extra: Record) => {
+ compileInputs.length = 0;
+ renderToStaticMarkup(
+
+ {}}
+ query=""
+ onQueryChange={() => {}}
+ options={{ index: "logs-*" }}
+ onOptionsChange={() => {}}
+ onCatalogSelect={() => {}}
+ search={{
+ query: {
+ op: "term",
+ field: "service.name",
+ value: "{{.params.service}}",
+ },
+ }}
+ onSearchChange={() => {}}
+ compileBaseUrl="/api/v1/connection/abc/browser"
+ execute={async () => ({ rows: [] })}
+ {...extra}
+ />
+ ,
+ );
+ return compileInputs;
+ };
+
+ it("compiles the specification against the host's parameter values", () => {
+ const inputs = renderWorkspace({
+ params: [{ name: "service" }, { name: "since", role: "time-from" }],
+ paramValues: { service: "payments", since: "now-1h" },
+ paramRoles: { since: "time-from" },
+ });
+ expect(inputs.length).toBeGreaterThan(0);
+ expect(inputs[0]?.params).toEqual({ service: "payments", since: "now-1h" });
+ expect(inputs[0]?.roles).toEqual({ since: "time-from" });
+ });
+
+ it("sends no parameter values when the host declares none", () => {
+ const inputs = renderWorkspace({});
+ expect(inputs.length).toBeGreaterThan(0);
+ expect(inputs[0]?.params).toBeUndefined();
+ });
+});
diff --git a/packages/ui/src/profiles/connectionQueryWorkspace.tsx b/packages/ui/src/profiles/connectionQueryWorkspace.tsx
new file mode 100644
index 00000000..a406239c
--- /dev/null
+++ b/packages/ui/src/profiles/connectionQueryWorkspace.tsx
@@ -0,0 +1,306 @@
+/**
+ * The single query-browser host. The connection browser, the profile wizard and
+ * the profile-edit builder all render this: they own where the query and the
+ * options are stored and what a run means, and this owns the browser itself —
+ * the catalog navigator, the structured filter builder, the completion, and the
+ * descriptor's result view.
+ */
+
+import type { JsonSchemaObject } from "../components/json-schema-form-types";
+import { LogsTable } from "../data/LogsTable";
+import { QueryBrowser } from "../data/query-browser/QueryBrowser";
+import type { QueryBrowserFilterLookup, QueryBrowserRequest, QueryBrowserResult, QueryBrowserResultContext } from "../data/query-browser/QueryBrowser.types";
+import { Tabs } from "../layout/Tabs";
+import { useEffect, useRef, useState, type ReactNode } from "react";
+import { CatalogTree } from "./catalogTree";
+import {
+ withTarget,
+ type BrowserDescriptor,
+ type CatalogNode,
+ type Inspection,
+ type ProfileRowLimits
+} from "./connectionBrowserModel";
+import { makeFieldValueLookup } from "./esFieldValues";
+import { EsQueryBuilder } from "./esQueryBuilder";
+import {
+ toBuilderMode,
+ toRawMode,
+ type EsSearch,
+ type QueryModeTransition
+} from "./esQueryBuilderModel";
+import {
+ esBuilderVocabulary
+ } from "./esQueryOperators";
+import { PrometheusResults } from "./prometheusResults";
+import { QueryRowLimits } from "./queryRowLimits";
+import { QueryTargetPicker } from "./queryTargetPicker";
+import type { ParamMappingEdit } from "./esParamMappingModel";
+import type { ParamDraft } from "./profileWizardModel";
+import { initialNavigatorTab, navigatorTabs, supportsQueryBuilder } from "./connectionQueryWorkspaceModel";
+import { esQueryFields } from "./esQueryBuilderForm";
+import { useCompiledSearch } from "./esQueryCompile";
+
+
+export type ConnectionQueryWorkspaceProps = {
+ id: string;
+ title: string;
+ descriptor: BrowserDescriptor;
+ inspection: Inspection;
+ onDatabaseChange: (database: string) => void;
+ query: string;
+ onQueryChange?: (query: string) => void | undefined;
+ options: Record;
+ onOptionsChange: (options: Record) => void;
+ onCatalogSelect: (node: CatalogNode) => void;
+ optionsSchema?: JsonSchemaObject | undefined;
+ /**
+ * The structured specification this host stores, when it stores one.
+ * `undefined` means the raw query is the artifact.
+ */
+ search?: EsSearch | undefined;
+ /**
+ * Every change reports the specification and the raw query together, and one
+ * of the two is always empty. The host stores both in one write, so it can
+ * never end up holding a specification and a query at once — a state the
+ * server rejects.
+ */
+ onSearchChange?: (transition: QueryModeTransition) => void | undefined;
+ /**
+ * The row caps the edited profile sets for itself. They are profile settings,
+ * not provider options, so they are stored beside the query rather than
+ * through `onOptionsChange`. A host that edits no profile — the connection
+ * browser — passes neither, and the caps are then not offered at all.
+ */
+ limits?: ProfileRowLimits | undefined;
+ onLimitsChange?: (limits: ProfileRowLimits | undefined) => void;
+ /** Declared profile parameters an operand can bind to. */
+ params?: ParamDraft[] | undefined;
+ onParamMappingChange?: (edit: ParamMappingEdit) => void | undefined;
+ /**
+ * What those parameters currently resolve to. The server binds a {param:…}
+ * operand from them and interpolates a {{.params.…}} one, so without values
+ * the preview shows template text — or the compiler's refusal to guess.
+ */
+ paramValues?: Record | undefined;
+ /** Those parameters' roles, so the compiled preview folds them as a run would. */
+ paramRoles?: Record | undefined;
+ /** Where POST /compile lives. Empty leaves the preview unresolved. */
+ compileBaseUrl?: string | undefined;
+ execute: (request: QueryBrowserRequest) => Promise;
+ /**
+ * Answers a filter's value type-ahead. Absent leaves every filter the source
+ * described showing only the values the result itself carried.
+ */
+ lookupFilterValues?: QueryBrowserFilterLookup | undefined;
+ renderResults?: (context: QueryBrowserResultContext) => ReactNode | undefined;
+ className?: string | undefined;
+};
+
+export function ConnectionQueryWorkspace({
+ id,
+ title,
+ descriptor,
+ inspection,
+ onDatabaseChange,
+ query,
+ onQueryChange,
+ options,
+ onOptionsChange,
+ onCatalogSelect,
+ optionsSchema,
+ search,
+ onSearchChange,
+ limits,
+ onLimitsChange,
+ params,
+ onParamMappingChange,
+ paramValues,
+ paramRoles,
+ compileBaseUrl = "",
+ execute,
+ lookupFilterValues,
+ renderResults,
+ className
+}: ConnectionQueryWorkspaceProps) {
+ const builder = Boolean(onSearchChange) && supportsQueryBuilder(descriptor);
+ const tabs = navigatorTabs({ descriptor, builder });
+ const [tab, setTab] = useState(() =>
+ initialNavigatorTab({
+ tabs,
+ search,
+ query,
+ ...(descriptor.defaultQuery ? { defaultQuery: descriptor.defaultQuery } : {})
+ }),
+ );
+ // Picking a target has to reach the browser, which only resyncs its options
+ // when `initialOptions` changes identity. The pick is layered here rather than
+ // round-tripped through the host, so an options-form keystroke — which the
+ // host also stores — cannot resync the browser out from under the author.
+ const [picked, setPicked] = useState>();
+ // What the browser last reported, so a pick keeps the author's other edits.
+ const edited = useRef(options);
+ const seed = useRef(options);
+ if (seed.current !== options) {
+ seed.current = options;
+ edited.current = options;
+ if (picked) setPicked(undefined);
+ }
+ const browserOptions = picked ?? options;
+ const applyOptions = (next: Record) => {
+ edited.current = next;
+ setPicked(next);
+ onOptionsChange(next);
+ };
+ const compilation = useCompiledSearch({
+ baseUrl: compileBaseUrl,
+ search: search ?? {},
+ ...(paramValues ? { params: paramValues } : {}),
+ ...(paramRoles ? { roles: paramRoles } : {}),
+ enabled: Boolean(search) && compileBaseUrl !== ""
+ });
+ const values = makeFieldValueLookup({
+ baseUrl: compileBaseUrl,
+ index: String(browserOptions.index ?? ""),
+ ...(paramValues ? { params: paramValues } : {}),
+ ...(paramRoles ? { roles: paramRoles } : {})
+ });
+
+ // While a specification is active the editor mirrors what it compiles to. It
+ // is a preview, not an input: the query is not stored alongside the spec, so
+ // there is no keystroke for a compile to overwrite.
+ const specMode = search !== undefined;
+ const active = tabs.some((entry) => entry.id === tab) ? tab : tabs[0]?.id;
+
+ // The form tab is the specification, so being on it means holding one. A tab
+ // that stores nothing would leave the builder rendering a query it cannot
+ // edit, and this is also what makes filters the default rather than an opt-in.
+ useEffect(() => {
+ if (active === "form" && onSearchChange && search === undefined) {
+ onSearchChange(toBuilderMode());
+ }
+ }, [active, onSearchChange, search]);
+
+ // Switching tabs is the mode switch. Each mode stores its own artifact and the
+ // server rejects holding both, so leaving the form hands the raw editor the
+ // DSL the specification last compiled to and drops the specification.
+ const selectTab = (next: string) => {
+ if (next === "json" && search && onSearchChange) {
+ onSearchChange(toRawMode(search, compilation.query, query));
+ }
+ setTab(next);
+ };
+
+ return (
+ {
+ edited.current = next;
+ onOptionsChange(next);
+ }}
+ className={className}
+ navigator={
+ tabs.length === 0 && !descriptor.targetLabel ? undefined : (
+
+ {descriptor.targetLabel ? (
+
+ applyOptions(withTarget(edited.current, { index, targetKind }))
+ }
+ />
+ ) : null}
+ {builder ? (
+
+ applyOptions({ ...edited.current, limit })
+ }
+ {...(descriptor.rowLimits
+ ? { defaults: descriptor.rowLimits }
+ : {})}
+ {...(limits ? { limits } : {})}
+ {...(onLimitsChange ? { onLimitsChange } : {})}
+ />
+ ) : null}
+ {tabs.length > 1 ? (
+
+ ) : null}
+ {active === "form" && onSearchChange && search ? (
+ onSearchChange({ search: next, query: "" })}
+ fields={esQueryFields(inspection.completion)}
+ vocabulary={esBuilderVocabulary(descriptor.optionsSchema)}
+ {...(params ? { params } : {})}
+ {...(onParamMappingChange
+ ? { onMappingChange: onParamMappingChange }
+ : {})}
+ {...(values ? { values } : {})}
+ compilation={compilation}
+ />
+ ) : active === "json" ? (
+
+ The {descriptor.queryLabel ?? "query"} editor holds the query.
+ Switch back to Form to build it from filters — the raw query is
+ dropped then, since only one of the two is stored.
+
+ ) : active === "catalog" ? (
+
+ ) : null}
+
+ )
+ }
+ execute={(request) =>
+ execute(
+ specMode
+ ? { ...request, query: "", options: { ...request.options, search } }
+ : request,
+ )
+ }
+ {...(lookupFilterValues ? { lookupFilterValues } : {})}
+ renderResults={renderResults ?? descriptorResultView(descriptor)}
+ />
+ );
+}
+
+/**
+ * descriptorResultView honours the view the server nominated for this provider.
+ * A host that renders its own results (the profile builder's column picker)
+ * passes renderResults and takes over entirely.
+ */
+function descriptorResultView(
+ descriptor: BrowserDescriptor,
+): ((context: QueryBrowserResultContext) => ReactNode) | undefined {
+ if (descriptor.resultView === "logs") {
+ return ({ result, defaultView }) =>
+ result.rows?.length ? (
+
+ ) : (
+ defaultView
+ );
+ }
+ if (descriptor.resultView === "timeseries") {
+ return ({ result, defaultView }) => (
+
+ );
+ }
+ return undefined;
+}
diff --git a/packages/ui/src/profiles/connectionQueryWorkspaceModel.ts b/packages/ui/src/profiles/connectionQueryWorkspaceModel.ts
new file mode 100644
index 00000000..cf3e039a
--- /dev/null
+++ b/packages/ui/src/profiles/connectionQueryWorkspaceModel.ts
@@ -0,0 +1,66 @@
+/**
+ * Which navigator tabs the query workspace offers, and which one it opens on.
+ *
+ * Kept apart from the workspace component so that module exports only
+ * components (react/only-export-components).
+ */
+
+import type { BrowserDescriptor } from "./connectionBrowserModel";
+import type { EsSearch } from "./esQueryBuilderModel";
+import { operatorCatalogFromSchema } from "./esQueryOperators";
+
+export type NavigatorTab = { id: "catalog" | "form" | "json"; label: string };
+
+/**
+ * A source supports the builder when the server described a structured search on
+ * it. The operator catalog travels with the schema, so no provider name is
+ * hardcoded here — adding a structured provider in Go reaches the editor on its
+ * own.
+ */
+export function supportsQueryBuilder(descriptor: BrowserDescriptor): boolean {
+ return operatorCatalogFromSchema(descriptor.optionsSchema).length > 0;
+}
+
+/**
+ * navigatorTabs is what the left pane offers. Where the source has a structured
+ * search, the two ways of authoring one — the form and the raw DSL — are tabs
+ * rather than a one-way door: they are the same query, and the tab says which
+ * of the two is stored. A source that picks one flat target has a combobox
+ * pinned above the tabs instead of a catalog tree — its targets are a list of
+ * index names, and a list is not worth navigating.
+ */
+export function navigatorTabs(input: {
+ descriptor: BrowserDescriptor;
+ builder: boolean;
+}): NavigatorTab[] {
+ const tabs: NavigatorTab[] = [];
+ if (input.descriptor.catalog && !input.descriptor.targetLabel) {
+ tabs.push({ id: "catalog", label: "Catalog" });
+ }
+ if (input.builder) {
+ tabs.push({ id: "form", label: "Form" }, { id: "json", label: "JSON" });
+ }
+ return tabs;
+}
+
+/**
+ * initialNavigatorTab opens on the form: filters are what the builder is for,
+ * so it is where authoring starts rather than something to opt into. The one
+ * thing that overrides it is a raw query already worth preserving — and the
+ * starter query the descriptor supplies is not one, since nobody wrote it.
+ */
+export function initialNavigatorTab(input: {
+ tabs: NavigatorTab[];
+ search: EsSearch | undefined;
+ query: string;
+ defaultQuery?: string;
+}): string | undefined {
+ const has = (id: NavigatorTab["id"]) =>
+ input.tabs.some((tab) => tab.id === id);
+ if (!has("form")) return input.tabs[0]?.id;
+ if (input.search) return "form";
+ const authored = input.query.trim();
+ return authored && authored !== (input.defaultQuery ?? "").trim()
+ ? "json"
+ : "form";
+}
diff --git a/packages/ui/src/profiles/esFieldValues.test.ts b/packages/ui/src/profiles/esFieldValues.test.ts
new file mode 100644
index 00000000..03e1a9f3
--- /dev/null
+++ b/packages/ui/src/profiles/esFieldValues.test.ts
@@ -0,0 +1,170 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { makeFieldValueLookup, valueLookupField } from "./esFieldValues";
+import type { EsFieldMapping } from "./esQueryOperators";
+
+const fields: EsFieldMapping[] = [
+ { name: "@timestamp", dataType: "date", aggregatable: true },
+ { name: "service.name", dataType: "keyword", aggregatable: true },
+ { name: "message", dataType: "text", aggregatable: false },
+ { name: "message.keyword", dataType: "keyword", aggregatable: true },
+ { name: "trace.id", dataType: "text", aggregatable: false },
+];
+
+describe("valueLookupField", () => {
+ it("aggregates a keyword field on itself", () => {
+ expect(valueLookupField(fields, "service.name")).toBe("service.name");
+ });
+
+ it("aggregates an analyzed text field through its keyword sibling", () => {
+ expect(valueLookupField(fields, "message")).toBe("message.keyword");
+ });
+
+ it("offers no lookup for a text field without a keyword sibling", () => {
+ expect(valueLookupField(fields, "trace.id")).toBeUndefined();
+ });
+
+ it("offers no lookup for a date field, whose values are all distinct", () => {
+ expect(valueLookupField(fields, "@timestamp")).toBeUndefined();
+ });
+
+ it("offers no lookup for a field the mappings do not describe", () => {
+ expect(valueLookupField(fields, "unmapped")).toBeUndefined();
+ expect(valueLookupField(fields, undefined)).toBeUndefined();
+ });
+});
+
+const baseUrl = "/api/v1/connection/abc/browser";
+const search = { query: { op: "term", field: "env", value: "prod" } };
+
+const respondWith = (body: unknown, status = 200) =>
+ ({
+ ok: status >= 200 && status < 300,
+ status,
+ json: async () => body,
+ text: async () => JSON.stringify(body),
+ }) as Response;
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe("makeFieldValueLookup", () => {
+ it("asks nothing without a connection or an index", () => {
+ expect(makeFieldValueLookup({ baseUrl: "", index: "logs-*" })).toBeUndefined();
+ expect(makeFieldValueLookup({ baseUrl, index: "" })).toBeUndefined();
+ });
+
+ it("posts the field, the substring and the scope to the browser", async () => {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue(
+ respondWith({ values: [{ value: "payments", count: 3 }], total: 9, scoped: true }),
+ );
+ vi.stubGlobal("fetch", fetchMock);
+
+ const source = makeFieldValueLookup({
+ baseUrl,
+ index: "logs-*",
+ roles: { since: "time-from" },
+ });
+ const result = await source!({ field: "service.name", search }).fetch("pay");
+
+ expect(result).toEqual({
+ values: [{ value: "payments", count: 3 }],
+ total: 9,
+ scoped: true,
+ });
+ const [url, init] = fetchMock.mock.calls[0];
+ expect(url).toBe(`${baseUrl}/values`);
+ expect(JSON.parse(init.body)).toMatchObject({
+ index: "logs-*",
+ field: "service.name",
+ q: "pay",
+ search,
+ roles: { since: "time-from" },
+ });
+ });
+
+ // A sibling condition left half-finished cannot compile, and an empty value
+ // list would read as "this field holds nothing". The whole index is asked
+ // instead, and the answer says the scope was widened.
+ it("retries across the whole index when the scope will not compile", async () => {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(respondWith("condition has no value", 422))
+ .mockResolvedValueOnce(respondWith({ values: [], total: 0, scoped: false }));
+ vi.stubGlobal("fetch", fetchMock);
+
+ const source = makeFieldValueLookup({ baseUrl, index: "logs-*" });
+ const result = await source!({ field: "service.name", search }).fetch("");
+
+ expect(result.scoped).toBe(false);
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ expect(JSON.parse(fetchMock.mock.calls[1][1].body).search).toBeUndefined();
+ });
+
+ it("surfaces a lookup that fails for any other reason", async () => {
+ const fetchMock = vi.fn().mockResolvedValue(respondWith("index_not_found", 404));
+ vi.stubGlobal("fetch", fetchMock);
+
+ const source = makeFieldValueLookup({ baseUrl, index: "logs-*" });
+ await expect(source!({ field: "service.name", search }).fetch("")).rejects.toThrow(
+ /index_not_found/,
+ );
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ // The scope is compiled server-side, so a sibling condition interpolating
+ // {{.params.env}} only narrows the suggestions if the values travel with it.
+ it("posts the parameter values the scope is compiled against", async () => {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue(respondWith({ values: [], total: 0, scoped: true }));
+ vi.stubGlobal("fetch", fetchMock);
+
+ const source = makeFieldValueLookup({
+ baseUrl,
+ index: "logs-*",
+ params: { env: "prod" },
+ });
+ await source!({ field: "service.name", search }).fetch("");
+
+ expect(JSON.parse(fetchMock.mock.calls[0][1].body).params).toEqual({
+ env: "prod",
+ });
+ });
+
+ it("leaves the parameter values off when none are declared", async () => {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue(respondWith({ values: [], total: 0, scoped: true }));
+ vi.stubGlobal("fetch", fetchMock);
+
+ const source = makeFieldValueLookup({ baseUrl, index: "logs-*", params: {} });
+ await source!({ field: "service.name", search }).fetch("");
+
+ expect(JSON.parse(fetchMock.mock.calls[0][1].body)).not.toHaveProperty("params");
+ });
+
+ it("keys a lookup by every input that changes the compiled value scope", () => {
+ const source = makeFieldValueLookup({ baseUrl, index: "logs-*" })!;
+ const scoped = source({ field: "service.name", search });
+ expect(scoped.key).not.toBe(source({ field: "service.name" }).key);
+ expect(scoped.key).not.toBe(source({ field: "host.name", search }).key);
+ expect(scoped.key).toBe(source({ field: "service.name", search }).key);
+
+ const parameterized = makeFieldValueLookup({
+ baseUrl,
+ index: "logs-*",
+ params: { env: "prod" },
+ })!;
+ expect(parameterized({ field: "service.name", search }).key).not.toBe(scoped.key);
+
+ const withRole = makeFieldValueLookup({
+ baseUrl,
+ index: "logs-*",
+ roles: { since: "time-from" },
+ })!;
+ expect(withRole({ field: "service.name", search }).key).not.toBe(scoped.key);
+ });
+});
diff --git a/packages/ui/src/profiles/esFieldValues.ts b/packages/ui/src/profiles/esFieldValues.ts
new file mode 100644
index 00000000..071a6c9d
--- /dev/null
+++ b/packages/ui/src/profiles/esFieldValues.ts
@@ -0,0 +1,132 @@
+/**
+ * What a field actually holds. The browser answers a terms aggregation over the
+ * selected index, so an operand is picked from real values rather than typed
+ * from memory. Which field the aggregation can run on is not always the field
+ * being filtered — an analyzed text field aggregates through its keyword
+ * sibling — and that resolution is owned here.
+ */
+
+import type { EsSearch } from "./esQueryBuilderModel";
+import { fieldFamily, type EsFieldMapping } from "./esQueryOperators";
+
+export type FieldValue = { value: string; count: number };
+
+export type FieldValuesResult = {
+ values: FieldValue[];
+ total: number;
+ /** Whether the values reflect the rest of the query or the whole index. */
+ scoped: boolean;
+};
+
+/**
+ * One resolved lookup. `key` identifies what is being asked — field and scope —
+ * so a consumer can cache the answer without re-serializing the request.
+ */
+export type FieldValuesQuery = {
+ key: string;
+ fetch: (query: string) => Promise;
+};
+
+/** A host's lookup, bound to a connection and an index. */
+export type FieldValuesSource = (request: {
+ field: string;
+ search?: EsSearch;
+}) => FieldValuesQuery;
+
+const valueLimit = 100;
+
+/**
+ * valueLookupField resolves the field a terms aggregation can run on, or
+ * undefined when none can. A text field is analyzed, so its own doc values are
+ * absent and the aggregation goes through the keyword sibling _field_caps
+ * reports beside it. Dates are excluded deliberately: every timestamp is
+ * distinct, so a value list says nothing the date-math presets do not.
+ */
+export function valueLookupField(
+ fields: EsFieldMapping[],
+ name: string | undefined,
+): string | undefined {
+ if (!name) return undefined;
+ const field = fields.find((entry) => entry.name === name);
+ if (!field || fieldFamily(field) === "date") return undefined;
+ if (field.aggregatable !== false) return field.name;
+ const keyword = fields.find(
+ (entry) => entry.name === `${name}.keyword` && entry.aggregatable !== false,
+ );
+ return keyword?.name;
+}
+
+type ValuesRequestBody = {
+ index: string;
+ field: string;
+ q?: string;
+ limit?: number;
+ search?: EsSearch;
+ params?: Record;
+ roles?: Record;
+};
+
+async function postValues(
+ baseUrl: string,
+ body: ValuesRequestBody,
+): Promise {
+ const response = await fetch(`${baseUrl}/values`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ if (!response.ok) {
+ const text = (await response.text()).trim();
+ const error = new Error(text || `value lookup failed: ${response.status}`);
+ (error as Error & { status?: number }).status = response.status;
+ throw error;
+ }
+ return (await response.json()) as FieldValuesResult;
+}
+
+/**
+ * makeFieldValueLookup binds a lookup to a connection and an index. A scope the
+ * server cannot compile — a sibling condition left half-finished — is answered
+ * across the whole index instead, and the result says so, so the widened scope
+ * is visible rather than silently assumed. The scope is compiled server-side
+ * against `params`, so a sibling condition that interpolates `{{.params.…}}`
+ * narrows the suggestions the same way a run would.
+ */
+export function makeFieldValueLookup(options: {
+ baseUrl: string;
+ index: string;
+ params?: Record;
+ roles?: Record;
+}): FieldValuesSource | undefined {
+ const { baseUrl, index, params, roles } = options;
+ if (!baseUrl || !index) return undefined;
+ return ({ field, search }) => ({
+ key: JSON.stringify([
+ baseUrl,
+ index,
+ field,
+ search ?? null,
+ params ?? null,
+ roles ?? null,
+ ]),
+ fetch: async (query) => {
+ const body: ValuesRequestBody = {
+ index,
+ field,
+ q: query,
+ limit: valueLimit,
+ ...(search ? { search } : {}),
+ ...(params && Object.keys(params).length ? { params } : {}),
+ ...(roles && Object.keys(roles).length ? { roles } : {}),
+ };
+ try {
+ return await postValues(baseUrl, body);
+ } catch (error) {
+ const status = (error as Error & { status?: number }).status;
+ if (!search || status !== 422) throw error;
+ const { search: _dropped, ...unscoped } = body;
+ return postValues(baseUrl, unscoped);
+ }
+ },
+ });
+}
diff --git a/packages/ui/src/profiles/esParamMappingModel.test.ts b/packages/ui/src/profiles/esParamMappingModel.test.ts
new file mode 100644
index 00000000..1af0940c
--- /dev/null
+++ b/packages/ui/src/profiles/esParamMappingModel.test.ts
@@ -0,0 +1,307 @@
+import { describe, expect, it } from "vitest";
+import type { EsSearch } from "./esQueryBuilderModel";
+import {
+ addParamMapping,
+ bindParamOperand,
+ paramMappings,
+ reconcileParamMappings,
+ reconcileSearchParamMappings,
+ removeParamMapping,
+} from "./esParamMappingModel";
+import type { ParamDraft } from "./profileWizardModel";
+
+const filterParams: ParamDraft[] = [
+ { name: "service", type: "string", role: "filter" },
+ { name: "schemes", type: "list", role: "filter" },
+];
+
+describe("parameter query mappings", () => {
+ it("adds more than one scalar condition for the same parameter", () => {
+ const first = addParamMapping({
+ search: {},
+ params: filterParams,
+ name: "service",
+ field: "service.name",
+ });
+ const second = addParamMapping({
+ ...first,
+ name: "service",
+ field: "peer.service",
+ });
+
+ expect(second).toEqual({
+ search: {
+ query: {
+ op: "bool",
+ conditions: [
+ {
+ op: "term",
+ occur: "filter",
+ field: "service.name",
+ value: { param: "service" },
+ optional: true,
+ },
+ {
+ op: "term",
+ occur: "filter",
+ field: "peer.service",
+ value: { param: "service" },
+ optional: true,
+ },
+ ],
+ },
+ },
+ params: filterParams,
+ });
+ expect(paramMappings(second.search, "service")).toEqual([
+ { path: [0], field: "service.name", operand: "value" },
+ { path: [1], field: "peer.service", operand: "value" },
+ ]);
+ });
+
+ it("moves a list mapping and keeps its native field linked", () => {
+ const existing: EsSearch = {
+ query: {
+ op: "bool",
+ conditions: [
+ {
+ op: "terms",
+ field: "old.scheme",
+ values: ["literal", { param: "schemes" }],
+ },
+ ],
+ },
+ };
+
+ const result = addParamMapping({
+ search: existing,
+ params: filterParams,
+ name: "schemes",
+ field: "scheme.id",
+ });
+
+ expect(result.search.query?.conditions).toEqual([
+ { op: "terms", field: "old.scheme", values: ["literal"] },
+ {
+ op: "terms",
+ occur: "filter",
+ field: "scheme.id",
+ value: { param: "schemes" },
+ optional: true,
+ },
+ ]);
+ expect(result.params[1].field).toBe("scheme.id");
+ expect(paramMappings(result.search, "schemes")).toEqual([
+ { path: [1], field: "scheme.id", operand: "value" },
+ ]);
+ });
+
+ it("removes only the selected reference and prunes an empty leaf", () => {
+ const search: EsSearch = {
+ query: {
+ op: "bool",
+ conditions: [
+ {
+ op: "terms",
+ field: "scheme.id",
+ values: ["literal", { param: "schemes" }],
+ },
+ { op: "term", field: "service.name", value: { param: "service" } },
+ ],
+ },
+ };
+
+ const list = removeParamMapping({
+ search,
+ params: filterParams,
+ name: "schemes",
+ path: [0],
+ });
+ expect(list.search.query?.conditions).toEqual([
+ { op: "terms", field: "scheme.id", values: ["literal"] },
+ { op: "term", field: "service.name", value: { param: "service" } },
+ ]);
+ expect(list.params[1].field).toBeUndefined();
+
+ const scalar = removeParamMapping({
+ ...list,
+ name: "service",
+ path: [1],
+ });
+ expect(scalar.search.query?.conditions).toEqual([
+ { op: "terms", field: "scheme.id", values: ["literal"] },
+ ]);
+ });
+
+ it("binds a multiple operand canonically without a stale singular value", () => {
+ const result = bindParamOperand({
+ search: {
+ query: {
+ op: "terms",
+ field: "scheme.id",
+ value: "stale",
+ values: ["literal"],
+ },
+ },
+ params: filterParams,
+ path: [],
+ operand: "values",
+ name: "schemes",
+ });
+
+ expect(result.search.query).toEqual({
+ op: "terms",
+ field: "scheme.id",
+ value: undefined,
+ values: [{ param: "schemes" }],
+ gt: undefined,
+ gte: undefined,
+ lt: undefined,
+ lte: undefined,
+ conditions: undefined,
+ });
+ expect(result.params[1].field).toBe("scheme.id");
+ });
+
+ it("keeps a linked list field synchronized across query tree edits", () => {
+ const previousSearch: EsSearch = {
+ query: {
+ op: "bool",
+ conditions: [
+ {
+ op: "terms",
+ field: "scheme.id",
+ value: { param: "schemes" },
+ },
+ ],
+ },
+ };
+ const params = [
+ filterParams[0],
+ { ...filterParams[1], field: "scheme.id" },
+ ];
+
+ const moved = reconcileSearchParamMappings({
+ previousSearch,
+ nextSearch: {
+ query: {
+ op: "bool",
+ conditions: [
+ {
+ op: "terms",
+ field: "scheme.code",
+ value: { param: "schemes" },
+ },
+ ],
+ },
+ },
+ params,
+ });
+ expect(moved.params[1].field).toBe("scheme.code");
+
+ const removed = reconcileSearchParamMappings({
+ previousSearch: moved.search,
+ nextSearch: { query: { op: "bool", conditions: [] } },
+ params: moved.params,
+ });
+ expect(removed.params[1].field).toBeUndefined();
+ });
+
+ it("preserves a native-only list field across unrelated query edits", () => {
+ const params = [
+ filterParams[0],
+ { ...filterParams[1], field: "legacy.scheme" },
+ ];
+
+ const edit = reconcileSearchParamMappings({
+ previousSearch: {},
+ nextSearch: {
+ query: { op: "term", field: "service.name", value: "payments" },
+ },
+ params,
+ });
+
+ expect(edit.params[1].field).toBe("legacy.scheme");
+ });
+
+ it("maps time roles and rejects automatic paging roles", () => {
+ const time = addParamMapping({
+ search: {},
+ params: [{ name: "from", type: "date", role: "time-from" }],
+ name: "from",
+ field: "startTimeMillis",
+ });
+ expect(time.search.timeField).toBe("startTimeMillis");
+
+ expect(() =>
+ addParamMapping({
+ search: {},
+ params: [{ name: "limit", type: "number", role: "limit" }],
+ name: "limit",
+ field: "size",
+ }),
+ ).toThrow("limit parameter limit cannot map to a query field");
+ });
+});
+
+describe("parameter definition reconciliation", () => {
+ it("renames every operand and gate atomically", () => {
+ const search: EsSearch = {
+ query: {
+ op: "bool",
+ conditions: [
+ { op: "term", field: "service.name", value: { param: "service" } },
+ { op: "exists", field: "error", when: "service" },
+ ],
+ },
+ };
+ const next = [{ ...filterParams[0], name: "application" }, filterParams[1]];
+
+ expect(
+ reconcileParamMappings({ search, previous: filterParams, next }),
+ ).toEqual({
+ search: {
+ query: {
+ op: "bool",
+ conditions: [
+ {
+ op: "term",
+ field: "service.name",
+ value: { param: "application" },
+ },
+ { op: "exists", field: "error", when: "application" },
+ ],
+ },
+ },
+ params: next,
+ });
+ });
+
+ it("removes deleted references and preserves unrelated conditions", () => {
+ const search: EsSearch = {
+ query: {
+ op: "bool",
+ conditions: [
+ { op: "term", field: "service.name", value: { param: "service" } },
+ { op: "term", field: "level", value: "error" },
+ ],
+ },
+ };
+
+ expect(
+ reconcileParamMappings({
+ search,
+ previous: filterParams,
+ next: [filterParams[1]],
+ }),
+ ).toEqual({
+ search: {
+ query: {
+ op: "bool",
+ conditions: [{ op: "term", field: "level", value: "error" }],
+ },
+ },
+ params: [filterParams[1]],
+ });
+ });
+});
diff --git a/packages/ui/src/profiles/esParamMappingModel.ts b/packages/ui/src/profiles/esParamMappingModel.ts
new file mode 100644
index 00000000..74f7dffa
--- /dev/null
+++ b/packages/ui/src/profiles/esParamMappingModel.ts
@@ -0,0 +1,410 @@
+import type {
+ ConditionPath,
+ EsCondition,
+ EsSearch,
+ EsValue,
+} from "./esQueryBuilderModel";
+import {
+ conditionAt,
+ emptyGroup,
+ isParamValue,
+ updateAt,
+} from "./esQueryBuilderModel";
+import { conditionOperandPatch } from "./esQueryOperandModel";
+import { applyPatch, type ParamDraft } from "./profileWizardModel";
+
+export type ParamOperand = "value" | "values" | "gt" | "gte" | "lt" | "lte";
+
+export type ParamMapping = {
+ path: ConditionPath;
+ field: string;
+ operand: ParamOperand;
+};
+
+export type ParamMappingEdit = {
+ search: EsSearch;
+ params: ParamDraft[];
+};
+
+export function paramMappings(
+ search: EsSearch | undefined,
+ name: string,
+): ParamMapping[] {
+ const found: ParamMapping[] = [];
+ const walk = (condition: EsCondition | undefined, path: ConditionPath) => {
+ if (!condition) return;
+ for (const operand of operands) {
+ const value = condition[operand];
+ const values = Array.isArray(value) ? value : [value];
+ if (
+ condition.field &&
+ values.some((entry) => isParamValue(entry) && entry.param === name)
+ ) {
+ found.push({ path, field: condition.field, operand });
+ }
+ }
+ condition.conditions?.forEach((child, index) =>
+ walk(child, [...path, index]),
+ );
+ };
+ walk(search?.query, []);
+ return found;
+}
+
+export function addParamMapping({
+ search,
+ params,
+ name,
+ field,
+}: ParamMappingEdit & { name: string; field: string }): ParamMappingEdit {
+ const param = namedParam(params, name);
+ if (param.role === "limit" || param.role === "offset") {
+ throw new Error(
+ `${param.role} parameter ${name} cannot map to a query field`,
+ );
+ }
+ if (param.role === "time-from" || param.role === "time-to") {
+ return { search: { ...search, timeField: field }, params };
+ }
+ const withoutPrevious =
+ param.type === "list" ? stripParamReferences(search, name) : search;
+ const condition: EsCondition = {
+ op: param.type === "list" ? "terms" : "term",
+ occur: "filter",
+ field,
+ value: { param: name },
+ ...(!param.required ? { optional: true } : {}),
+ };
+ return {
+ search: appendCondition(withoutPrevious, condition),
+ params: syncNativeField(
+ params,
+ name,
+ param.type === "list" ? field : undefined,
+ ),
+ };
+}
+
+export function bindParamOperand({
+ search,
+ params,
+ path,
+ operand,
+ name,
+}: ParamMappingEdit & {
+ path: ConditionPath;
+ operand: ParamOperand;
+ name: string;
+}): ParamMappingEdit {
+ const param = namedParam(params, name);
+ if (param.role && param.role !== "filter") {
+ throw new Error(
+ `${param.role} parameter ${name} cannot bind a query operand`,
+ );
+ }
+ const condition = search.query && conditionAt(search.query, path);
+ if (!condition?.field)
+ throw new Error(`condition for parameter ${name} has no field`);
+ const value = { param: name };
+ const patch =
+ operand === "values"
+ ? conditionOperandPatch({ arity: "multiple", values: [value] })
+ : operand === "value"
+ ? conditionOperandPatch({ arity: "single", value })
+ : conditionOperandPatch({ arity: "range", bound: operand, value });
+ const query = updateAt(search.query as EsCondition, path, (current) =>
+ applyPatch(current, patch),
+ );
+ const bound = { ...search, query };
+ return {
+ search:
+ param.type === "list" ? stripParamReferences(bound, name, path) : bound,
+ params: syncNativeField(
+ params,
+ name,
+ param.type === "list" ? condition.field : undefined,
+ ),
+ };
+}
+
+export function removeParamMapping({
+ search,
+ params,
+ name,
+ path,
+}: ParamMappingEdit & {
+ name: string;
+ path?: ConditionPath;
+}): ParamMappingEdit {
+ const param = namedParam(params, name);
+ if (param.role === "time-from" || param.role === "time-to") {
+ const next = { ...search };
+ delete next.timeField;
+ return { search: next, params };
+ }
+ if (!path) {
+ if (param.type !== "list")
+ throw new Error(`parameter ${name} has no field mapping to remove`);
+ return { search, params: syncNativeField(params, name, undefined) };
+ }
+ const query = search.query
+ ? editAt(search.query, path, (condition) =>
+ removeReference(condition, name),
+ )
+ : undefined;
+ return {
+ search: { ...search, query: query ?? emptyGroup() },
+ params: syncNativeField(params, name, undefined),
+ };
+}
+
+export function reconcileParamMappings({
+ search,
+ previous,
+ next,
+}: {
+ search: EsSearch;
+ previous: ParamDraft[];
+ next: ParamDraft[];
+}): ParamMappingEdit {
+ let reconciled = search;
+ const previousNames = new Set(
+ previous.map((param) => param.name).filter(Boolean),
+ );
+ const nextNames = new Set(next.map((param) => param.name).filter(Boolean));
+ const renamed = new Set();
+ if (previous.length === next.length) {
+ previous.forEach((param, index) => {
+ const oldName = param.name;
+ const newName = next[index]?.name;
+ if (
+ oldName &&
+ newName &&
+ oldName !== newName &&
+ !nextNames.has(oldName) &&
+ !previousNames.has(newName)
+ ) {
+ reconciled = renameParamReferences(reconciled, oldName, newName);
+ renamed.add(oldName);
+ }
+ });
+ }
+ for (const param of previous) {
+ if (param.name && !renamed.has(param.name) && !nextNames.has(param.name)) {
+ reconciled = stripParamReferences(reconciled, param.name);
+ }
+ }
+ return {
+ search: reconciled,
+ params: syncAllNativeFields(reconciled, next, search),
+ };
+}
+
+export function reconcileSearchParamMappings({
+ previousSearch,
+ nextSearch,
+ params,
+}: {
+ previousSearch: EsSearch;
+ nextSearch: EsSearch;
+ params: ParamDraft[];
+}): ParamMappingEdit {
+ return {
+ search: nextSearch,
+ params: syncAllNativeFields(nextSearch, params, previousSearch),
+ };
+}
+
+const operands: ParamOperand[] = ["value", "values", "gt", "gte", "lt", "lte"];
+
+function namedParam(params: ParamDraft[], name: string): ParamDraft {
+ const param = params.find((candidate) => candidate.name === name);
+ if (!param) throw new Error(`parameter ${name} does not exist`);
+ return param;
+}
+
+function appendCondition(search: EsSearch, condition: EsCondition): EsSearch {
+ if (!search.query || search.query.op === "match_all") {
+ return { ...search, query: { ...emptyGroup(), conditions: [condition] } };
+ }
+ if (search.query.op === "bool") {
+ return {
+ ...search,
+ query: {
+ ...search.query,
+ conditions: [...(search.query.conditions ?? []), condition],
+ },
+ };
+ }
+ return {
+ ...search,
+ query: { ...emptyGroup(), conditions: [search.query, condition] },
+ };
+}
+
+function stripParamReferences(
+ search: EsSearch,
+ name: string,
+ keepPath?: ConditionPath,
+): EsSearch {
+ const query = stripCondition(search.query, name, [], keepPath);
+ return { ...search, query: query ?? emptyGroup() };
+}
+
+function stripCondition(
+ condition: EsCondition | undefined,
+ name: string,
+ path: ConditionPath,
+ keepPath?: ConditionPath,
+): EsCondition | undefined {
+ if (!condition) return undefined;
+ if (keepPath && samePath(path, keepPath)) return condition;
+ if (condition.when === name) return undefined;
+ let next = removeReference(condition, name);
+ if (!next) return undefined;
+ if (next.conditions) {
+ const conditions = next.conditions.flatMap((child, index) => {
+ const stripped = stripCondition(child, name, [...path, index], keepPath);
+ return stripped ? [stripped] : [];
+ });
+ next = { ...next, conditions };
+ if (conditions.length === 0 && (condition.conditions?.length ?? 0) > 0)
+ return undefined;
+ }
+ return next;
+}
+
+function removeReference(
+ condition: EsCondition,
+ name: string,
+): EsCondition | undefined {
+ let removed = false;
+ const next = { ...condition };
+ for (const operand of operands) {
+ const value = condition[operand];
+ if (operand === "values") {
+ const values = (condition.values ?? []).filter((entry) => {
+ const matches = isParamValue(entry) && entry.param === name;
+ removed ||= matches;
+ return !matches;
+ });
+ // Deleted rather than set to undefined: the search is serialized, and a
+ // present `values: undefined` is a key the backend has to interpret.
+ if (values.length) next.values = values;
+ else delete next.values;
+ } else if (isParamValue(value) && value.param === name) {
+ delete next[operand];
+ removed = true;
+ }
+ }
+ return removed && !hasOperand(next) ? undefined : next;
+}
+
+function hasOperand(condition: EsCondition): boolean {
+ return (
+ condition.value !== undefined ||
+ Boolean(condition.values?.length) ||
+ condition.gt !== undefined ||
+ condition.gte !== undefined ||
+ condition.lt !== undefined ||
+ condition.lte !== undefined ||
+ Boolean(condition.conditions?.length)
+ );
+}
+
+function editAt(
+ condition: EsCondition,
+ path: ConditionPath,
+ edit: (condition: EsCondition) => EsCondition | undefined,
+): EsCondition | undefined {
+ if (path.length === 0) return edit(condition);
+ const [target, ...rest] = path;
+ const children = condition.conditions ?? [];
+ const child = target === undefined ? undefined : children[target];
+ if (!child) throw new Error(`condition path ${path.join(".")} does not exist`);
+ const edited = editAt(child, rest, edit);
+ return {
+ ...condition,
+ conditions: children.flatMap((child, index) =>
+ index !== target ? [child] : edited ? [edited] : [],
+ ),
+ };
+}
+
+function renameParamReferences(
+ search: EsSearch,
+ oldName: string,
+ newName: string,
+): EsSearch {
+ const rename = (condition: EsCondition): EsCondition => {
+ const next = { ...condition };
+ for (const operand of operands) {
+ const value = condition[operand];
+ if (operand === "values") {
+ if (condition.values) {
+ next.values = condition.values.map((entry) =>
+ renamedValue(entry, oldName, newName),
+ );
+ }
+ } else {
+ next[operand] = renamedValue(value, oldName, newName) as never;
+ }
+ }
+ if (condition.when === oldName) next.when = newName;
+ if (condition.conditions)
+ next.conditions = condition.conditions.map(rename);
+ return next;
+ };
+ if (!search.query) return { ...search };
+ return { ...search, query: rename(search.query) };
+}
+
+function renamedValue(
+ value: EsValue,
+ oldName: string,
+ newName: string,
+): EsValue {
+ return isParamValue(value) && value.param === oldName
+ ? { param: newName }
+ : value;
+}
+
+function syncNativeField(
+ params: ParamDraft[],
+ name: string,
+ field: string | undefined,
+): ParamDraft[] {
+ const param = namedParam(params, name);
+ if (param.type !== "list") return params;
+ return params.map((candidate) => {
+ if (candidate.name !== name) return candidate;
+ const next: ParamDraft = { ...candidate, ...(field ? { field } : {}) };
+ if (!field) delete next.field;
+ return next;
+ });
+}
+
+function syncAllNativeFields(
+ search: EsSearch,
+ params: ParamDraft[],
+ previousSearch: EsSearch,
+): ParamDraft[] {
+ return params.map((param) => {
+ if (param.type !== "list" || !param.name) return param;
+ const mapping = paramMappings(search, param.name)[0];
+ const wasMapped = paramMappings(previousSearch, param.name).length > 0;
+ if (!mapping && !wasMapped) return param;
+ const field = mapping?.field;
+ if (field === param.field) return param;
+ const next: ParamDraft = { ...param, ...(field ? { field } : {}) };
+ if (!field) delete next.field;
+ return next;
+ });
+}
+
+function samePath(left: ConditionPath, right: ConditionPath): boolean {
+ return (
+ left.length === right.length &&
+ left.every((value, index) => value === right[index])
+ );
+}
diff --git a/packages/ui/src/profiles/esParamMappingPill.tsx b/packages/ui/src/profiles/esParamMappingPill.tsx
new file mode 100644
index 00000000..7300e824
--- /dev/null
+++ b/packages/ui/src/profiles/esParamMappingPill.tsx
@@ -0,0 +1,32 @@
+/**
+ * The chip showing which parameter an operand is bound to.
+ *
+ * Split from esParamOperandExtension.tsx: that module exports the operand
+ * extension, which is not a component, and a module may not export both
+ * (react/only-export-components).
+ */
+
+
+export function ParamMappingPill({
+ name,
+ label,
+ onClear
+}: {
+ name: string;
+ label: string;
+ onClear: () => void;
+}) {
+ return (
+
+ {name}
+
+ ×
+
+
+ );
+}
diff --git a/packages/ui/src/profiles/esParamOperandExtension.tsx b/packages/ui/src/profiles/esParamOperandExtension.tsx
new file mode 100644
index 00000000..6a5f6287
--- /dev/null
+++ b/packages/ui/src/profiles/esParamOperandExtension.tsx
@@ -0,0 +1,83 @@
+import { ParamMappingPill } from "./esParamMappingPill";
+import { applyPostExtensions } from "../components/json-schema-form-extensions";
+import type { FieldControl, PostExtension } from "../components/json-schema-form-types";
+import { Select } from "../components/select";
+import type { ReactNode } from "react";
+import { isParamValue, type EsValue } from "./esQueryBuilderModel";
+import type { ParamDraft } from "./profileWizardModel";
+
+const esParamOperandPost: PostExtension = (field, nodes, ctx) => {
+ if (field.schema["x-clicky-component"] !== "es-query-operand") return nodes;
+ const label = String(field.schema["x-es-operand-label"] ?? field.label);
+ const params = operandParams((ctx?.rootValue?.params ?? []) as ParamDraft[]);
+ const bound = boundParam(field.value);
+ const selector = params.length ? (
+
+ ({ value: param, label: param }))}
+ onChange={(event) => {
+ if (event.target.value) field.onChange({ param: event.target.value });
+ }}
+ />
+
+ ) : null;
+ return {
+ label: nodes.label,
+ value: bound ? (
+
+ {Array.isArray(field.value) ? nodes.value : null}
+ field.onChange(undefined)}
+ />
+
+ ) : (
+
+ {nodes.value}
+ {selector}
+
+ ),
+ };
+};
+
+export function extendEsParamOperand(input: {
+ label: string;
+ value: EsValue | EsValue[];
+ onChange: (next: unknown) => void;
+ node: ReactNode;
+ params: ParamDraft[];
+}): ReactNode {
+ const field: FieldControl = {
+ key: "operand",
+ kind: Array.isArray(input.value) ? "array" : "string",
+ label: input.label,
+ required: false,
+ schema: {
+ "x-clicky-component": "es-query-operand",
+ "x-es-operand-label": input.label,
+ },
+ value: input.value,
+ onChange: input.onChange,
+ };
+ return applyPostExtensions(
+ field,
+ { label: null, value: input.node },
+ [esParamOperandPost],
+ { rootValue: { params: input.params } },
+ ).value;
+}
+
+function operandParams(params: ParamDraft[]): string[] {
+ return params.flatMap((param) =>
+ param.name && (!param.role || param.role === "filter") ? [param.name] : [],
+ );
+}
+
+function boundParam(value: EsValue | EsValue[]): string | undefined {
+ const values = Array.isArray(value) ? value : [value];
+ return values.find(isParamValue)?.param;
+}
diff --git a/packages/ui/src/profiles/esQueryBuilder.test.tsx b/packages/ui/src/profiles/esQueryBuilder.test.tsx
new file mode 100644
index 00000000..29dc1d7f
--- /dev/null
+++ b/packages/ui/src/profiles/esQueryBuilder.test.tsx
@@ -0,0 +1,349 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it, vi } from "vitest";
+import { EsQueryBuilder } from "./esQueryBuilder";
+import { esQueryBuilderFormExtensions } from "./esQueryBuilderExtension";
+import { defaultParamValues, esQueryFields, paramNames, paramRoles } from "./esQueryBuilderForm";
+import { compileRequestBody, type EsCompileRequest } from "./esQueryCompile";
+import type { EsSearch } from "./esQueryBuilderModel";
+
+// useCompiledSearch only issues its request once effects run, which server
+// rendering never does — so the wiring is asserted on what the hook was handed.
+const compileInputs = vi.hoisted(() => [] as EsCompileRequest[]);
+vi.mock("./esQueryCompile", async (importOriginal) => {
+ const original = await importOriginal();
+ return {
+ ...original,
+ useCompiledSearch: (input: EsCompileRequest) => {
+ compileInputs.push(input);
+ return original.useCompiledSearch(input);
+ },
+ };
+});
+import type {
+ EsBuilderVocabulary,
+ EsFieldMapping,
+ EsOperatorInfo,
+} from "./esQueryOperators";
+
+const operator = (
+ op: string,
+ fieldTypes: string[],
+ extra: Partial = {},
+): EsOperatorInfo => ({
+ op,
+ label: op,
+ arity: "single",
+ needsField: true,
+ fieldTypes,
+ ...extra,
+});
+
+const vocabulary: EsBuilderVocabulary = {
+ catalog: [
+ operator("term", ["keyword", "number", "boolean", "ip", "date"]),
+ operator("terms", ["keyword", "number", "boolean", "ip", "date"], {
+ arity: "multiple",
+ }),
+ operator("range", ["date", "number", "ip"], { arity: "range" }),
+ operator("exists", ["any"], { arity: "none" }),
+ operator("bool", [], {
+ op: "bool",
+ label: "group",
+ arity: "group",
+ group: true,
+ needsField: false,
+ }),
+ ],
+ occurs: ["filter", "must", "should", "must_not"],
+ qualifierNames: ["boost"],
+ qualifiers: { boost: { type: "number", title: "Boost" } },
+ sortOrders: ["asc", "desc"],
+};
+
+const fields: EsFieldMapping[] = [
+ { name: "@timestamp", dataType: "date", aggregatable: true },
+ { name: "service.name", dataType: "keyword", aggregatable: true },
+ { name: "message", dataType: "text", aggregatable: false },
+];
+
+const render = (search: EsSearch, extra: Record = {}) =>
+ renderToStaticMarkup(
+ {}}
+ fields={fields}
+ vocabulary={vocabulary}
+ {...extra}
+ />,
+ );
+
+describe("EsQueryBuilder", () => {
+ it("renders the root group without a clause or remove control", () => {
+ const html = render({});
+ expect(html).toContain('data-es-group="bool"');
+ expect(html).not.toContain('aria-label="Clause"');
+ expect(html).not.toContain('aria-label="Remove group"');
+ });
+
+ it("renders one condition row per child of the root group", () => {
+ const html = render({
+ query: {
+ op: "bool",
+ conditions: [
+ { op: "term", field: "service.name" },
+ { op: "exists", field: "message" },
+ ],
+ },
+ });
+ expect(html.match(/aria-label="Operator"/g)).toHaveLength(2);
+ });
+
+ it("offers only date fields as the time field", () => {
+ const html = render({ timeField: "@timestamp" });
+ const at = html.indexOf('aria-label="Time field"');
+ expect(at).toBeGreaterThan(-1);
+ // Combobox keeps its options closed, so the selected value is what SSR shows.
+ expect(html.slice(at, html.indexOf(">", at))).toContain('value="@timestamp"');
+ });
+
+ it("renders the sort and output editors", () => {
+ const html = render({ sort: [{ field: "@timestamp", order: "desc" }] });
+ expect(html).toContain('aria-label="Sort field"');
+ expect(html).toContain('aria-label="From"');
+ });
+
+ it("shows the compiled preview only when a compilation is supplied", () => {
+ expect(render({})).not.toContain("Compiled DSL");
+ expect(
+ render({}, {
+ compilation: { query: '{"query":{"match_all":{}}}', loading: false },
+ }),
+ ).toContain("Compiled DSL");
+ });
+
+ // Raw DSL is the other tab, not a button inside this one: the builder edits a
+ // specification and knows nothing about where the host stores the alternative.
+ it("keeps the mode switch out of the builder", () => {
+ expect(render({})).not.toContain("Edit raw DSL");
+ });
+});
+
+describe("EsQueryBuilder value lookups", () => {
+ // An analyzed text field has no doc values of its own, so its keyword sibling
+ // is what a value list can be aggregated from.
+ const lookupFields: EsFieldMapping[] = [
+ ...fields,
+ { name: "message.keyword", dataType: "keyword", aggregatable: true },
+ ];
+
+ const twoConditions: EsSearch = {
+ query: {
+ op: "bool",
+ conditions: [
+ { op: "term", field: "service.name", value: "pay" },
+ { op: "term", field: "message", value: "timeout" },
+ ],
+ },
+ };
+
+ const renderWithLookup = (search: EsSearch) => {
+ const asked: { field: string; search?: EsSearch }[] = [];
+ const values = (request: { field: string; search?: EsSearch }) => {
+ asked.push(request);
+ return { key: request.field, fetch: async () => ({ values: [], total: 0, scoped: true }) };
+ };
+ const html = renderToStaticMarkup(
+
+ {}}
+ fields={lookupFields}
+ vocabulary={vocabulary}
+ values={values}
+ />
+ ,
+ );
+ return { asked, html };
+ };
+
+ // The row being edited holds the value the list is meant to complete, so
+ // scoping by it would filter the suggestions down to what was already typed.
+ it("scopes a row's lookup to the query without that row", () => {
+ const { asked } = renderWithLookup(twoConditions);
+ expect(asked.map((entry) => entry.field)).toEqual([
+ "service.name",
+ "message.keyword",
+ ]);
+ expect(asked[0]?.search?.query?.conditions).toEqual([
+ { op: "term", field: "message", value: "timeout" },
+ ]);
+ expect(asked[1]?.search?.query?.conditions).toEqual([
+ { op: "term", field: "service.name", value: "pay" },
+ ]);
+ });
+
+ it("asks for no lookup on a field that cannot be aggregated", () => {
+ const { asked } = renderWithLookup({
+ query: { op: "bool", conditions: [{ op: "term", field: "@timestamp" }] },
+ });
+ expect(asked).toEqual([]);
+ });
+
+ it("picks the operand from the field's values when a lookup exists", () => {
+ const { html } = renderWithLookup({
+ query: { op: "bool", conditions: [{ op: "term", field: "service.name" }] },
+ });
+ expect(html).toMatch(/role="combobox"[^>]*aria-label="Value"/);
+ });
+
+ it("picks multiple operands from existing values for is one of", () => {
+ const { html } = renderWithLookup({
+ query: {
+ op: "bool",
+ conditions: [{ op: "terms", field: "service.name", values: ["payments"] }],
+ },
+ });
+ expect(html).toMatch(/role="combobox"[^>]*aria-label="Values"/);
+ });
+
+ it("leaves the operand a plain input when the host has no connection", () => {
+ const html = render({
+ query: { op: "bool", conditions: [{ op: "term", field: "service.name" }] },
+ });
+ expect(html).toContain('aria-label="Value"');
+ expect(html).not.toMatch(/role="combobox"[^>]*aria-label="Value"/);
+ });
+});
+
+describe("esQueryBuilderFormExtensions", () => {
+ const [post] = esQueryBuilderFormExtensions.post;
+ const nodes = { label: "label", value: "input" };
+ const field = (component: string | undefined) => ({
+ key: "search",
+ kind: "object" as const,
+ label: "Search",
+ required: false,
+ schema: component ? { "x-clicky-component": component } : {},
+ value: {},
+ onChange: () => {},
+ });
+
+ it("passes the rendered nodes through for any other component", () => {
+ expect(post(field("profile-query-builder"), nodes)).toBe(nodes);
+ expect(post(field(undefined), nodes)).toBe(nodes);
+ });
+
+ it("replaces the value node for the es-query-builder component", () => {
+ const replaced = post(field("es-query-builder"), nodes);
+ expect(replaced.label).toBe(nodes.label);
+ expect(replaced.value).not.toBe(nodes.value);
+ });
+});
+
+describe("param plumbing", () => {
+ it("lists declared parameter names in order, skipping unnamed drafts", () => {
+ expect(
+ paramNames([{ name: "env" }, {}, { name: "" }, { name: "since" }]),
+ ).toEqual(["env", "since"]);
+ });
+
+ it("maps only the parameters that carry a role", () => {
+ expect(
+ paramRoles([
+ { name: "since", role: "time-from" },
+ { name: "env" },
+ { name: "rows", role: "limit" },
+ ]),
+ ).toEqual({ since: "time-from", rows: "limit" });
+ });
+
+ it("takes the default of every named parameter that declares one", () => {
+ expect(
+ defaultParamValues([
+ { name: "country", default: "kenya" },
+ { name: "env" },
+ { name: "rows", default: 500 },
+ { default: "orphan" },
+ ]),
+ ).toEqual({ country: "kenya", rows: 500 });
+ });
+
+ it("returns empty plumbing when the profile declares no parameters", () => {
+ expect(paramNames(undefined)).toEqual([]);
+ expect(paramRoles(undefined)).toEqual({});
+ expect(defaultParamValues(undefined)).toEqual({});
+ });
+});
+
+// The preview compiles server-side against the parameter values a run would
+// start with, so an operand that binds {param:…} or interpolates {{.params.…}}
+// resolves in the panel instead of showing template text.
+describe("EsQueryBuilderField compilation", () => {
+ const [post] = esQueryBuilderFormExtensions.post;
+
+ it("sends the declared parameter defaults and roles to /compile", () => {
+ compileInputs.length = 0;
+ renderToStaticMarkup(
+
+ {
+ post(
+ {
+ key: "search",
+ kind: "object" as const,
+ label: "Search",
+ required: false,
+ schema: { "x-clicky-component": "es-query-builder" },
+ value: {
+ query: {
+ op: "term",
+ field: "process.serviceName",
+ value: "{{.params.country}}-api",
+ },
+ },
+ onChange: () => {},
+ },
+ { label: "label", value: "input" },
+ {
+ rootValue: {
+ provider: {
+ connection: "connection://11111111-2222-3333-4444-555555555555",
+ options: { index: "jaeger-span*" },
+ },
+ params: [
+ { name: "country", default: "kenya" },
+ { name: "since", role: "time-from", default: "now-1h" },
+ { name: "env" },
+ ],
+ },
+ },
+ ).value as React.ReactNode
+ }
+ ,
+ );
+
+ expect(compileInputs).toHaveLength(1);
+ expect(compileInputs[0]?.params).toEqual({
+ country: "kenya",
+ since: "now-1h",
+ });
+ expect(compileInputs[0]?.roles).toEqual({ since: "time-from" });
+ expect(JSON.parse(compileRequestBody(compileInputs[0]!)).params).toEqual({
+ country: "kenya",
+ since: "now-1h",
+ });
+ });
+});
+
+describe("esQueryFields", () => {
+ it("reads the mappings off an OpenSearch field completion", () => {
+ expect(
+ esQueryFields({ kind: "json-fields", vocabulary: "opensearch", fields }),
+ ).toEqual(fields);
+ });
+
+ it("builds against free text for a completion of any other kind", () => {
+ expect(esQueryFields({ kind: "sql", dialect: "postgresql" })).toEqual([]);
+ expect(esQueryFields(undefined)).toEqual([]);
+ });
+});
diff --git a/packages/ui/src/profiles/esQueryBuilder.tsx b/packages/ui/src/profiles/esQueryBuilder.tsx
new file mode 100644
index 00000000..e649a33e
--- /dev/null
+++ b/packages/ui/src/profiles/esQueryBuilder.tsx
@@ -0,0 +1,260 @@
+/**
+ * The query builder panel and the form extension that mounts it. The panel is
+ * pure: it takes a specification and the vocabulary the schema described, and
+ * reports edits. Everything that needs a connection — field mappings and the
+ * compiled preview — is wired by the host that has one.
+ */
+
+import { Combobox } from "../components/Combobox";
+import type { JsonSchemaProperty } from "../components/json-schema-form-types";
+import {
+ browserBaseUrl,
+ savedConnectionID,
+ useInspection
+} from "./connectionBrowserModel";
+import {
+ conditionAt,
+ emptyGroup,
+ insertAt,
+ removeAt,
+ updateAt,
+ type EsSearch
+} from "./esQueryBuilderModel";
+import {
+ makeFieldValueLookup,
+ valueLookupField,
+ type FieldValuesSource
+} from "./esFieldValues";
+import { EsQueryClauseGroup } from "./esQueryClauseGroup";
+import type {
+ EsQueryContext,
+ EsQueryTreeActions
+} from "./esQueryConditionRow";
+import {
+ esBuilderVocabulary,
+ fieldFamily,
+ type EsBuilderVocabulary,
+ type EsFieldMapping
+} from "./esQueryOperators";
+import { EsQueryOutputEditor } from "./esQueryOutputEditor";
+import {
+ EsQueryPreview
+ } from "./esQueryPreview";
+import { EsQuerySortEditor } from "./esQuerySortEditor";
+import type { ProfileDraft } from "./profileBuilderWorkspace";
+import type { ParamDraft } from "./profileWizardModel";
+import {
+ bindParamOperand,
+ reconcileSearchParamMappings,
+ removeParamMapping,
+ type ParamMappingEdit
+} from "./esParamMappingModel";
+import { defaultParamValues, esQueryFields, paramRoles } from "./esQueryBuilderForm";
+import { useCompiledSearch, type EsCompilation } from "./esQueryCompile";
+
+export type EsQueryBuilderProps = {
+ search: EsSearch;
+ onChange: (search: EsSearch) => void;
+ fields: EsFieldMapping[];
+ vocabulary: EsBuilderVocabulary;
+ /** Declared profile parameters an operand can bind to. */
+ params?: ParamDraft[];
+ onMappingChange?: (edit: ParamMappingEdit) => void;
+ /** Where a field's own values come from; absent without a connection. */
+ values?: FieldValuesSource;
+ compilation?: EsCompilation;
+ className?: string;
+};
+
+export function EsQueryBuilder({
+ search,
+ onChange,
+ fields,
+ vocabulary,
+ params = [],
+ onMappingChange,
+ values,
+ compilation,
+ className
+}: EsQueryBuilderProps) {
+ const root = search.query ?? emptyGroup();
+ const mappingFieldsChanged = (edit: ParamMappingEdit) =>
+ edit.params.some((param, index) => param.field !== params[index]?.field);
+ const commitMappingEdit = (edit: ParamMappingEdit) => {
+ if (onMappingChange) {
+ onMappingChange(edit);
+ return;
+ }
+ if (mappingFieldsChanged(edit)) {
+ throw new Error(
+ "list parameter mappings require an atomic mapping change handler",
+ );
+ }
+ onChange(edit.search);
+ };
+ const commitSearchEdit = (nextSearch: EsSearch) => {
+ const edit = reconcileSearchParamMappings({
+ previousSearch: search,
+ nextSearch,
+ params
+ });
+ if (mappingFieldsChanged(edit)) {
+ commitMappingEdit(edit);
+ return;
+ }
+ onChange(nextSearch);
+ };
+ const context: EsQueryContext = {
+ fields,
+ vocabulary,
+ params,
+ // The builder owns the tree, so it — not the host — decides what a lookup is
+ // scoped by: the query without the row being edited. Leaving that row in
+ // would filter the suggestions by the half-typed value they are meant to
+ // complete.
+ ...(values
+ ? {
+ values: ({ path, field }) => {
+ const target = valueLookupField(fields, field);
+ if (!target) return undefined;
+ return values({ field: target, search: { ...search, query: removeAt(root, path) } });
+ }
+ }
+ : {})
+ };
+ const actions: EsQueryTreeActions = {
+ update: (path, update) =>
+ commitSearchEdit({ ...search, query: updateAt(root, path, update) }),
+ insert: (groupPath, condition) =>
+ commitSearchEdit({
+ ...search,
+ query: insertAt(
+ root,
+ groupPath,
+ conditionAt(root, groupPath)?.conditions?.length ?? 0,
+ condition,
+ )
+ }),
+ remove: (path) =>
+ commitSearchEdit({ ...search, query: removeAt(root, path) }),
+ mapParam: (path, operand, name) => {
+ const edit = bindParamOperand({ search, params, path, operand, name });
+ commitMappingEdit(edit);
+ },
+ unmapParam: (path, name) => {
+ const edit = removeParamMapping({ search, params, name, path });
+ commitMappingEdit(edit);
+ }
+ };
+
+ return (
+
+
+
+ Time field
+ onChange({ ...search, timeField: next || undefined })}
+ options={fields
+ .filter((field) => fieldFamily(field) === "date")
+ .map((field) => ({ value: field.name, label: field.name }))}
+ placeholder="Date field…"
+ allowCustomValue
+ />
+
+ Where time-from and time-to parameters apply
+
+
+
onChange({ ...search, sort: sort.length ? sort : undefined })}
+ />
+ onChange({ ...search, ...patch })}
+ />
+ {compilation ? : null}
+
+ );
+}
+
+/**
+ * The builder as the profile form mounts it. The form knows the connection and
+ * the index, so the field mappings and the compiled preview come from the same
+ * browser endpoints the connection browser uses.
+ */
+export function EsQueryBuilderField({
+ search,
+ onChange,
+ schema,
+ rootValue,
+ onRootChange
+}: {
+ search: EsSearch;
+ onChange: (next: unknown) => void;
+ schema: JsonSchemaProperty;
+ rootValue: ProfileDraft;
+ onRootChange?: ((next: Record) => void) | undefined;
+}) {
+ const connectionID = savedConnectionID(rootValue.provider?.connection);
+ const baseUrl = connectionID ? browserBaseUrl(connectionID) : "";
+ const target = String(rootValue.provider?.options?.index ?? "");
+ const inspection = useInspection({
+ cacheKey: "es-query-builder",
+ id: connectionID ?? "",
+ baseUrl,
+ enabled: baseUrl !== "",
+ database: "",
+ target
+ });
+ const roles = paramRoles(rootValue.params);
+ const params = defaultParamValues(rootValue.params);
+ const compilation = useCompiledSearch({
+ baseUrl,
+ search,
+ params,
+ roles,
+ enabled: baseUrl !== ""
+ });
+ const values = makeFieldValueLookup({ baseUrl, index: target, params, roles });
+
+ return (
+ onChange(next)}
+ fields={esQueryFields(inspection.completion)}
+ vocabulary={esBuilderVocabulary({ properties: { search: schema } })}
+ params={rootValue.params ?? []}
+ onMappingChange={(edit) => {
+ if (!onRootChange) {
+ throw new Error(
+ "query parameter mappings require an atomic root form update",
+ );
+ }
+ onRootChange({
+ ...rootValue,
+ params: edit.params,
+ provider: {
+ ...rootValue.provider,
+ options: {
+ ...rootValue.provider?.options,
+ search: edit.search
+ }
+ }
+ });
+ }}
+ {...(values ? { values } : {})}
+ compilation={compilation}
+ />
+ );
+}
diff --git a/packages/ui/src/profiles/esQueryBuilderExtension.tsx b/packages/ui/src/profiles/esQueryBuilderExtension.tsx
new file mode 100644
index 00000000..ac415a36
--- /dev/null
+++ b/packages/ui/src/profiles/esQueryBuilderExtension.tsx
@@ -0,0 +1,30 @@
+/**
+ * The JSON-schema form extension that swaps in the OpenSearch query builder.
+ *
+ * It lives apart from esQueryBuilder.tsx because it is not a component, and a
+ * module that exports components must export nothing else for Fast Refresh to
+ * work (react/only-export-components).
+ */
+
+import type { PostExtension } from "../components/json-schema-form-types";
+import { EsQueryBuilderField } from "./esQueryBuilder";
+import type { EsSearch } from "./esQueryBuilderModel";
+import type { ProfileDraft } from "./profileBuilderWorkspace";
+
+const esQueryBuilderPost: PostExtension = (field, nodes, ctx) => {
+ if (field.schema["x-clicky-component"] !== "es-query-builder") return nodes;
+ return {
+ label: nodes.label,
+ value: (
+
+ ),
+ };
+};
+
+export const esQueryBuilderFormExtensions = { post: [esQueryBuilderPost] };
diff --git a/packages/ui/src/profiles/esQueryBuilderForm.ts b/packages/ui/src/profiles/esQueryBuilderForm.ts
new file mode 100644
index 00000000..8dc42b24
--- /dev/null
+++ b/packages/ui/src/profiles/esQueryBuilderForm.ts
@@ -0,0 +1,52 @@
+/**
+ * The pure helpers behind the query builder: which parameters exist, what they
+ * default to, and the field mappings a schema describes.
+ *
+ * Kept apart from esQueryBuilder.tsx so that module exports only components
+ * (react/only-export-components).
+ */
+
+import type { EsFieldMapping } from "./esQueryOperators";
+import type { ParamDraft } from "./profileWizardModel";
+
+/** paramNames lists the parameters an operand may bind, in declared order. */
+export function paramNames(params: ParamDraft[] | undefined): string[] {
+ return (params ?? [])
+ .map((param) => param.name ?? "")
+ .filter((name) => name !== "");
+}
+
+/** paramRoles is the name-to-role table the compiler folds roles from. */
+export function paramRoles(
+ params: ParamDraft[] | undefined,
+): Record {
+ const roles: Record = {};
+ for (const param of params ?? []) {
+ if (param.name && param.role) roles[param.name] = param.role;
+ }
+ return roles;
+}
+
+/**
+ * defaultParamValues is what the declared parameters resolve to before anyone
+ * filters. The compiler needs them to bind a {param:…} operand and to
+ * interpolate a {{.params.…}} one, so the preview shows the DSL a run produces.
+ */
+export function defaultParamValues(
+ params: ParamDraft[] | undefined,
+): Record {
+ return Object.fromEntries(
+ (params ?? [])
+ .filter((param) => param.name && param.default !== undefined)
+ .map((param) => [param.name as string, param.default]),
+ );
+}
+
+/**
+ * esQueryFields reads the field mappings off a browser inspection. Only an
+ * OpenSearch target carries them, so anything else builds against free text.
+ */
+export function esQueryFields(completion: unknown): EsFieldMapping[] {
+ const typed = completion as { kind?: string; fields?: EsFieldMapping[] };
+ return typed?.kind === "json-fields" ? (typed.fields ?? []) : [];
+}
diff --git a/packages/ui/src/profiles/esQueryBuilderModel.test.ts b/packages/ui/src/profiles/esQueryBuilderModel.test.ts
new file mode 100644
index 00000000..f75e1c3f
--- /dev/null
+++ b/packages/ui/src/profiles/esQueryBuilderModel.test.ts
@@ -0,0 +1,180 @@
+import { describe, expect, it } from "vitest";
+import {
+ conditionAt,
+ emptyCondition,
+ fieldForParam,
+ insertAt,
+ isEmptySpec,
+ isParamValue,
+ normalizeOccur,
+ removeAt,
+ toBuilderMode,
+ toRawMode,
+ updateAt,
+ type EsCondition,
+ type EsSearch,
+} from "./esQueryBuilderModel";
+
+const tree: EsCondition = {
+ op: "bool",
+ conditions: [
+ { op: "term", field: "level", value: "error" },
+ {
+ op: "bool",
+ occur: "should",
+ conditions: [
+ { op: "match", field: "message", value: "timeout" },
+ { op: "exists", field: "trace.id" },
+ ],
+ },
+ ],
+};
+
+describe("condition tree edits", () => {
+ it("addresses a nested condition by its index path", () => {
+ expect(conditionAt(tree, [1, 0])).toEqual({
+ op: "match",
+ field: "message",
+ value: "timeout",
+ });
+ expect(conditionAt(tree, [])).toBe(tree);
+ expect(conditionAt(tree, [1, 7])).toBeUndefined();
+ });
+
+ it("replaces a nested condition without mutating the original tree", () => {
+ const next = updateAt(tree, [1, 0], (condition) => ({
+ ...condition,
+ value: "timed out",
+ }));
+ expect(conditionAt(next, [1, 0])?.value).toBe("timed out");
+ expect(conditionAt(tree, [1, 0])?.value).toBe("timeout");
+ // Untouched branches are shared, so React sees a changed identity only
+ // along the edited path.
+ expect(next.conditions?.[0]).toBe(tree.conditions?.[0]);
+ expect(next.conditions?.[1]).not.toBe(tree.conditions?.[1]);
+ });
+
+ it("inserts into a group at the requested position", () => {
+ const added = emptyCondition("keyword");
+ const next = insertAt(tree, [1], 1, added);
+ expect(next.conditions?.[1].conditions).toEqual([
+ { op: "match", field: "message", value: "timeout" },
+ added,
+ { op: "exists", field: "trace.id" },
+ ]);
+ expect(tree.conditions?.[1].conditions).toHaveLength(2);
+ });
+
+ it("appends when the position is past the end", () => {
+ const next = insertAt(tree, [], 99, emptyCondition("keyword"));
+ expect(next.conditions).toHaveLength(3);
+ expect(next.conditions?.[2].op).toBe("term");
+ });
+
+ it("removes a nested condition and leaves its siblings", () => {
+ const next = removeAt(tree, [1, 0]);
+ expect(next.conditions?.[1].conditions).toEqual([
+ { op: "exists", field: "trace.id" },
+ ]);
+ });
+
+ it("removing the root leaves an empty group rather than nothing", () => {
+ expect(removeAt(tree, [])).toEqual({ op: "bool", conditions: [] });
+ });
+});
+
+describe("condition defaults", () => {
+ it("picks the family's default operator", () => {
+ expect(emptyCondition("keyword").op).toBe("term");
+ expect(emptyCondition("text").op).toBe("match");
+ expect(emptyCondition("date").op).toBe("range");
+ expect(emptyCondition("number").op).toBe("range");
+ expect(emptyCondition("boolean").op).toBe("term");
+ });
+
+ it("defaults an unset occur to filter", () => {
+ expect(normalizeOccur(undefined)).toBe("filter");
+ expect(normalizeOccur("")).toBe("filter");
+ expect(normalizeOccur("must_not")).toBe("must_not");
+ });
+});
+
+describe("param operands", () => {
+ it("recognises a param reference and leaves literals alone", () => {
+ expect(isParamValue({ param: "level" })).toBe(true);
+ expect(isParamValue({ literal: { param: "level" } })).toBe(false);
+ expect(isParamValue("error")).toBe(false);
+ expect(isParamValue(undefined)).toBe(false);
+ expect(isParamValue({ param: "level", boost: 2 })).toBe(false);
+ });
+});
+
+describe("spec emptiness", () => {
+ const cases: Array<[string, EsSearch | undefined, boolean]> = [
+ ["undefined", undefined, true],
+ ["no keys", {}, true],
+ ["a bare match_all", { query: { op: "match_all" } }, true],
+ ["an empty root group", { query: { op: "bool", conditions: [] } }, true],
+ ["a group holding a leaf", { query: tree }, false],
+ ["sort only", { sort: [{ field: "@timestamp" }] }, false],
+ ["size only", { size: 50 }, false],
+ ["a preserved aggregation", { aggregations: { byLevel: {} } }, false],
+ ["a time field only", { timeField: "@timestamp" }, false],
+ ];
+ it.each(cases)("treats %s as empty=%s", (_name, spec, empty) => {
+ expect(isEmptySpec(spec)).toBe(empty);
+ });
+});
+
+describe("raw-DSL transition", () => {
+ // The server rejects a spec and a raw query together, so each transition has
+ // to clear the side it is leaving. Neither direction may leave both set.
+ it("hands the compiled DSL to the raw editor and drops the spec", () => {
+ expect(
+ toRawMode({ query: tree, size: 10 }, '{"query":{"match_all":{}}}'),
+ ).toEqual({ search: undefined, query: '{"query":{"match_all":{}}}' });
+ });
+
+ it("keeps the raw query when there is no compiled DSL to carry over", () => {
+ expect(toRawMode({ query: tree }, "", "{}")).toEqual({
+ search: undefined,
+ query: "{}",
+ });
+ });
+
+ it("starts the builder from an empty spec and clears the raw query", () => {
+ expect(toBuilderMode()).toEqual({
+ search: { query: { op: "bool", conditions: [] } },
+ query: "",
+ });
+ });
+});
+
+describe("fieldForParam", () => {
+ const bound: EsSearch = {
+ query: {
+ op: "bool",
+ conditions: [
+ { op: "term", field: "level", value: "error" },
+ {
+ op: "bool",
+ conditions: [
+ { op: "terms", field: "service.name", values: [{ param: "service" }] },
+ { op: "range", field: "@timestamp", gte: { param: "since" } },
+ ],
+ },
+ ],
+ },
+ };
+
+ it("reads the field off the condition a parameter is bound to", () => {
+ expect(fieldForParam(bound, "service")).toBe("service.name");
+ expect(fieldForParam(bound, "since")).toBe("@timestamp");
+ });
+
+ it("has no field for a parameter the specification never references", () => {
+ expect(fieldForParam(bound, "unused")).toBeUndefined();
+ expect(fieldForParam(bound, "")).toBeUndefined();
+ expect(fieldForParam(undefined, "service")).toBeUndefined();
+ });
+});
diff --git a/packages/ui/src/profiles/esQueryBuilderModel.ts b/packages/ui/src/profiles/esQueryBuilderModel.ts
new file mode 100644
index 00000000..c51e60e9
--- /dev/null
+++ b/packages/ui/src/profiles/esQueryBuilderModel.ts
@@ -0,0 +1,251 @@
+/**
+ * Mirror of the Go `query/esdsl` specification. The builder edits this shape and
+ * the server compiles it; nothing here renders DSL, so the two never drift.
+ */
+
+export type EsOccur = "filter" | "must" | "should" | "must_not";
+
+/** A literal operand, or `{param}` binding one to a profile parameter. */
+export type EsValue = unknown;
+
+export type EsCondition = {
+ op: string;
+ occur?: EsOccur | "" | undefined;
+ field?: string | undefined;
+ fields?: string[] | undefined;
+ value?: EsValue | undefined;
+ values?: EsValue[] | undefined;
+ gt?: EsValue | undefined;
+ gte?: EsValue | undefined;
+ lt?: EsValue | undefined;
+ lte?: EsValue | undefined;
+ format?: string | undefined;
+ timeZone?: string | undefined;
+ analyzer?: string | undefined;
+ matchOperator?: string | undefined;
+ multiMatchType?: string | undefined;
+ fuzziness?: string | undefined;
+ slop?: number | undefined;
+ boost?: number | undefined;
+ caseInsensitive?: boolean | undefined;
+ escape?: boolean | undefined;
+ path?: string | undefined;
+ scoreMode?: string | undefined;
+ minimumShouldMatch?: string | undefined;
+ conditions?: EsCondition[] | undefined;
+ optional?: boolean | undefined;
+ when?: string | undefined;
+};
+
+export type EsSortBy = {
+ field: string;
+ order?: string | undefined;
+ mode?: string | undefined;
+ missing?: string | undefined;
+ unmappedType?: string | undefined;
+};
+
+export type EsSearch = {
+ query?: EsCondition | undefined;
+ sort?: EsSortBy[] | undefined;
+ size?: number | undefined;
+ from?: number | undefined;
+ source?:
+ | {
+ enabled?: boolean | undefined;
+ includes?: string[] | undefined;
+ excludes?: string[] | undefined;
+ }
+ | undefined;
+ trackTotalHits?:
+ | { enabled?: boolean | undefined; threshold?: number | undefined }
+ | undefined;
+ storedFields?: string[] | undefined;
+ fields?: string[] | undefined;
+ aggregations?: Record | undefined;
+ timeField?: string | undefined;
+};
+
+/** A path of child indexes from the root condition. `[]` is the root itself. */
+export type ConditionPath = number[];
+
+const defaultOperators: Record = {
+ keyword: "term",
+ text: "match",
+ date: "range",
+ number: "range",
+ boolean: "term",
+ ip: "term",
+ nested: "nested",
+};
+
+export function normalizeOccur(occur: string | undefined): EsOccur {
+ return (occur || "filter") as EsOccur;
+}
+
+/**
+ * defaultOperatorForFamily is the operator a family reads best as. It seeds a
+ * new condition and leads the operator list, so both agree by construction.
+ */
+export function defaultOperatorForFamily(family: string): string {
+ return defaultOperators[family] ?? "term";
+}
+
+export function emptyCondition(family = "keyword"): EsCondition {
+ const op = defaultOperatorForFamily(family);
+ return op === "nested" ? { op, conditions: [] } : { op };
+}
+
+export function emptyGroup(): EsCondition {
+ return { op: "bool", conditions: [] };
+}
+
+export function conditionAt(
+ root: EsCondition,
+ path: ConditionPath,
+): EsCondition | undefined {
+ let node: EsCondition | undefined = root;
+ for (const index of path) {
+ node = node?.conditions?.[index];
+ if (!node) return undefined;
+ }
+ return node;
+}
+
+/**
+ * updateAt rebuilds only the branch it edits, so untouched subtrees keep their
+ * identity and React re-renders just the changed rows.
+ */
+export function updateAt(
+ root: EsCondition,
+ path: ConditionPath,
+ update: (condition: EsCondition) => EsCondition,
+): EsCondition {
+ if (path.length === 0) return update(root);
+ const [index, ...rest] = path;
+ return {
+ ...root,
+ conditions: (root.conditions ?? []).map((child, position) =>
+ position === index ? updateAt(child, rest, update) : child,
+ ),
+ };
+}
+
+export function insertAt(
+ root: EsCondition,
+ groupPath: ConditionPath,
+ position: number,
+ condition: EsCondition,
+): EsCondition {
+ return updateAt(root, groupPath, (group) => {
+ const children = [...(group.conditions ?? [])];
+ children.splice(Math.min(Math.max(position, 0), children.length), 0, condition);
+ return { ...group, conditions: children };
+ });
+}
+
+export function removeAt(root: EsCondition, path: ConditionPath): EsCondition {
+ if (path.length === 0) return emptyGroup();
+ const index = path[path.length - 1];
+ return updateAt(root, path.slice(0, -1), (group) => ({
+ ...group,
+ conditions: (group.conditions ?? []).filter(
+ (_child, position) => position !== index,
+ ),
+ }));
+}
+
+/** isParamValue reports whether an operand binds a profile parameter. */
+export function isParamValue(value: EsValue): value is { param: string } {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
+ const keys = Object.keys(value as Record);
+ return (
+ keys.length === 1 &&
+ keys[0] === "param" &&
+ typeof (value as { param: unknown }).param === "string"
+ );
+}
+
+export function paramName(value: EsValue): string | undefined {
+ return isParamValue(value) ? value.param : undefined;
+}
+
+/**
+ * fieldForParam is the field a parameter filters on: the field of the first
+ * condition whose operand binds it. A parameter the specification never
+ * references has no field, and so nothing to offer values from.
+ */
+export function fieldForParam(
+ search: EsSearch | undefined,
+ name: string,
+): string | undefined {
+ const walk = (condition: EsCondition | undefined): string | undefined => {
+ if (!condition) return undefined;
+ const operands: EsValue[] = [
+ condition.value,
+ ...(condition.values ?? []),
+ condition.gt,
+ condition.gte,
+ condition.lt,
+ condition.lte,
+ ];
+ if (condition.field && operands.some((value) => paramName(value) === name)) {
+ return condition.field;
+ }
+ for (const child of condition.conditions ?? []) {
+ const found = walk(child);
+ if (found) return found;
+ }
+ return undefined;
+ };
+ return name ? walk(search?.query) : undefined;
+}
+
+/**
+ * isEmptySpec reports whether a specification says nothing the raw query would
+ * not. It is what decides whether a profile is still in raw mode.
+ */
+export function isEmptySpec(search: EsSearch | undefined): boolean {
+ if (!search) return true;
+ const { query, ...rest } = search;
+ for (const value of Object.values(rest)) {
+ if (Array.isArray(value) ? value.length > 0 : value !== undefined && value !== null) {
+ if (typeof value === "object" && !Array.isArray(value)) {
+ if (Object.keys(value as object).length > 0) return false;
+ continue;
+ }
+ return false;
+ }
+ }
+ return isEmptyCondition(query);
+}
+
+function isEmptyCondition(condition: EsCondition | undefined): boolean {
+ if (!condition) return true;
+ if (condition.op === "match_all") return true;
+ if (condition.op !== "bool") return false;
+ return (condition.conditions ?? []).every(isEmptyCondition);
+}
+
+export type QueryModeTransition = {
+ search: EsSearch | undefined;
+ query: string;
+};
+
+/**
+ * toRawMode leaves the builder for the raw editor, seeding it with the DSL the
+ * specification last compiled to. The specification is dropped, never kept
+ * alongside the query: the server treats holding both as an authoring error.
+ */
+export function toRawMode(
+ _search: EsSearch | undefined,
+ compiled: string,
+ currentQuery = "",
+): QueryModeTransition {
+ return { search: undefined, query: compiled.trim() || currentQuery };
+}
+
+/** toBuilderMode is the inverse: the raw query is dropped, not parsed. */
+export function toBuilderMode(search?: EsSearch): QueryModeTransition {
+ return { search: search ?? { query: emptyGroup() }, query: "" };
+}
diff --git a/packages/ui/src/profiles/esQueryClauseGroup.test.tsx b/packages/ui/src/profiles/esQueryClauseGroup.test.tsx
new file mode 100644
index 00000000..2f41396b
--- /dev/null
+++ b/packages/ui/src/profiles/esQueryClauseGroup.test.tsx
@@ -0,0 +1,159 @@
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it } from "vitest";
+import { EsQueryClauseGroup } from "./esQueryClauseGroup";
+import { groupOperatorOptions, isGroupOperator } from "./esQueryGroupModel";
+import type { EsQueryContext, EsQueryTreeActions } from "./esQueryConditionRow";
+import type { EsCondition } from "./esQueryBuilderModel";
+import type {
+ EsBuilderVocabulary,
+ EsFieldMapping,
+ EsOperatorInfo,
+} from "./esQueryOperators";
+
+const catalog: EsOperatorInfo[] = [
+ { op: "term", label: "is", arity: "single", needsField: true,
+ fieldTypes: ["keyword", "text", "date", "number", "boolean", "ip"] },
+ { op: "match", label: "matches", arity: "single", needsField: true,
+ fieldTypes: ["text", "keyword"], analyzed: true },
+ { op: "exists", label: "exists", arity: "none", needsField: true, fieldTypes: ["any"] },
+ { op: "nested", label: "nested", arity: "group", fieldTypes: ["nested"], group: true },
+ { op: "bool", label: "group", arity: "group", fieldTypes: ["any"], group: true },
+];
+
+const vocabulary: EsBuilderVocabulary = {
+ catalog,
+ occurs: ["filter", "must", "should", "must_not"],
+ qualifierNames: ["boost"],
+ qualifiers: { boost: { type: "number", title: "Boost" } },
+ sortOrders: ["asc", "desc"],
+};
+
+const fields: EsFieldMapping[] = [
+ { name: "level", dataType: "keyword", searchable: true, aggregatable: true },
+ { name: "message", dataType: "text", searchable: true, aggregatable: false },
+ { name: "spans", dataType: "nested", searchable: true, aggregatable: false },
+];
+
+const noopActions: EsQueryTreeActions = {
+ update: () => undefined,
+ insert: () => undefined,
+ remove: () => undefined,
+ mapParam: () => undefined,
+ unmapParam: () => undefined,
+};
+
+const render = (condition: EsCondition, root = true) => {
+ const context: EsQueryContext = { fields, vocabulary, params: [] };
+ return renderToStaticMarkup(
+ ,
+ );
+};
+
+/** How many groups the rendered markup contains, of any kind. */
+const groupCount = (html: string): number =>
+ html.match(/data-es-group="/g)?.length ?? 0;
+
+describe("group operators", () => {
+ it("recognises only the operators that hold other conditions", () => {
+ expect(isGroupOperator(catalog, "bool")).toBe(true);
+ expect(isGroupOperator(catalog, "nested")).toBe(true);
+ expect(isGroupOperator(catalog, "term")).toBe(false);
+ expect(isGroupOperator(catalog, "unheard_of")).toBe(false);
+ });
+
+ it("offers exactly the catalog's group operators as group kinds", () => {
+ expect(groupOperatorOptions(catalog)).toEqual([
+ { value: "nested", label: "nested" },
+ { value: "bool", label: "group" },
+ ]);
+ });
+});
+
+describe("the root group", () => {
+ // The root is the whole query, so it has no clause to contribute to and
+ // nothing to be removed from.
+ it("offers neither a clause nor a remove control", () => {
+ const html = render({ op: "bool", conditions: [] });
+ expect(html).not.toContain('aria-label="Clause"');
+ expect(html).not.toContain('aria-label="Remove group"');
+ expect(html).not.toContain('aria-label="Group type"');
+ });
+
+ it("says plainly that an empty query matches everything", () => {
+ expect(render({ op: "bool", conditions: [] })).toContain(
+ "No conditions — every document matches.",
+ );
+ });
+});
+
+describe("children", () => {
+ it("renders a leaf as a condition row and a group as a nested group", () => {
+ const html = render({
+ op: "bool",
+ conditions: [
+ { op: "term", field: "level", value: "error" },
+ { op: "bool", conditions: [{ op: "exists", field: "message" }] },
+ ],
+ });
+ expect(groupCount(html)).toBe(2);
+ expect(html).toContain('aria-label="Operator"');
+ expect(html).toContain('aria-label="Remove condition"');
+ expect(html).toContain('aria-label="Remove group"');
+ });
+
+ it("nests a group inside a group inside a group", () => {
+ const html = render({
+ op: "bool",
+ conditions: [
+ { op: "bool", conditions: [{ op: "bool", conditions: [] }] },
+ ],
+ });
+ expect(groupCount(html)).toBe(3);
+ });
+
+ it("gives a non-root group both a clause and a group kind", () => {
+ const html = render({ op: "bool", occur: "should", conditions: [] }, false);
+ expect(html).toContain('aria-label="Clause"');
+ expect(html).toContain('aria-label="Group type"');
+ expect(html).toContain('aria-label="Remove group"');
+ });
+});
+
+describe("minimum should match", () => {
+ // Without a should child the setting has nothing to act on, so offering it
+ // would only invite a value the compiler ignores.
+ it("stays hidden until a child contributes to the should clause", () => {
+ expect(
+ render({ op: "bool", conditions: [{ op: "term", field: "level" }] }),
+ ).not.toContain('aria-label="Minimum should match"');
+ });
+
+ it("appears once a child is an or clause", () => {
+ expect(
+ render({
+ op: "bool",
+ conditions: [{ op: "term", field: "level", occur: "should" }],
+ }),
+ ).toContain('aria-label="Minimum should match"');
+ });
+});
+
+describe("nested groups", () => {
+ it("asks for the path a nested group descends into", () => {
+ const html = render({ op: "nested", path: "spans", conditions: [] }, false);
+ expect(html).toContain('aria-label="Nested path"');
+ expect(html).toContain('value="spans"');
+ });
+
+ it("does not ask a plain bool group for a path", () => {
+ expect(render({ op: "bool", conditions: [] }, false)).not.toContain(
+ 'aria-label="Nested path"',
+ );
+ });
+});
diff --git a/packages/ui/src/profiles/esQueryClauseGroup.tsx b/packages/ui/src/profiles/esQueryClauseGroup.tsx
new file mode 100644
index 00000000..646131fc
--- /dev/null
+++ b/packages/ui/src/profiles/esQueryClauseGroup.tsx
@@ -0,0 +1,167 @@
+/**
+ * A bool or nested group of conditions. A group renders itself for a child that
+ * is also a group, so the tree nests to whatever depth the author builds; the
+ * compiler places no bound on it either.
+ */
+
+import { Combobox } from "../components/Combobox";
+import { IconButton } from "../components/IconButton";
+import { InputField } from "../components/InputField";
+import { Button } from "../components/button";
+import { Select } from "../components/select";
+import { UiAdd, UiTrash } from "../icons";
+import { applyPatch, type Patch } from "./profileWizardModel";
+import {
+ emptyCondition,
+ emptyGroup,
+ normalizeOccur,
+ type ConditionPath,
+ type EsCondition
+} from "./esQueryBuilderModel";
+import {
+ EsQueryConditionRow,
+ type EsQueryContext,
+ type EsQueryTreeActions
+} from "./esQueryConditionRow";
+import {
+ fieldFamily,
+ type EsFieldMapping
+} from "./esQueryOperators";
+import { groupOperatorOptions, isGroupOperator } from "./esQueryGroupModel";
+import { occurOptions } from "./esQueryOccur";
+
+export function EsQueryClauseGroup({
+ condition,
+ path,
+ context,
+ actions,
+ root = false
+}: {
+ condition: EsCondition;
+ path: ConditionPath;
+ context: EsQueryContext;
+ actions: EsQueryTreeActions;
+ root?: boolean;
+}) {
+ const children = condition.conditions ?? [];
+ const set = (patch: Patch) =>
+ actions.update(path, (current) => applyPatch(current, patch));
+ // A should clause alongside anything else matches one by default. Offering the
+ // override only once a should exists keeps it out of the way until it means
+ // something.
+ const hasShould = children.some(
+ (child) => normalizeOccur(child.occur) === "should",
+ );
+
+ return (
+
+
+ {root ? (
+ Match
+ ) : (
+ <>
+
+ set({ occur: event.target.value as never })}
+ />
+
+
+ set({ op: event.target.value })}
+ />
+
+ >
+ )}
+ {condition.op === "nested" ? (
+ set({ path: next })}
+ options={nestedPathOptions(context.fields)}
+ placeholder="Nested path…"
+ allowCustomValue
+ />
+ ) : null}
+ {hasShould ? (
+
+ set({ minimumShouldMatch: next === "" ? undefined : next })
+ }
+ />
+ ) : null}
+
+ actions.insert(path, emptyCondition())}
+ >
+ Condition
+
+ actions.insert(path, emptyGroup())}
+ >
+ Group
+
+ {root ? null : (
+ actions.remove(path)}
+ />
+ )}
+
+
+ {children.length === 0 ? (
+
+ No conditions — every document matches.
+
+ ) : (
+
+ {children.map((child, index) =>
+ isGroupOperator(context.vocabulary.catalog, child.op) ? (
+
+ ) : (
+
+ ),
+ )}
+
+ )}
+
+ );
+}
+
+function nestedPathOptions(fields: EsFieldMapping[]) {
+ return fields
+ .filter((field) => fieldFamily(field) === "nested")
+ .map((field) => ({ value: field.name, label: field.name }));
+}
diff --git a/packages/ui/src/profiles/esQueryCompile.ts b/packages/ui/src/profiles/esQueryCompile.ts
new file mode 100644
index 00000000..e5e20f60
--- /dev/null
+++ b/packages/ui/src/profiles/esQueryCompile.ts
@@ -0,0 +1,87 @@
+/**
+ * The DSL a specification compiles to. The server compiles it — the same code
+ * path a query runs through — so the preview is the query, not a re-derivation
+ * of it that could drift.
+ */
+
+import { useQuery } from "@tanstack/react-query";
+import { useEffect, useState } from "react";
+import { fetchJSON } from "./connectionBrowserModel";
+import type { EsSearch } from "./esQueryBuilderModel";
+
+export type EsCompileResult = { query: string; size: number; from: number };
+
+export type EsCompilation = {
+ query: string;
+ size?: number | undefined;
+ from?: number | undefined;
+ /** The compiler's own message for a specification it rejected. */
+ error?: string | undefined;
+ loading: boolean;
+};
+
+/**
+ * EsCompileInput is the specification together with the parameter values it
+ * compiles against: the server binds a {param:…} operand from them and
+ * interpolates a {{.params.…}} one, so both need real values to preview.
+ */
+export type EsCompileInput = {
+ search: EsSearch;
+ params?: Record;
+ roles?: Record;
+};
+
+export type EsCompileRequest = EsCompileInput & {
+ baseUrl: string;
+ enabled?: boolean;
+ debounceMs?: number;
+};
+
+/** compileRequestBody is what POST /compile takes, with the empty parts left off. */
+export function compileRequestBody(input: EsCompileInput): string {
+ const { search, params, roles } = input;
+ return JSON.stringify({
+ search,
+ ...(params && Object.keys(params).length ? { params } : {}),
+ ...(roles && Object.keys(roles).length ? { roles } : {}),
+ });
+}
+
+export function errorMessage(error: unknown): string | undefined {
+ if (!error) return undefined;
+ return error instanceof Error ? error.message : String(error);
+}
+
+/**
+ * useCompiledSearch compiles the specification as it is edited. The body is
+ * debounced rather than the request, so an edit that lands back on a body
+ * already compiled costs nothing.
+ */
+export function useCompiledSearch(input: EsCompileRequest): EsCompilation {
+ const { baseUrl, enabled = true, debounceMs = 250 } = input;
+ const body = compileRequestBody(input);
+ const [settled, setSettled] = useState(body);
+ useEffect(() => {
+ const timer = setTimeout(() => setSettled(body), debounceMs);
+ return () => clearTimeout(timer);
+ }, [body, debounceMs]);
+
+ const compiled = useQuery({
+ queryKey: ["es-compile", baseUrl, settled],
+ queryFn: () =>
+ fetchJSON(`${baseUrl}/compile`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: settled,
+ }),
+ enabled: enabled && baseUrl !== "",
+ retry: 0,
+ });
+
+ return {
+ query: compiled.data?.query ?? "",
+ ...(compiled.data ? { size: compiled.data.size, from: compiled.data.from } : {}),
+ ...(compiled.error ? { error: errorMessage(compiled.error) } : {}),
+ loading: compiled.isFetching,
+ };
+}
diff --git a/packages/ui/src/profiles/esQueryConditionRow.test.tsx b/packages/ui/src/profiles/esQueryConditionRow.test.tsx
new file mode 100644
index 00000000..83f73f53
--- /dev/null
+++ b/packages/ui/src/profiles/esQueryConditionRow.test.tsx
@@ -0,0 +1,207 @@
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it } from "vitest";
+import { EsQueryConditionRow, type EsQueryContext, type EsQueryTreeActions } from "./esQueryConditionRow";
+import { occurOptions } from "./esQueryOccur";
+import type { EsCondition } from "./esQueryBuilderModel";
+import type {
+ EsBuilderVocabulary,
+ EsFieldMapping,
+ EsOperatorInfo,
+} from "./esQueryOperators";
+
+const catalog: EsOperatorInfo[] = [
+ { op: "term", label: "is", arity: "single", needsField: true,
+ fieldTypes: ["keyword", "text", "date", "number", "boolean", "ip"] },
+ { op: "terms", label: "is one of", arity: "multiple", needsField: true,
+ fieldTypes: ["keyword", "text", "date", "number", "boolean", "ip"] },
+ { op: "match", label: "matches", arity: "single", needsField: true,
+ fieldTypes: ["text", "keyword"], analyzed: true },
+ { op: "range", label: "is between", arity: "range", needsField: true,
+ fieldTypes: ["date", "number", "ip", "keyword"] },
+ { op: "exists", label: "exists", arity: "none", needsField: true, fieldTypes: ["any"] },
+];
+
+const vocabulary: EsBuilderVocabulary = {
+ catalog,
+ occurs: ["filter", "must", "should", "must_not"],
+ qualifierNames: ["boost", "caseInsensitive", "scoreMode"],
+ qualifierRestrictions: { caseInsensitive: ["term"], scoreMode: ["nested"] },
+ qualifiers: {
+ boost: { type: "number", title: "Boost" },
+ caseInsensitive: { type: "boolean", title: "Case insensitive" },
+ scoreMode: { type: "string", title: "Score mode", enum: ["avg", "none"] },
+ },
+ sortOrders: ["asc", "desc"],
+};
+
+const fields: EsFieldMapping[] = [
+ { name: "level", dataType: "keyword", searchable: true, aggregatable: true },
+ { name: "message", dataType: "text", searchable: true, aggregatable: false },
+ { name: "@timestamp", dataType: "date", searchable: true, aggregatable: true },
+ {
+ name: "code",
+ types: ["keyword", "long"],
+ conflicting: true,
+ searchable: true,
+ aggregatable: true,
+ },
+];
+
+const noopActions: EsQueryTreeActions = {
+ update: () => undefined,
+ insert: () => undefined,
+ remove: () => undefined,
+ mapParam: () => undefined,
+ unmapParam: () => undefined,
+};
+
+const render = (condition: EsCondition, params: string[] = []) => {
+ const context: EsQueryContext = {
+ fields,
+ vocabulary,
+ params: params.map((name) => ({ name })),
+ };
+ return renderToStaticMarkup(
+ ,
+ );
+};
+
+/** The markup of one labelled , so the clause and operator controls do
+ * not read each other's options. */
+const selectMarkup = (html: string, ariaLabel: string): string => {
+ const start = html.indexOf(`aria-label="${ariaLabel}"`);
+ expect(start, `no select labelled ${ariaLabel}`).toBeGreaterThan(-1);
+ return html.slice(start, html.indexOf(" ", start));
+};
+
+/** The operator values the rendered offers, in document order. */
+const offeredOperators = (html: string): string[] =>
+ Array.from(
+ selectMarkup(html, "Operator").matchAll(/ match[1]);
+
+describe("clause labels", () => {
+ // filter and must both narrow the hits; only must contributes to the score,
+ // which the raw bool clause names do not say out loud.
+ it("names each bool clause the way an author reads it", () => {
+ expect(occurOptions(["filter", "must", "should", "must_not"])).toEqual([
+ { value: "filter", label: "AND" },
+ { value: "must", label: "AND (scored)" },
+ { value: "should", label: "OR" },
+ { value: "must_not", label: "NOT" },
+ ]);
+ });
+
+ it("passes an unknown clause through rather than dropping it", () => {
+ expect(occurOptions(["filter_v2"])).toEqual([
+ { value: "filter_v2", label: "filter_v2" },
+ ]);
+ });
+});
+
+describe("operators offered per field", () => {
+ it("leads a keyword field with the exact operators", () => {
+ expect(offeredOperators(render({ op: "term", field: "level" }))).toEqual([
+ "term",
+ "terms",
+ "range",
+ "exists",
+ "match",
+ ]);
+ });
+
+ // A text field is analyzed, so term matches tokens rather than the stored
+ // text. It stays offered, but behind the Advanced divider.
+ it("leads a text field with match and demotes the exact operators", () => {
+ const html = render({ op: "match", field: "message" });
+ expect(offeredOperators(html)).toEqual(["match", "exists", "term", "terms"]);
+ expect(html).toContain(' ');
+ expect(html.indexOf('value="match"')).toBeLessThan(
+ html.indexOf(''),
+ );
+ });
+
+ // Changing the field must never blank the operator control, so an operator
+ // the new field does not suit is still offered — under Advanced.
+ it("keeps the current operator when the field does not suit it", () => {
+ const html = render({ op: "match", field: "@timestamp" });
+ expect(offeredOperators(html)).toContain("match");
+ expect(html).toContain('');
+ });
+});
+
+describe("operand editors", () => {
+ it("renders a chip input for a multi-value operator", () => {
+ const html = render({ op: "terms", field: "level", values: ["warn", "error"] });
+ expect(html).toContain('aria-label="Add value"');
+ expect(html).toContain("es-value-chip");
+ expect(html).toContain("warn");
+ expect(html).toContain("error");
+ });
+
+ it("renders both bounds of a range with date math presets", () => {
+ const html = render({ op: "range", field: "@timestamp", gte: "now-1h" });
+ expect(html).toContain('aria-label="From"');
+ expect(html).toContain('aria-label="To"');
+ expect(html).toContain('value="now-15m"');
+ });
+
+ it("offers no date math on a field that is not a date", () => {
+ expect(render({ op: "range", field: "level" })).not.toContain('value="now-15m"');
+ });
+
+ it("renders no operand at all for a presence test", () => {
+ const html = render({ op: "exists", field: "level" });
+ expect(html).not.toContain('aria-label="Value"');
+ });
+
+ // A bound operand is substituted structurally at compile time. Showing the
+ // parameter rather than an editable box is what keeps that visible.
+ it("renders a parameter chip in place of a bound value", () => {
+ const html = render({ op: "term", field: "level", value: { param: "severity" } });
+ expect(html).toContain("es-param-chip");
+ expect(html).toContain("severity");
+ expect(html).not.toContain('aria-label="Value"');
+ });
+
+ it("offers a parameter binder only where the profile declares parameters", () => {
+ expect(render({ op: "term", field: "level" }, ["severity"])).toContain(
+ 'aria-label="Bind Value to a parameter"',
+ );
+ expect(render({ op: "term", field: "level" })).not.toContain(
+ "to a parameter",
+ );
+ });
+});
+
+describe("field warnings", () => {
+ it("warns inline about a field mapped differently across indexes", () => {
+ const html = render({ op: "term", field: "code" });
+ expect(html).toContain('role="alert"');
+ expect(html).toContain("code is mapped as keyword and long across indexes");
+ });
+
+ it("stays quiet about an ordinary field", () => {
+ expect(render({ op: "term", field: "level" })).not.toContain('role="alert"');
+ });
+});
+
+describe("advanced qualifiers", () => {
+ it("offers only the qualifiers the operator emits", () => {
+ const html = render({ op: "term", field: "level" });
+ expect(html).toContain("Case insensitive");
+ expect(html).toContain('aria-label="Boost"');
+ expect(html).not.toContain("Score mode");
+ });
+
+ it("drops a restricted qualifier for an operator that does not emit it", () => {
+ const html = render({ op: "match", field: "message" });
+ expect(html).not.toContain("Case insensitive");
+ expect(html).toContain('aria-label="Boost"');
+ });
+});
diff --git a/packages/ui/src/profiles/esQueryConditionRow.tsx b/packages/ui/src/profiles/esQueryConditionRow.tsx
new file mode 100644
index 00000000..f5169831
--- /dev/null
+++ b/packages/ui/src/profiles/esQueryConditionRow.tsx
@@ -0,0 +1,245 @@
+/**
+ * One leaf condition: which field, which operator, and the operand that
+ * operator takes. Which operators a field may use and which advanced qualifiers
+ * an operator emits both come from the schema vocabulary, so this file owns the
+ * editing surface and never the vocabulary itself.
+ */
+
+import { Combobox } from "../components/Combobox";
+import { IconButton } from "../components/IconButton";
+import { MultiSelect } from "../components/MultiSelect";
+import { Select } from "../components/select";
+import { UiTrash } from "../icons";
+import type { ConditionPath, EsCondition } from "./esQueryBuilderModel";
+import { applyPatch, type Patch } from "./profileWizardModel";
+import type { ParamOperand } from "./esParamMappingModel";
+import type { FieldValuesQuery } from "./esFieldValues";
+import { ConditionOperand, QualifierInput } from "./esQueryOperandEditors";
+import type { ParamDraft } from "./profileWizardModel";
+import {
+ changeConditionOperator,
+ fieldWarning,
+ operatorsForField,
+ qualifiersForOperator,
+ type EsBuilderVocabulary,
+ type EsFieldMapping,
+} from "./esQueryOperators";
+import { occurOptions } from "./esQueryOccur";
+
+export type EsQueryContext = {
+ fields: EsFieldMapping[];
+ vocabulary: EsBuilderVocabulary;
+ /** Declared profile parameters an operand can bind to. */
+ params: ParamDraft[];
+ /**
+ * The values a row's field holds, scoped by the builder to the rest of the
+ * query. Absent where the host has no connection to ask.
+ */
+ values?: (request: {
+ path: ConditionPath;
+ field: string;
+ }) => FieldValuesQuery | undefined;
+};
+
+/**
+ * The edits a condition row or group asks for, addressed by path. The builder
+ * owns the tree and applies them; every node below it is stateless.
+ */
+export type EsQueryTreeActions = {
+ update: (
+ path: ConditionPath,
+ update: (condition: EsCondition) => EsCondition,
+ ) => void;
+ insert: (groupPath: ConditionPath, condition: EsCondition) => void;
+ remove: (path: ConditionPath) => void;
+ mapParam: (path: ConditionPath, operand: ParamOperand, name: string) => void;
+ unmapParam: (path: ConditionPath, name: string) => void;
+};
+
+export function EsQueryConditionRow({
+ condition,
+ path,
+ context,
+ actions,
+}: {
+ condition: EsCondition;
+ path: ConditionPath;
+ context: EsQueryContext;
+ actions: EsQueryTreeActions;
+}) {
+ const { catalog, qualifierNames, qualifierRestrictions, qualifiers } =
+ context.vocabulary;
+ const field = context.fields.find((entry) => entry.name === condition.field);
+ const info = catalog.find((entry) => entry.op === condition.op);
+ const warning = field ? fieldWarning(field) : undefined;
+ const set = (patch: Patch) =>
+ actions.update(path, (current) => applyPatch(current, patch));
+ const rowId = path.join("-") || "root";
+ const values = condition.field
+ ? context.values?.({ path, field: condition.field })
+ : undefined;
+
+ const advanced = qualifiersForOperator({
+ names: qualifierNames,
+ ...(qualifierRestrictions ? { restrictions: qualifierRestrictions } : {}),
+ op: condition.op,
+ });
+
+ return (
+
+
+
+ set({ occur: event.target.value as never })}
+ />
+
+ {info?.needsField ? (
+ set({ field: next })}
+ options={fieldOptions(context.fields)}
+ placeholder="Field…"
+ />
+ ) : null}
+ {info?.acceptsFields ? (
+ set({ fields: next })}
+ options={context.fields.map((entry) => ({
+ value: entry.name,
+ label: entry.name,
+ }))}
+ placeholder="All fields"
+ />
+ ) : null}
+
+ {
+ const next = catalog.find(
+ (entry) => entry.op === event.target.value,
+ );
+ if (!next) {
+ throw new Error(
+ `operator ${event.target.value} is missing from the catalog`,
+ );
+ }
+ actions.update(path, (current) =>
+ changeConditionOperator(current, next),
+ );
+ }}
+ >
+ {operatorGroups(catalog, field, condition.op).map((group) => {
+ const options = group.operators.map((entry) => (
+
+ {entry.label}
+
+ ));
+ return group.label ? (
+
+ {options}
+
+ ) : (
+ options
+ );
+ })}
+
+
+ actions.mapParam(path, operand, name)}
+ onUnbindParam={(name) => actions.unmapParam(path, name)}
+ />
+ actions.remove(path)}
+ />
+
+ {warning ? (
+
+ {warning}
+
+ ) : null}
+ {advanced.length ? (
+
+
+ Advanced
+
+
+ {advanced.map((name) => (
+ )[name]}
+ onChange={(next) => set({ [name]: next } as Patch)}
+ />
+ ))}
+
+ set({ optional: event.target.checked })}
+ />
+ Optional
+
+
+
+ ) : null}
+
+ );
+}
+
+function fieldOptions(fields: EsFieldMapping[]) {
+ return fields.map((field) => ({
+ value: field.name,
+ label: field.name,
+ title: (field.types ?? (field.dataType ? [field.dataType] : [])).join(", "),
+ }));
+}
+
+type OperatorGroup = {
+ label?: string;
+ operators: { op: string; label: string }[];
+};
+
+/**
+ * operatorGroups splits the offered operators into the ones that suit the field
+ * and the ones that fight its analysis, which render behind an Advanced
+ * divider. The condition's own operator is always offered, even where the field
+ * does not suit it, so changing the field never blanks the control.
+ */
+function operatorGroups(
+ catalog: EsBuilderVocabulary["catalog"],
+ field: EsFieldMapping | undefined,
+ current: string,
+): OperatorGroup[] {
+ const offered = operatorsForField(catalog, field);
+ const groups: OperatorGroup[] = [];
+ const plain = offered.filter((entry) => !entry.advanced);
+ const advanced = offered.filter((entry) => entry.advanced);
+ if (!offered.some((entry) => entry.op === current)) {
+ const info = catalog.find((entry) => entry.op === current);
+ advanced.unshift({ ...(info ?? { op: current, label: current, arity: "single", fieldTypes: [] }) });
+ }
+ if (plain.length) groups.push({ operators: plain });
+ if (advanced.length) groups.push({ label: "Advanced", operators: advanced });
+ return groups;
+}
diff --git a/packages/ui/src/profiles/esQueryGroupModel.ts b/packages/ui/src/profiles/esQueryGroupModel.ts
new file mode 100644
index 00000000..95717f2f
--- /dev/null
+++ b/packages/ui/src/profiles/esQueryGroupModel.ts
@@ -0,0 +1,24 @@
+/**
+ * A bool or nested group of conditions. A group renders itself for a child that
+ * is also a group, so the tree nests to whatever depth the author builds; the
+ * compiler places no bound on it either.
+ */
+
+import {
+ type EsBuilderVocabulary
+ } from "./esQueryOperators";
+
+/** Whether an operator holds other conditions rather than matching a value. */
+export function isGroupOperator(
+ catalog: EsBuilderVocabulary["catalog"],
+ op: string,
+): boolean {
+ return catalog.find((entry) => entry.op === op)?.group === true;
+}
+
+/** The group kinds the catalog offers — bool and nested, today. */
+export function groupOperatorOptions(catalog: EsBuilderVocabulary["catalog"]) {
+ return catalog
+ .filter((entry) => entry.group)
+ .map((entry) => ({ value: entry.op, label: entry.label }));
+}
diff --git a/packages/ui/src/profiles/esQueryOccur.ts b/packages/ui/src/profiles/esQueryOccur.ts
new file mode 100644
index 00000000..b1a522e4
--- /dev/null
+++ b/packages/ui/src/profiles/esQueryOccur.ts
@@ -0,0 +1,23 @@
+/**
+ * One leaf condition: which field, which operator, and the operand that
+ * operator takes. Which operators a field may use and which advanced qualifiers
+ * an operator emits both come from the schema vocabulary, so this file owns the
+ * editing surface and never the vocabulary itself.
+ */
+
+
+export function occurOptions(occurs: string[]) {
+ return occurs.map((occur) => ({
+ value: occur,
+ label: occurLabels[occur] ?? occur
+ }));
+}
+
+// How each bool clause reads to an author. filter and must both narrow, but
+// only must scores, which is the distinction the raw names hide.
+const occurLabels: Record = {
+ filter: "AND",
+ must: "AND (scored)",
+ should: "OR",
+ must_not: "NOT"
+};
diff --git a/packages/ui/src/profiles/esQueryOperandEditors.tsx b/packages/ui/src/profiles/esQueryOperandEditors.tsx
new file mode 100644
index 00000000..08174c0c
--- /dev/null
+++ b/packages/ui/src/profiles/esQueryOperandEditors.tsx
@@ -0,0 +1,338 @@
+/**
+ * The operand side of a condition row: the value an operator takes, and the
+ * advanced qualifiers it emits alongside it. Which operand shape applies comes
+ * from the operator's arity, and which qualifiers exist comes from the schema —
+ * neither is decided here.
+ */
+
+import { InputField } from "../components/InputField";
+import { Select } from "../components/select";
+import { useState, type ReactNode } from "react";
+import {
+ isParamValue,
+ type EsCondition,
+ type EsValue,
+} from "./esQueryBuilderModel";
+import type { FieldValuesQuery } from "./esFieldValues";
+import type { Patch } from "./profileWizardModel";
+import type { ParamOperand } from "./esParamMappingModel";
+import { extendEsParamOperand } from "./esParamOperandExtension";
+import {
+ conditionOperandPatch,
+ multipleConditionValues,
+} from "./esQueryOperandModel";
+import type { EsQualifierSchema } from "./esQueryOperators";
+import { ValueCombobox, ValuesCombobox } from "./esValueCombobox";
+import type { ParamDraft } from "./profileWizardModel";
+
+// Date math a range over a date field commonly starts from. They are ordinary
+// operand values that the backend resolves — this only saves the typing.
+const dateMathPresets = [
+ "now-15m",
+ "now-1h",
+ "now-24h",
+ "now-7d",
+ "now/d",
+ "now",
+];
+
+export function ConditionOperand({
+ condition,
+ arity,
+ rowId,
+ params,
+ dateMath,
+ values,
+ set,
+ onBindParam,
+ onUnbindParam,
+}: {
+ condition: EsCondition;
+ arity: string;
+ rowId: string;
+ params: ParamDraft[];
+ dateMath: boolean;
+ /** The field's own values, when the mapping allows aggregating them. */
+ values?: FieldValuesQuery;
+ set: (patch: Patch) => void;
+ onBindParam: (operand: ParamOperand, name: string) => void;
+ onUnbindParam: (name: string) => void;
+}) {
+ if (arity === "none" || arity === "group") return null;
+ if (arity === "multiple") {
+ return (
+ {
+ if (isParamValue(next)) return onBindParam("values", next.param);
+ const bound = boundParam(multipleConditionValues(condition));
+ if (next === undefined && bound) return onUnbindParam(bound);
+ if (!Array.isArray(next))
+ throw new Error("multiple operand requires a value list");
+ set(conditionOperandPatch({ arity: "multiple", values: next }));
+ }}
+ />
+ );
+ }
+ if (arity === "range") {
+ return (
+
+ {(["gte", "lte"] as const).map((bound) => (
+ {
+ if (isParamValue(next)) return onBindParam(bound, next.param);
+ const mapped = boundParam(condition[bound]);
+ if (next === undefined && mapped) return onUnbindParam(mapped);
+ set(
+ conditionOperandPatch({ arity: "range", bound, value: next }),
+ );
+ }}
+ />
+ ))}
+
+ );
+ }
+ return (
+ {
+ if (isParamValue(next)) return onBindParam("value", next.param);
+ const bound = boundParam(condition.value);
+ if (next === undefined && bound) return onUnbindParam(bound);
+ set(conditionOperandPatch({ arity: "single", value: next }));
+ }}
+ />
+ );
+}
+
+function ValueInput({
+ id,
+ label,
+ value,
+ params,
+ presets,
+ lookup,
+ onChange,
+}: {
+ id: string;
+ label: string;
+ value: EsValue;
+ params: ParamDraft[];
+ presets: string[];
+ lookup?: FieldValuesQuery;
+ onChange: (next: EsValue) => void;
+}) {
+ let node: ReactNode;
+ if (lookup) {
+ node = (
+ onChange(next === "" ? undefined : next)}
+ />
+ );
+ } else {
+ const listId = presets.length ? `es-presets-${id}` : undefined;
+ node = (
+ <>
+ onChange(next === "" ? undefined : next)}
+ />
+ {listId ? (
+
+ {presets.map((preset) => (
+
+ ))}
+
+ ) : null}
+ >
+ );
+ }
+ return extendEsParamOperand({
+ label,
+ value,
+ onChange,
+ node,
+ params,
+ });
+}
+
+function ValuesInput({
+ values,
+ params,
+ lookup,
+ onChange,
+}: {
+ values: EsValue[];
+ params: ParamDraft[];
+ lookup?: FieldValuesQuery;
+ onChange: (values: EsValue[] | EsValue | undefined) => void;
+}) {
+ const [draft, setDraft] = useState("");
+ const bound = values.filter(isParamValue);
+ const literals = values.filter((value) => !isParamValue(value));
+ let node: ReactNode;
+ if (lookup) {
+ node = (
+ onChange([...next, ...bound])}
+ />
+ );
+ } else {
+ const commit = () => {
+ const parsed = draft
+ .split(",")
+ .map((entry) => entry.trim())
+ .filter(Boolean);
+ if (!parsed.length) return;
+ onChange([...literals, ...parsed, ...bound]);
+ setDraft("");
+ };
+ const removeAt = (index: number) =>
+ onChange([
+ ...literals.filter((_value, position) => position !== index),
+ ...bound,
+ ]);
+ node = (
+
+ {literals.map((value, index) => (
+
+ {String(value)}
+ removeAt(index)}
+ >
+ ×
+
+
+ ))}
+ {
+ if (event.key !== "Enter" && event.key !== ",") return;
+ event.preventDefault();
+ commit();
+ }}
+ onBlur={commit}
+ />
+
+ );
+ }
+ return extendEsParamOperand({
+ label: "value",
+ value: values,
+ onChange,
+ node,
+ params,
+ });
+}
+
+function boundParam(value: EsValue | EsValue[]): string | undefined {
+ const values = Array.isArray(value) ? value : [value];
+ return values.find(isParamValue)?.param;
+}
+
+export function QualifierInput({
+ name,
+ schema,
+ value,
+ onChange,
+}: {
+ name: string;
+ schema: EsQualifierSchema;
+ value: unknown;
+ onChange: (next: unknown) => void;
+}) {
+ const label = schema.title ?? name;
+ if (schema.type === "boolean") {
+ const checked =
+ value === undefined ? schema.default === true : value === true;
+ return (
+
+ onChange(event.target.checked)}
+ />
+ {label}
+
+ );
+ }
+ if (schema.enum?.length) {
+ // Select renders `placeholder` as a disabled option, which would trap the
+ // qualifier once set. An explicit empty entry is what clears it again.
+ return (
+
+ ({ value: entry, label: entry })),
+ ]}
+ onChange={(event) =>
+ onChange(event.target.value === "" ? undefined : event.target.value)
+ }
+ />
+
+ );
+ }
+ const numeric = schema.type === "integer" || schema.type === "number";
+ return (
+ {
+ if (next === "") return onChange(undefined);
+ onChange(numeric ? Number(next) : next);
+ }}
+ />
+ );
+}
diff --git a/packages/ui/src/profiles/esQueryOperandModel.test.ts b/packages/ui/src/profiles/esQueryOperandModel.test.ts
new file mode 100644
index 00000000..6cd9aabc
--- /dev/null
+++ b/packages/ui/src/profiles/esQueryOperandModel.test.ts
@@ -0,0 +1,99 @@
+import { describe, expect, it } from "vitest";
+import {
+ conditionOperandPatch,
+ multipleConditionValues,
+} from "./esQueryOperandModel";
+import { changeConditionOperator, type EsOperatorInfo } from "./esQueryOperators";
+
+describe("multipleConditionValues", () => {
+ it("uses canonical values ahead of a stale singular operand", () => {
+ expect(
+ multipleConditionValues({
+ op: "terms",
+ value: "stale",
+ values: ["scheme-1", "scheme-2"],
+ }),
+ ).toEqual(["scheme-1", "scheme-2"]);
+ });
+
+ it("shows a valid singular terms operand as one selected value", () => {
+ expect(multipleConditionValues({ op: "terms", value: "scheme-1" })).toEqual([
+ "scheme-1",
+ ]);
+ expect(multipleConditionValues({ op: "terms" })).toEqual([]);
+ });
+});
+
+describe("conditionOperandPatch", () => {
+ it("never leaves value beside values after the term to terms editing flow", () => {
+ const terms: EsOperatorInfo = {
+ op: "terms",
+ label: "is one of",
+ arity: "multiple",
+ needsField: true,
+ fieldTypes: ["keyword"],
+ };
+ const changed = changeConditionOperator(
+ { op: "term", field: "tag.scheme@id", value: "scheme-1" },
+ terms,
+ );
+
+ expect({
+ ...changed,
+ ...conditionOperandPatch({
+ arity: "multiple",
+ values: [...multipleConditionValues(changed), "scheme-2"],
+ }),
+ }).toEqual({
+ op: "terms",
+ field: "tag.scheme@id",
+ value: undefined,
+ values: ["scheme-1", "scheme-2"],
+ gt: undefined,
+ gte: undefined,
+ lt: undefined,
+ lte: undefined,
+ conditions: undefined,
+ });
+ });
+
+ it("makes a multiple edit the only operand representation", () => {
+ expect(
+ conditionOperandPatch({
+ arity: "multiple",
+ values: ["scheme-1", "scheme-2"],
+ }),
+ ).toEqual({
+ value: undefined,
+ values: ["scheme-1", "scheme-2"],
+ gt: undefined,
+ gte: undefined,
+ lt: undefined,
+ lte: undefined,
+ conditions: undefined,
+ });
+ });
+
+ it("clears list and range operands for a singular edit", () => {
+ expect(conditionOperandPatch({ arity: "single", value: "error" })).toEqual({
+ value: "error",
+ values: undefined,
+ gt: undefined,
+ gte: undefined,
+ lt: undefined,
+ lte: undefined,
+ conditions: undefined,
+ });
+ });
+
+ it("keeps sibling bounds while clearing scalar and list operands", () => {
+ expect(
+ conditionOperandPatch({ arity: "range", bound: "gte", value: "now-1h" }),
+ ).toEqual({
+ value: undefined,
+ values: undefined,
+ conditions: undefined,
+ gte: "now-1h",
+ });
+ });
+});
diff --git a/packages/ui/src/profiles/esQueryOperandModel.ts b/packages/ui/src/profiles/esQueryOperandModel.ts
new file mode 100644
index 00000000..b513f627
--- /dev/null
+++ b/packages/ui/src/profiles/esQueryOperandModel.ts
@@ -0,0 +1,39 @@
+import type { EsCondition, EsValue } from "./esQueryBuilderModel";
+import type { Patch } from "./profileWizardModel";
+
+type ConditionOperandEdit =
+ | { arity: "single"; value: EsValue }
+ | { arity: "multiple"; values: EsValue[] }
+ | {
+ arity: "range";
+ bound: "gt" | "gte" | "lt" | "lte";
+ value: EsValue;
+ };
+
+export function multipleConditionValues(condition: EsCondition): EsValue[] {
+ if (condition.values?.length) return condition.values;
+ return condition.value === undefined ? [] : [condition.value];
+}
+
+export function conditionOperandPatch(
+ edit: ConditionOperandEdit,
+): Patch {
+ if (edit.arity === "range") {
+ return {
+ value: undefined,
+ values: undefined,
+ conditions: undefined,
+ [edit.bound]: edit.value,
+ };
+ }
+ const cleared = {
+ gt: undefined,
+ gte: undefined,
+ lt: undefined,
+ lte: undefined,
+ conditions: undefined,
+ };
+ return edit.arity === "multiple"
+ ? { value: undefined, values: edit.values, ...cleared }
+ : { value: edit.value, values: undefined, ...cleared };
+}
diff --git a/packages/ui/src/profiles/esQueryOperators.test.ts b/packages/ui/src/profiles/esQueryOperators.test.ts
new file mode 100644
index 00000000..ea04bd72
--- /dev/null
+++ b/packages/ui/src/profiles/esQueryOperators.test.ts
@@ -0,0 +1,348 @@
+import { describe, expect, it } from "vitest";
+import {
+ changeConditionOperator,
+ esBuilderVocabulary,
+ fieldFamily,
+ fieldWarning,
+ operatorCatalogFromSchema,
+ operatorsForField,
+ qualifiersForOperator,
+ sortableFields,
+ type EsFieldMapping,
+ type EsOperatorInfo,
+} from "./esQueryOperators";
+
+// A trimmed catalog in the exact shape x-es-operators carries. Using the real
+// keys keeps the test honest about what the schema actually hands over.
+const catalog: EsOperatorInfo[] = [
+ { op: "term", label: "is", arity: "single", needsField: true,
+ fieldTypes: ["keyword", "text", "date", "number", "boolean", "ip"] },
+ { op: "terms", label: "is one of", arity: "multiple", needsField: true,
+ fieldTypes: ["keyword", "text", "date", "number", "boolean", "ip"] },
+ { op: "match", label: "matches", arity: "single", needsField: true,
+ fieldTypes: ["text", "keyword"], analyzed: true },
+ { op: "prefix", label: "starts with", arity: "single", needsField: true,
+ fieldTypes: ["keyword", "text"] },
+ { op: "range", label: "is between", arity: "range", needsField: true,
+ fieldTypes: ["date", "number", "ip", "keyword"] },
+ { op: "exists", label: "exists", arity: "none", needsField: true, fieldTypes: ["any"] },
+ { op: "query_string", label: "query string", arity: "single", acceptsFields: true,
+ fieldTypes: ["any"], analyzed: true },
+ { op: "nested", label: "nested", arity: "group", fieldTypes: ["nested"], group: true },
+ { op: "bool", label: "group", arity: "group", fieldTypes: ["any"], group: true },
+];
+
+const field = (mapping: Partial & { name: string }): EsFieldMapping => ({
+ searchable: true,
+ aggregatable: true,
+ ...mapping,
+});
+
+describe("condition operator transitions", () => {
+ const operator = (op: string) => {
+ const match = catalog.find((entry) => entry.op === op);
+ if (!match) throw new Error(`missing test operator ${op}`);
+ return match;
+ };
+
+ it("moves a singular operand into values when changing to terms", () => {
+ expect(
+ changeConditionOperator(
+ {
+ op: "term",
+ occur: "filter",
+ field: "tag.scheme@id",
+ value: "scheme-1",
+ boost: 2,
+ },
+ operator("terms"),
+ ),
+ ).toEqual({
+ op: "terms",
+ occur: "filter",
+ field: "tag.scheme@id",
+ values: ["scheme-1"],
+ boost: 2,
+ });
+ });
+
+ it("promotes one list operand when changing to a singular operator", () => {
+ expect(
+ changeConditionOperator(
+ { op: "terms", field: "level", values: ["error"] },
+ operator("term"),
+ ),
+ ).toEqual({ op: "term", field: "level", value: "error" });
+ });
+
+ it("clears a list that cannot be converted to one operand", () => {
+ expect(
+ changeConditionOperator(
+ { op: "terms", field: "level", values: ["warn", "error"] },
+ operator("term"),
+ ),
+ ).toEqual({ op: "term", field: "level" });
+ });
+
+ it("keeps only operands accepted by the next arity", () => {
+ expect(
+ changeConditionOperator(
+ {
+ op: "range",
+ field: "@timestamp",
+ value: "stale",
+ values: ["also-stale"],
+ gte: "now-1h",
+ lte: "now",
+ conditions: [{ op: "exists", field: "trace.id" }],
+ },
+ operator("range"),
+ ),
+ ).toEqual({
+ op: "range",
+ field: "@timestamp",
+ gte: "now-1h",
+ lte: "now",
+ });
+
+ expect(
+ changeConditionOperator(
+ { op: "term", field: "level", value: "error", gte: "stale" },
+ operator("exists"),
+ ),
+ ).toEqual({ op: "exists", field: "level" });
+ });
+});
+
+describe("field families", () => {
+ const cases: Array<[string, string]> = [
+ ["keyword", "keyword"],
+ ["constant_keyword", "keyword"],
+ ["wildcard", "keyword"],
+ ["text", "text"],
+ ["match_only_text", "text"],
+ ["search_as_you_type", "text"],
+ ["date", "date"],
+ ["date_nanos", "date"],
+ ["long", "number"],
+ ["integer", "number"],
+ ["short", "number"],
+ ["byte", "number"],
+ ["double", "number"],
+ ["float", "number"],
+ ["half_float", "number"],
+ ["scaled_float", "number"],
+ ["unsigned_long", "number"],
+ ["boolean", "boolean"],
+ ["ip", "ip"],
+ ["ip_range", "ip"],
+ ["nested", "nested"],
+ ["object", "object"],
+ ["flattened", "object"],
+ ["geo_point", "any"],
+ ["", "any"],
+ ];
+ it.each(cases)("reduces %s to the %s family", (dataType, family) => {
+ expect(fieldFamily(field({ name: "f", dataType }))).toBe(family);
+ });
+
+ it("takes the first mapped type when a field carries several", () => {
+ expect(fieldFamily(field({ name: "f", types: ["keyword", "text"] }))).toBe("keyword");
+ });
+});
+
+describe("operators offered per field", () => {
+ const operators = (mapping: Partial & { name: string }) =>
+ operatorsForField(catalog, field(mapping)).map((entry) => entry.op);
+
+ it("leads a keyword field with an exact match", () => {
+ expect(operators({ name: "level", dataType: "keyword" })).toEqual([
+ "term", "terms", "prefix", "range", "exists", "query_string", "match",
+ ]);
+ });
+
+ // A text field is analyzed, so term/prefix rarely do what an author expects.
+ // They stay available, but behind the advanced flag.
+ it("leads a text field with match and demotes the exact operators", () => {
+ const offered = operatorsForField(catalog, field({ name: "message", dataType: "text" }));
+ expect(offered.map((entry) => entry.op)).toEqual([
+ "match", "exists", "query_string", "term", "terms", "prefix",
+ ]);
+ expect(offered.find((entry) => entry.op === "term")?.advanced).toBe(true);
+ expect(offered.find((entry) => entry.op === "match")?.advanced).toBeFalsy();
+ });
+
+ it("leads a date field with a range", () => {
+ expect(operators({ name: "@timestamp", dataType: "date" })[0]).toBe("range");
+ });
+
+ it("leads a number field with a range and omits the analyzed operators", () => {
+ const offered = operators({ name: "duration", dataType: "long" });
+ expect(offered[0]).toBe("range");
+ expect(offered).not.toContain("match");
+ expect(offered).not.toContain("prefix");
+ });
+
+ it("offers a nested field a nested group rather than a leaf", () => {
+ expect(operators({ name: "spans", dataType: "nested" })).toEqual(["nested", "exists"]);
+ });
+
+ // An unsearchable field can only be tested for presence; offering anything
+ // else would build a query the backend silently returns nothing for.
+ it("offers an unsearchable field only exists", () => {
+ expect(operators({ name: "blob", dataType: "keyword", searchable: false })).toEqual([
+ "exists",
+ ]);
+ });
+
+ it("falls back to the type-independent operators for an unknown type", () => {
+ expect(operators({ name: "point", dataType: "geo_point" })).toEqual([
+ "exists", "query_string",
+ ]);
+ });
+
+ it("offers every operator when no field is selected yet", () => {
+ expect(operatorsForField(catalog, undefined).map((entry) => entry.op)).toEqual(
+ catalog.map((entry) => entry.op),
+ );
+ });
+});
+
+describe("field warnings", () => {
+ it("names every mapped type of a conflicting field", () => {
+ expect(
+ fieldWarning(field({ name: "code", conflicting: true, types: ["keyword", "long"] })),
+ ).toBe("code is mapped as keyword and long across indexes");
+ });
+
+ it("explains why an unsearchable field is limited", () => {
+ expect(fieldWarning(field({ name: "blob", dataType: "binary", searchable: false }))).toBe(
+ "blob is not searchable, so only exists applies",
+ );
+ });
+
+ it("stays quiet about an ordinary field", () => {
+ expect(fieldWarning(field({ name: "level", dataType: "keyword" }))).toBeUndefined();
+ });
+});
+
+describe("sortable fields", () => {
+ it("keeps the aggregatable fields and always offers _score and _doc", () => {
+ expect(
+ sortableFields([
+ field({ name: "@timestamp", dataType: "date" }),
+ field({ name: "message", dataType: "text", aggregatable: false }),
+ field({ name: "level", dataType: "keyword" }),
+ ]),
+ ).toEqual(["@timestamp", "level", "_score", "_doc"]);
+ });
+});
+
+describe("qualifiers", () => {
+ const names = ["analyzer", "caseInsensitive", "scoreMode", "boost"];
+ const restrictions = {
+ analyzer: ["match", "match_phrase"],
+ caseInsensitive: ["term", "prefix"],
+ scoreMode: ["nested"],
+ };
+
+ it("offers only the qualifiers the operator emits", () => {
+ expect(qualifiersForOperator({ names, restrictions, op: "match" })).toEqual([
+ "analyzer",
+ "boost",
+ ]);
+ expect(qualifiersForOperator({ names, restrictions, op: "nested" })).toEqual([
+ "scoreMode",
+ "boost",
+ ]);
+ });
+
+ // boost is absent from the table because the compiler accepts it everywhere,
+ // so an unlisted qualifier has to stay offered rather than disappear.
+ it("keeps an unrestricted qualifier for every operator", () => {
+ for (const op of ["match", "term", "nested", "range"]) {
+ expect(qualifiersForOperator({ names, restrictions, op })).toContain("boost");
+ }
+ });
+
+ it("offers everything when the schema carried no table", () => {
+ expect(qualifiersForOperator({ names, op: "match" })).toEqual(names);
+ });
+});
+
+describe("catalog extraction", () => {
+ it("reads the catalog off the search property of the options schema", () => {
+ expect(
+ operatorCatalogFromSchema({
+ properties: { search: { "x-es-operators": catalog } },
+ }),
+ ).toEqual(catalog);
+ });
+
+ it("returns nothing when the schema carries no catalog", () => {
+ expect(operatorCatalogFromSchema({ properties: { index: { type: "string" } } })).toEqual([]);
+ expect(operatorCatalogFromSchema(undefined)).toEqual([]);
+ });
+});
+
+describe("builder vocabulary", () => {
+ // The shape query/schema/search_spec.go emits, trimmed to what is read here.
+ const schema = {
+ properties: {
+ search: {
+ "x-es-operators": catalog,
+ "x-es-occurs": ["filter", "must", "should", "must_not"],
+ "x-es-qualifiers": { analyzer: ["match"], scoreMode: ["nested"] },
+ properties: {
+ query: {
+ properties: {
+ op: { type: "string" },
+ occur: { type: "string" },
+ field: { type: "string" },
+ conditions: { type: "array" },
+ analyzer: { type: "string", title: "Analyzer" },
+ boost: { type: "number", title: "Boost" },
+ scoreMode: { type: "string", title: "Score mode", enum: ["avg", "none"] },
+ },
+ },
+ sort: {
+ items: { properties: { order: { type: "string", enum: ["asc", "desc"] } } },
+ },
+ },
+ },
+ },
+ };
+
+ // A qualifier is whatever the condition schema carries beyond the structural
+ // keys, so a new one in Go reaches the advanced editor untouched here.
+ it("separates the advanced qualifiers from the structural condition keys", () => {
+ const vocabulary = esBuilderVocabulary(schema);
+ expect(vocabulary.qualifierNames).toEqual(["analyzer", "boost", "scoreMode"]);
+ expect(vocabulary.qualifiers.scoreMode).toEqual({
+ type: "string",
+ title: "Score mode",
+ enum: ["avg", "none"],
+ });
+ });
+
+ it("carries the clauses, the restriction table and the sort orders", () => {
+ const vocabulary = esBuilderVocabulary(schema);
+ expect(vocabulary.occurs).toEqual(["filter", "must", "should", "must_not"]);
+ expect(vocabulary.qualifierRestrictions).toEqual({
+ analyzer: ["match"],
+ scoreMode: ["nested"],
+ });
+ expect(vocabulary.sortOrders).toEqual(["asc", "desc"]);
+ expect(vocabulary.catalog).toEqual(catalog);
+ });
+
+ it("stays empty for a schema that describes no search", () => {
+ expect(esBuilderVocabulary({ properties: { index: { type: "string" } } })).toEqual({
+ catalog: [],
+ occurs: [],
+ qualifierNames: [],
+ qualifiers: {},
+ sortOrders: [],
+ });
+ });
+});
diff --git a/packages/ui/src/profiles/esQueryOperators.ts b/packages/ui/src/profiles/esQueryOperators.ts
new file mode 100644
index 00000000..ac172190
--- /dev/null
+++ b/packages/ui/src/profiles/esQueryOperators.ts
@@ -0,0 +1,284 @@
+/**
+ * Which operators a field accepts. The operator vocabulary itself comes from
+ * the schema (`x-es-operators`, emitted from Go's esdsl.Catalog), so the only
+ * knowledge owned here is how a mapping type reduces to a family and how the
+ * resulting operators are ordered for an author.
+ */
+
+import {
+ defaultOperatorForFamily,
+ type EsCondition,
+} from "./esQueryBuilderModel";
+
+/** One entry of x-es-operators, keyed exactly as esdsl.OperatorInfo marshals. */
+export type EsOperatorInfo = {
+ op: string;
+ label: string;
+ arity: "none" | "single" | "multiple" | "range" | "group";
+ needsField?: boolean;
+ acceptsFields?: boolean;
+ fieldTypes: string[];
+ analyzed?: boolean;
+ group?: boolean;
+};
+
+export function changeConditionOperator(
+ condition: EsCondition,
+ next: EsOperatorInfo,
+): EsCondition {
+ const { value, values, gt, gte, lt, lte, conditions, ...rest } = condition;
+ const changed: EsCondition = { ...rest, op: next.op };
+
+ if (next.arity === "single") {
+ const operand = value !== undefined
+ ? value
+ : values?.length === 1
+ ? values[0]
+ : undefined;
+ return operand === undefined ? changed : { ...changed, value: operand };
+ }
+ if (next.arity === "multiple") {
+ const operands = values?.length
+ ? values
+ : value === undefined
+ ? []
+ : [value];
+ return operands.length === 0 ? changed : { ...changed, values: operands };
+ }
+ if (next.arity === "range") {
+ return {
+ ...changed,
+ ...(gt !== undefined ? { gt } : {}),
+ ...(gte !== undefined ? { gte } : {}),
+ ...(lt !== undefined ? { lt } : {}),
+ ...(lte !== undefined ? { lte } : {}),
+ };
+ }
+ if (next.arity === "group") {
+ return { ...changed, conditions: conditions ?? [] };
+ }
+ return changed;
+}
+
+/** A field as _field_caps reports it, via the browser inspection response. */
+export type EsFieldMapping = {
+ name: string;
+ dataType?: string;
+ types?: string[];
+ searchable?: boolean;
+ aggregatable?: boolean;
+ conflicting?: boolean;
+};
+
+/** An offered operator. `advanced` marks one that fights the field's analysis. */
+export type OfferedOperator = EsOperatorInfo & { advanced?: boolean };
+
+const families: Record = {
+ keyword: "keyword",
+ constant_keyword: "keyword",
+ wildcard: "keyword",
+ text: "text",
+ match_only_text: "text",
+ search_as_you_type: "text",
+ date: "date",
+ date_nanos: "date",
+ long: "number",
+ integer: "number",
+ short: "number",
+ byte: "number",
+ double: "number",
+ float: "number",
+ half_float: "number",
+ scaled_float: "number",
+ unsigned_long: "number",
+ boolean: "boolean",
+ ip: "ip",
+ ip_range: "ip",
+ nested: "nested",
+ object: "object",
+ flattened: "object",
+};
+
+// Families whose values are run through an analyzer at index time, so an exact
+// operator on them matches tokens rather than the stored text.
+const analyzedFamilies = new Set(["text"]);
+
+// Structural families hold other fields rather than a value: they are descended
+// into or tested for presence, never matched against.
+const structuralFamilies = new Set(["nested", "object"]);
+
+export function fieldFamily(field: EsFieldMapping | undefined): string {
+ const mapped = field?.dataType || field?.types?.[0] || "";
+ return families[mapped] ?? "any";
+}
+
+/**
+ * operatorsForField ranks the applicable operators: the family's default first,
+ * then the rest that suit it, then the type-independent ones, and finally the
+ * ones that fight the field's analysis — those carry `advanced`.
+ */
+export function operatorsForField(
+ catalog: EsOperatorInfo[],
+ field: EsFieldMapping | undefined,
+): OfferedOperator[] {
+ if (!field) return catalog.map((entry) => ({ ...entry }));
+ if (field.searchable === false) {
+ return catalog.filter((entry) => entry.op === "exists").map((entry) => ({ ...entry }));
+ }
+ const family = fieldFamily(field);
+ if (structuralFamilies.has(family)) {
+ return catalog
+ .filter((entry) => entry.op === "exists" || entry.fieldTypes.includes(family))
+ .sort((left, right) => Number(left.op === "exists") - Number(right.op === "exists"))
+ .map((entry) => ({ ...entry }));
+ }
+
+ const analyzed = analyzedFamilies.has(family);
+ const preferred = defaultOperatorForFamily(family);
+ const ranked = catalog.flatMap((entry, position) => {
+ if (entry.group) return [];
+ const direct = entry.fieldTypes.includes(family);
+ const generic = entry.fieldTypes.includes("any");
+ if (!direct && !generic) return [];
+ if (direct && entry.op === preferred) return [{ entry, rank: 0, position }];
+ if (direct && Boolean(entry.analyzed) !== analyzed) {
+ return [{ entry: { ...entry, advanced: true }, rank: 3, position }];
+ }
+ return [{ entry, rank: direct ? 1 : 2, position }];
+ });
+ return ranked
+ .sort((left, right) => left.rank - right.rank || left.position - right.position)
+ .map(({ entry }) => ({ ...entry }));
+}
+
+/**
+ * fieldWarning explains a field the author cannot query the way they expect. A
+ * conflicting field is still offered — hiding it would silently drop a field
+ * that works on most of the matched indexes.
+ */
+export function fieldWarning(field: EsFieldMapping): string | undefined {
+ if (field.conflicting) {
+ const types = field.types ?? (field.dataType ? [field.dataType] : []);
+ return `${field.name} is mapped as ${joinWithAnd(types)} across indexes`;
+ }
+ if (field.searchable === false) {
+ return `${field.name} is not searchable, so only exists applies`;
+ }
+ return undefined;
+}
+
+/** sortableFields lists what can order the hits: doc values, _score and _doc. */
+export function sortableFields(fields: EsFieldMapping[]): string[] {
+ return [
+ ...fields.filter((field) => field.aggregatable !== false).map((field) => field.name),
+ "_score",
+ "_doc",
+ ];
+}
+
+/**
+ * qualifiersForOperator narrows the advanced settings to the ones the operator
+ * emits. `restrictions` is the schema's x-es-qualifiers table, which lists only
+ * the qualifiers the compiler restricts — a name it omits applies everywhere.
+ */
+export function qualifiersForOperator(options: {
+ names: string[];
+ restrictions?: Record;
+ op: string;
+}): string[] {
+ const { names, restrictions = {}, op } = options;
+ return names.filter((name) => !restrictions[name] || restrictions[name].includes(op));
+}
+
+type OptionsSchema = { properties?: Record } | undefined;
+
+type SchemaNode = {
+ properties?: Record;
+ items?: SchemaNode;
+ enum?: string[];
+ "x-es-operators"?: EsOperatorInfo[];
+ "x-es-occurs"?: string[];
+ "x-es-qualifiers"?: Record;
+};
+
+function searchProperty(schema: OptionsSchema): SchemaNode | undefined {
+ return schema?.properties?.search as SchemaNode | undefined;
+}
+
+/** operatorCatalogFromSchema reads x-es-operators off the search property. */
+export function operatorCatalogFromSchema(schema: OptionsSchema): EsOperatorInfo[] {
+ return searchProperty(schema)?.["x-es-operators"] ?? [];
+}
+
+/** esQualifiersFromSchema is the same read for the qualifier-to-operator table. */
+export function esQualifiersFromSchema(
+ schema: OptionsSchema,
+): Record | undefined {
+ return searchProperty(schema)?.["x-es-qualifiers"];
+}
+
+/** One advanced qualifier, as the condition schema describes it. */
+export type EsQualifierSchema = {
+ title?: string;
+ description?: string;
+ type?: string;
+ enum?: string[];
+ default?: unknown;
+};
+
+/**
+ * Everything about a search that Go owns: the operator catalog, the bool
+ * clauses, the advanced qualifiers and the sort orders. The builder reads all of
+ * it off the schema, so adding a qualifier or an operator in esdsl reaches the
+ * editor without a frontend change.
+ */
+export type EsBuilderVocabulary = {
+ catalog: EsOperatorInfo[];
+ occurs: string[];
+ qualifierNames: string[];
+ qualifierRestrictions?: Record;
+ qualifiers: Record;
+ sortOrders: string[];
+};
+
+// The condition properties that carry structure rather than an advanced
+// setting. Whatever the schema holds beyond these is a qualifier.
+const structuralConditionKeys = new Set([
+ "op",
+ "occur",
+ "field",
+ "fields",
+ "value",
+ "values",
+ "gt",
+ "gte",
+ "lt",
+ "lte",
+ "optional",
+ "when",
+ "conditions",
+]);
+
+export function esBuilderVocabulary(schema: OptionsSchema): EsBuilderVocabulary {
+ const search = searchProperty(schema);
+ const conditionProperties = search?.properties?.query?.properties ?? {};
+ const qualifiers: Record = {};
+ for (const [name, property] of Object.entries(conditionProperties)) {
+ if (structuralConditionKeys.has(name)) continue;
+ qualifiers[name] = property as EsQualifierSchema;
+ }
+ const restrictions = esQualifiersFromSchema(schema);
+ return {
+ catalog: operatorCatalogFromSchema(schema),
+ occurs: search?.["x-es-occurs"] ?? [],
+ qualifierNames: Object.keys(qualifiers),
+ ...(restrictions ? { qualifierRestrictions: restrictions } : {}),
+ qualifiers,
+ sortOrders: search?.properties?.sort?.items?.properties?.order?.enum ?? [],
+ };
+}
+
+function joinWithAnd(values: string[]): string {
+ if (values.length < 2) return values.join("");
+ return `${values.slice(0, -1).join(", ")} and ${values[values.length - 1]}`;
+}
diff --git a/packages/ui/src/profiles/esQueryOutputEditor.tsx b/packages/ui/src/profiles/esQueryOutputEditor.tsx
new file mode 100644
index 00000000..ac59fce5
--- /dev/null
+++ b/packages/ui/src/profiles/esQueryOutputEditor.tsx
@@ -0,0 +1,154 @@
+/**
+ * What a matching document comes back as: where the hits start, which _source
+ * fields travel, and whether the backend counts past its default. How many hits
+ * come back is the query's Limit, edited next to the filters — one row cap, so
+ * that it cannot disagree with itself across the form and the raw DSL.
+ */
+
+import { InputField } from "../components/InputField";
+import { useState } from "react";
+import type { EsSearch } from "./esQueryBuilderModel";
+import type { Patch } from "./profileWizardModel";
+import { parseCount, pruneEmpty } from "./esQueryOutputModel";
+
+export function EsQueryOutputEditor({
+ search,
+ onChange,
+}: {
+ search: EsSearch;
+ onChange: (patch: Patch) => void;
+}) {
+ const source = search.source ?? {};
+ const total = search.trackTotalHits ?? {};
+ const setSource = (patch: Partial>) =>
+ onChange({ source: pruneEmpty({ ...source, ...patch }) });
+ const setTotal = (patch: Partial>) =>
+ onChange({ trackTotalHits: pruneEmpty({ ...total, ...patch }) });
+
+ return (
+
+ );
+}
+
+/**
+ * A list of _source field patterns. They are patterns rather than field names —
+ * `user.*` is the point of them — so this stays free text rather than a picker.
+ */
+function PatternList({
+ label,
+ values,
+ onChange,
+}: {
+ label: string;
+ values: string[];
+ onChange: (values: string[]) => void;
+}) {
+ const [draft, setDraft] = useState("");
+ const commit = () => {
+ const parsed = draft
+ .split(",")
+ .map((entry) => entry.trim())
+ .filter(Boolean);
+ if (!parsed.length) return;
+ onChange([...values, ...parsed]);
+ setDraft("");
+ };
+ return (
+
+ {label}
+ {values.map((value, index) => (
+
+ {value}
+
+ onChange(values.filter((_entry, position) => position !== index))
+ }
+ >
+ ×
+
+
+ ))}
+ {
+ if (event.key !== "Enter" && event.key !== ",") return;
+ event.preventDefault();
+ commit();
+ }}
+ onBlur={commit}
+ />
+
+ );
+}
diff --git a/packages/ui/src/profiles/esQueryOutputEditors.test.tsx b/packages/ui/src/profiles/esQueryOutputEditors.test.tsx
new file mode 100644
index 00000000..b7b19365
--- /dev/null
+++ b/packages/ui/src/profiles/esQueryOutputEditors.test.tsx
@@ -0,0 +1,171 @@
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it } from "vitest";
+import { EsQuerySortEditor } from "./esQuerySortEditor";
+import { moveSortEntry } from "./esQuerySortModel";
+import { EsQueryOutputEditor } from "./esQueryOutputEditor";
+import { parseCount, pruneEmpty } from "./esQueryOutputModel";
+import type { EsSearch, EsSortBy } from "./esQueryBuilderModel";
+import type { EsFieldMapping } from "./esQueryOperators";
+
+const fields: EsFieldMapping[] = [
+ { name: "@timestamp", dataType: "date", searchable: true, aggregatable: true },
+ { name: "message", dataType: "text", searchable: true, aggregatable: false },
+ { name: "level", dataType: "keyword", searchable: true, aggregatable: true },
+];
+
+const renderSort = (sort: EsSortBy[]) =>
+ renderToStaticMarkup(
+ undefined}
+ />,
+ );
+
+/** The opening tag of the element carrying `ariaLabel`, attributes and all. */
+const openingTag = (html: string, ariaLabel: string): string => {
+ const at = html.indexOf(`aria-label="${ariaLabel}"`);
+ expect(at, `no element labelled ${ariaLabel}`).toBeGreaterThan(-1);
+ return html.slice(at, html.indexOf(">", at));
+};
+
+const renderOutput = (search: EsSearch) =>
+ renderToStaticMarkup(
+ undefined} />,
+ );
+
+describe("reordering sort entries", () => {
+ const sort: EsSortBy[] = [
+ { field: "@timestamp" },
+ { field: "level" },
+ { field: "_score" },
+ ];
+
+ it("swaps an entry with the one before it", () => {
+ expect(moveSortEntry(sort, 1, -1).map((entry) => entry.field)).toEqual([
+ "level",
+ "@timestamp",
+ "_score",
+ ]);
+ });
+
+ it("swaps an entry with the one after it", () => {
+ expect(moveSortEntry(sort, 0, 1).map((entry) => entry.field)).toEqual([
+ "level",
+ "@timestamp",
+ "_score",
+ ]);
+ });
+
+ // Wrapping would silently make the first tie-break the last, which is the
+ // opposite of what a click on a disabled-looking arrow should do.
+ it("clamps at both ends rather than wrapping", () => {
+ expect(moveSortEntry(sort, 0, -1)).toBe(sort);
+ expect(moveSortEntry(sort, 2, 1)).toBe(sort);
+ });
+
+ it("leaves the entries it did not move alone", () => {
+ expect(moveSortEntry(sort, 1, -1)[2]).toBe(sort[2]);
+ });
+});
+
+describe("the sort editor", () => {
+ it("renders one field and order control per sort entry", () => {
+ const html = renderSort([
+ { field: "@timestamp", order: "desc" },
+ { field: "level" },
+ ]);
+ expect(html.match(/aria-label="Sort field"/g)).toHaveLength(2);
+ expect(html).toContain('value="@timestamp"');
+ expect(html).toContain('desc ');
+ });
+
+ it("says plainly when nothing sorts the hits", () => {
+ expect(renderSort([])).toContain("Unsorted");
+ expect(renderSort([{ field: "level" }])).not.toContain("Unsorted");
+ });
+
+ it("names each move control after the field it moves", () => {
+ const html = renderSort([{ field: "@timestamp" }, { field: "level" }]);
+ expect(html).toContain('aria-label="Move @timestamp later"');
+ expect(html).toContain('aria-label="Move level earlier"');
+ expect(html).toContain('aria-label="Remove level"');
+ });
+
+ it("disables the move that would run off either end", () => {
+ const html = renderSort([{ field: "@timestamp" }, { field: "level" }]);
+ // The bare word also appears in Tailwind's disabled: variants, so this has
+ // to look for the attribute itself.
+ expect(openingTag(html, "Move @timestamp earlier")).toContain('disabled=""');
+ expect(openingTag(html, "Move level later")).toContain('disabled=""');
+ expect(openingTag(html, "Move @timestamp later")).not.toContain('disabled=""');
+ expect(openingTag(html, "Move level earlier")).not.toContain('disabled=""');
+ });
+});
+
+describe("pruning an empty sub-object", () => {
+ it("keeps a sub-object that still says something", () => {
+ expect(pruneEmpty({ enabled: false })).toEqual({ enabled: false });
+ expect(pruneEmpty({ includes: ["user.*"] })).toEqual({
+ includes: ["user.*"],
+ });
+ });
+
+ // Storing `{}` would leave a key the compiler has to ignore, so clearing the
+ // last field has to clear the object with it.
+ it("drops one whose every field was cleared", () => {
+ expect(pruneEmpty({ enabled: undefined, includes: [] })).toBeUndefined();
+ expect(pruneEmpty({})).toBeUndefined();
+ });
+});
+
+describe("reading a count", () => {
+ it.each([
+ ["25", 25],
+ ["0", 0],
+ ])("accepts %s as %i", (raw, expected) => {
+ expect(parseCount(raw)).toBe(expected);
+ });
+
+ it.each(["", " ", "-1", "1.5", "many"])("treats %j as unset", (raw) => {
+ expect(parseCount(raw)).toBeUndefined();
+ });
+});
+
+describe("the output editor", () => {
+ it("shows where the hits start and the _source controls", () => {
+ const html = renderOutput({ from: 20 });
+ expect(html).toContain('aria-label="From"');
+ expect(html).toContain('value="20"');
+ expect(html).toContain('aria-label="Add includes pattern"');
+ expect(html).toContain('aria-label="Add excludes pattern"');
+ });
+
+ // How many rows come back is the query's Limit, edited beside the filters.
+ // A second control here would be a row cap that can disagree with the one the
+ // raw query also honours.
+ it("leaves the row count to the query's own limit", () => {
+ expect(renderOutput({ size: 100 })).not.toContain('aria-label="Size"');
+ });
+
+ it("renders each stored _source pattern as a removable chip", () => {
+ const html = renderOutput({ source: { includes: ["user.*", "@timestamp"] } });
+ expect(html.match(/es-pattern-chip/g)).toHaveLength(2);
+ expect(html).toContain('aria-label="Remove user.*"');
+ });
+
+ // With _source off there is nothing to include or exclude, so the pattern
+ // lists would only collect values the backend never reads.
+ it("hides the pattern lists once _source is turned off", () => {
+ const html = renderOutput({ source: { enabled: false } });
+ expect(html).not.toContain('aria-label="Add includes pattern"');
+ });
+
+ it("asks for a threshold only while total hits are tracked", () => {
+ expect(renderOutput({})).not.toContain('aria-label="Total hits threshold"');
+ expect(renderOutput({ trackTotalHits: { enabled: true } })).toContain(
+ 'aria-label="Total hits threshold"',
+ );
+ });
+});
diff --git a/packages/ui/src/profiles/esQueryOutputModel.ts b/packages/ui/src/profiles/esQueryOutputModel.ts
new file mode 100644
index 00000000..32d4919e
--- /dev/null
+++ b/packages/ui/src/profiles/esQueryOutputModel.ts
@@ -0,0 +1,25 @@
+/**
+ * What a matching document comes back as: where the hits start, which _source
+ * fields travel, and whether the backend counts past its default. How many hits
+ * come back is the query's Limit, edited next to the filters — one row cap, so
+ * that it cannot disagree with itself across the form and the raw DSL.
+ */
+
+
+/**
+ * pruneEmpty drops a sub-object that says nothing, so clearing the last field of
+ * `source` removes `source` rather than storing `{}` the compiler must ignore.
+ */
+export function pruneEmpty(value: T): T | undefined {
+ const said = Object.values(value).some((entry) =>
+ Array.isArray(entry) ? entry.length > 0 : entry !== undefined && entry !== null,
+ );
+ return said ? value : undefined;
+}
+
+/** parseCount reads a non-negative integer, treating anything else as unset. */
+export function parseCount(raw: string): number | undefined {
+ if (raw.trim() === "") return undefined;
+ const parsed = Number(raw);
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined;
+}
diff --git a/packages/ui/src/profiles/esQueryPreview.tsx b/packages/ui/src/profiles/esQueryPreview.tsx
new file mode 100644
index 00000000..d5dc8974
--- /dev/null
+++ b/packages/ui/src/profiles/esQueryPreview.tsx
@@ -0,0 +1,45 @@
+/**
+ * The DSL a specification compiles to. The server compiles it — the same code
+ * path a query runs through — so the preview is the query, not a re-derivation
+ * of it that could drift.
+ */
+
+import type { EsCompilation } from "./esQueryCompile";
+
+export function EsQueryPreview({
+ compilation,
+ className
+}: {
+ compilation: EsCompilation;
+ className?: string;
+}) {
+ const { query, size, from, error, loading } = compilation;
+ return (
+
+
+ Compiled DSL
+ {size === undefined ? null : (
+
+ size {size}
+ {from ? ` · from ${from}` : ""}
+
+ )}
+ {loading ? (
+ compiling…
+ ) : null}
+
+ {error ? (
+
+ {error}
+
+ ) : (
+
+ {query}
+
+ )}
+
+ );
+}
diff --git a/packages/ui/src/profiles/esQuerySortEditor.tsx b/packages/ui/src/profiles/esQuerySortEditor.tsx
new file mode 100644
index 00000000..aaed5550
--- /dev/null
+++ b/packages/ui/src/profiles/esQuerySortEditor.tsx
@@ -0,0 +1,136 @@
+/**
+ * Multi-field sort. The order of the entries is the tie-break order the backend
+ * applies, so moving an entry is an edit in its own right rather than cosmetic.
+ */
+
+import { Combobox } from "../components/Combobox";
+import { IconButton } from "../components/IconButton";
+import { InputField } from "../components/InputField";
+import { Button } from "../components/button";
+import { Select } from "../components/select";
+import { UiAdd, UiArrowDown, UiArrowUp, UiTrash } from "../icons";
+import type { EsSortBy } from "./esQueryBuilderModel";
+import { applyPatch, type Patch } from "./profileWizardModel";
+import { sortableFields, type EsFieldMapping } from "./esQueryOperators";
+import { moveSortEntry } from "./esQuerySortModel";
+
+export function EsQuerySortEditor({
+ sort,
+ fields,
+ orders,
+ onChange,
+}: {
+ sort: EsSortBy[];
+ fields: EsFieldMapping[];
+ orders: string[];
+ onChange: (sort: EsSortBy[]) => void;
+}) {
+ const options = sortableFields(fields).map((name) => ({
+ value: name,
+ label: name,
+ }));
+ const set = (index: number, patch: Patch) =>
+ onChange(
+ sort.map((entry, position) =>
+ position === index ? applyPatch(entry, patch) : entry,
+ ),
+ );
+ const text = (value: string | undefined) => value ?? "";
+
+ return (
+
+
+ Sort
+
+ onChange([
+ ...sort,
+ // An empty vocabulary leaves the order unset, which is the same
+ // as the backend's default — not an entry with order: undefined.
+ { field: "", ...(orders[0] ? { order: orders[0] } : {}) },
+ ])
+ }
+ >
+ Sort field
+
+
+ {sort.length === 0 ? (
+
+ Unsorted — hits come back in the backend's own order.
+
+ ) : null}
+ {sort.map((entry, index) => (
+
+ set(index, { field: next })}
+ options={options}
+ placeholder="Field…"
+ allowCustomValue
+ />
+
+ ({ value: order, label: order })),
+ ]}
+ onChange={(event) =>
+ set(index, { order: event.target.value || undefined })
+ }
+ />
+
+ set(index, { mode: next || undefined })}
+ />
+ set(index, { missing: next || undefined })}
+ />
+ set(index, { unmappedType: next || undefined })}
+ />
+ onChange(moveSortEntry(sort, index, -1))}
+ />
+ onChange(moveSortEntry(sort, index, 1))}
+ />
+
+ onChange(sort.filter((_entry, position) => position !== index))
+ }
+ />
+
+ ))}
+
+ );
+}
diff --git a/packages/ui/src/profiles/esQuerySortModel.ts b/packages/ui/src/profiles/esQuerySortModel.ts
new file mode 100644
index 00000000..8357f60a
--- /dev/null
+++ b/packages/ui/src/profiles/esQuerySortModel.ts
@@ -0,0 +1,21 @@
+/**
+ * Multi-field sort. The order of the entries is the tie-break order the backend
+ * applies, so moving an entry is an edit in its own right rather than cosmetic.
+ */
+
+import type { EsSortBy } from "./esQueryBuilderModel";
+
+/** moveSortEntry shifts one entry, clamping at the ends rather than wrapping. */
+export function moveSortEntry(
+ sort: EsSortBy[],
+ index: number,
+ delta: number,
+): EsSortBy[] {
+ const target = index + delta;
+ if (target < 0 || target >= sort.length) return sort;
+ const moved = [...sort];
+ const [entry] = moved.splice(index, 1);
+ if (!entry) throw new Error(`sort entry ${index} does not exist`);
+ moved.splice(target, 0, entry);
+ return moved;
+}
diff --git a/packages/ui/src/profiles/esValueCombobox.test.tsx b/packages/ui/src/profiles/esValueCombobox.test.tsx
new file mode 100644
index 00000000..b55d66fa
--- /dev/null
+++ b/packages/ui/src/profiles/esValueCombobox.test.tsx
@@ -0,0 +1,52 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { renderToStaticMarkup } from "react-dom/server";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const comboboxCalls = vi.hoisted(() => [] as Record[]);
+
+vi.mock("../components/Combobox", () => ({
+ Combobox: (props: Record) => {
+ comboboxCalls.push(props);
+ return ;
+ },
+}));
+
+import { ValuesCombobox } from "./esValueCombobox";
+
+describe("ValuesCombobox", () => {
+ beforeEach(() => {
+ comboboxCalls.length = 0;
+ });
+
+ it("keeps an OpenSearch terms operand creatable while selecting multiple values", () => {
+ const onChange = vi.fn();
+ renderToStaticMarkup(
+
+ ({ values: [], total: 0, scoped: true }),
+ }}
+ values={["payments"]}
+ onChange={onChange}
+ />
+ ,
+ );
+
+ expect(comboboxCalls).toHaveLength(1);
+ expect(comboboxCalls[0]).toMatchObject({
+ multiple: true,
+ variant: "tags",
+ allowCustomValue: true,
+ value: ["payments"],
+ });
+
+ const change = comboboxCalls[0]?.onChange as
+ | ((next: string[]) => void)
+ | undefined;
+ expect(change).toBeDefined();
+ change?.(["payments", "custom-service"]);
+ expect(onChange).toHaveBeenCalledWith(["payments", "custom-service"]);
+ });
+});
diff --git a/packages/ui/src/profiles/esValueCombobox.tsx b/packages/ui/src/profiles/esValueCombobox.tsx
new file mode 100644
index 00000000..e963e0c3
--- /dev/null
+++ b/packages/ui/src/profiles/esValueCombobox.tsx
@@ -0,0 +1,110 @@
+/**
+ * The operand controls backed by a field's real values. Typing narrows the
+ * terms aggregation server-side rather than filtering a fetched page, so a
+ * field with thousands of values stays usable; a value outside the returned
+ * window is still typeable, which is what `allowCustomValue` stands for.
+ */
+
+import { Combobox } from "../components/Combobox";
+import type { ComboboxOption } from "../components/Combobox";
+import { keepPreviousData, useQuery } from "@tanstack/react-query";
+import { useState } from "react";
+import type { FieldValuesQuery, FieldValuesResult } from "./esFieldValues";
+
+function useFieldValues(lookup: FieldValuesQuery) {
+ const [query, setQuery] = useState("");
+ const result = useQuery({
+ queryKey: ["es-field-values", lookup.key, query],
+ queryFn: () => lookup.fetch(query),
+ placeholderData: keepPreviousData,
+ staleTime: 30_000,
+ retry: 0,
+ });
+ return { ...result, onSearch: setQuery };
+}
+
+function valueOptions(result: FieldValuesResult | undefined): ComboboxOption[] {
+ return (result?.values ?? []).map((entry) => ({
+ value: entry.value,
+ label: entry.value,
+ title: `${entry.count.toLocaleString()} documents`,
+ }));
+}
+
+/**
+ * The summary explains what the list is a window onto: how much of the field's
+ * cardinality is shown, and whether the rest of the query narrowed it.
+ */
+function valueSummary(
+ result: FieldValuesResult | undefined,
+ error: Error | null,
+): string | undefined {
+ if (error) return error.message;
+ if (!result) return undefined;
+ const scope = result.scoped ? "matching current filters" : "across the index";
+ if (!result.values.length) return `no values ${scope}`;
+ return `${result.values.length} of ${result.total.toLocaleString()} · ${scope}`;
+}
+
+export function ValueCombobox({
+ id,
+ label,
+ lookup,
+ value,
+ onChange,
+}: {
+ id: string;
+ label: string;
+ lookup: FieldValuesQuery;
+ value: string;
+ onChange: (next: string) => void;
+}) {
+ const { data, error, isFetching, onSearch } = useFieldValues(lookup);
+ const summary = valueSummary(data, error as Error | null);
+ return (
+
+ );
+}
+
+export function ValuesCombobox({
+ label,
+ lookup,
+ values,
+ onChange,
+}: {
+ label: string;
+ lookup: FieldValuesQuery;
+ values: string[];
+ onChange: (next: string[]) => void;
+}) {
+ const { data, error, isFetching, onSearch } = useFieldValues(lookup);
+ const summary = valueSummary(data, error as Error | null);
+ return (
+
+ );
+}
diff --git a/packages/ui/src/profiles/jsonPathSample.ts b/packages/ui/src/profiles/jsonPathSample.ts
new file mode 100644
index 00000000..a4765739
--- /dev/null
+++ b/packages/ui/src/profiles/jsonPathSample.ts
@@ -0,0 +1,114 @@
+import type { JSONPathEvalResult as JsonPathEvalResult } from "../components/JSONPathPlayground";
+import { useQuery } from "@tanstack/react-query";
+import { createContext, useContext, useMemo } from "react";
+import { fetchJSON } from "./connectionBrowserModel";
+import { profileApiPath } from "./profileApi";
+
+/** Every sampled row in scope; empty where nothing has been sampled. */
+export function useJsonPathSampleRows(): unknown[] {
+ return useContext(JsonPathSampleRowsContext);
+}
+
+export function sampleRequestProfile(profile: unknown): Record | null {
+ if (!isRecord(profile)) return null;
+ const provider = profile.provider;
+ if (!isRecord(provider)) return null;
+ const type = typeof provider.type === "string" ? provider.type.trim() : "";
+ if (!type) return null;
+ const request: Record = {};
+ for (const key of SAMPLE_PROFILE_KEYS) {
+ if (profile[key] !== undefined) request[key] = profile[key];
+ }
+ const name = typeof profile.profile === "string" ? profile.profile.trim() : "";
+ request.profile = name || "sample";
+ return request;
+}
+
+/**
+ * The rows the picker browses: a read-only sample of `profile`.
+ *
+ * A profile that cannot be sampled — no provider yet, or a query the backend
+ * rejects — yields nothing, which leaves JSONPathField's browse button disabled
+ * and the path typed by hand rather than picked.
+ */
+export function useJsonPathSample(profile: unknown): unknown[] {
+ const request = useMemo(() => sampleRequestProfile(profile), [profile]);
+ const { data } = useQuery({
+ queryKey: ["jsonpath-sample", JSON.stringify(request)],
+ enabled: request !== null,
+ // The sample is a query against someone's backend, so it is fetched once per
+ // profile shape and reused by every column's picker rather than re-run as
+ // the form re-renders.
+ staleTime: Infinity,
+ gcTime: 5 * 60 * 1000,
+ retry: false,
+ refetchOnWindowFocus: false,
+ queryFn: async () => {
+ const result = await fetchJSON<{ rows?: Record[] }>(
+ profileApiPath("profile/sample"),
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ profile: request, params: {} }),
+ },
+ );
+ return result.rows ?? EMPTY_ROWS;
+ },
+ });
+ return data ?? EMPTY_ROWS;
+}
+
+/**
+ * Evaluates a JSONPath against one sampled row, server-side.
+ *
+ * The path has to be read by the library the query engine reads it with, or the
+ * preview would confidently disagree with the column it previews. The row goes
+ * up with the request because the caller already has it — re-running someone's
+ * backend query on every keystroke would make the preview cost real money.
+ */
+export function evaluateJsonPath(request: {
+ jsonpath: string;
+ source?: string;
+ row: unknown;
+}): Promise {
+ return fetchJSON(profileApiPath("profile/sample/jsonpath"), {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(request),
+ });
+}
+
+// A stable identity so a component reading "nothing sampled" does not re-render
+// every parent render on a fresh [].
+const EMPTY_ROWS: unknown[] = [];
+
+// The rows the JSONPath picker browses, for the surfaces that cannot ask for
+// them themselves.
+//
+// The entity form's extension reads the profile straight off the form root and
+// samples it directly. The standalone profile editor renders its column
+// inspector far from the draft it is editing, so that page samples once here and
+// every column field reads the result — one request per editor rather than one
+// per column, and no query client needed at the leaf.
+export const JsonPathSampleRowsContext = createContext(EMPTY_ROWS);
+
+// The keys /profile/sample accepts. It decodes with DisallowUnknownFields, so
+// this is a whitelist rather than a tidy-up: one stray key and the whole request
+// is a 400.
+//
+// `columns`, `aliases` and `ignore` are left out deliberately. Those transforms
+// are the thing the author is still writing — a source column gets renamed and
+// consumed by them — and the picker has to offer the provider's own row shape,
+// not the shape a half-written profile projects out of it.
+const SAMPLE_PROFILE_KEYS = [
+ "profile",
+ "provider",
+ "query",
+ "params",
+ "imports",
+ "namespace",
+] as const;
+
+function isRecord(value: unknown): value is Record {
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
+}
diff --git a/packages/ui/src/profiles/jsonPathSampleRow.test.ts b/packages/ui/src/profiles/jsonPathSampleRow.test.ts
new file mode 100644
index 00000000..b9ebe3bb
--- /dev/null
+++ b/packages/ui/src/profiles/jsonPathSampleRow.test.ts
@@ -0,0 +1,69 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { evaluateJsonPath, sampleRequestProfile } from "./jsonPathSample";
+
+const profile = {
+ profile: "orders",
+ namespace: "default",
+ provider: { type: "sql", options: { url: "postgres://localhost/orders" } },
+ query: "SELECT payload FROM orders",
+ params: [{ name: "since", type: "string" }],
+ columns: [{ name: "email", source: "payload", jsonpath: "$.user.email" }],
+ aliases: [{ name: "user", cel: "row.payload.user" }],
+ ignore: ["payload"],
+ render: "logs",
+};
+
+describe("sampleRequestProfile", () => {
+ it("keeps only the keys the sample endpoint accepts", () => {
+ // /profile/sample decodes with DisallowUnknownFields, so anything extra —
+ // `render` here — turns the whole request into a 400.
+ expect(sampleRequestProfile(profile)).toEqual({
+ profile: "orders",
+ namespace: "default",
+ provider: { type: "sql", options: { url: "postgres://localhost/orders" } },
+ query: "SELECT payload FROM orders",
+ params: [{ name: "since", type: "string" }],
+ });
+ });
+
+ it("drops the transforms so the raw provider row is sampled", () => {
+ const request = sampleRequestProfile(profile)!;
+
+ expect(request).not.toHaveProperty("columns");
+ expect(request).not.toHaveProperty("aliases");
+ expect(request).not.toHaveProperty("ignore");
+ });
+
+ it("names an unnamed draft so the handler accepts it", () => {
+ const request = sampleRequestProfile({ ...profile, profile: " " })!;
+
+ expect(request.profile).toBe("sample");
+ });
+
+ it("declines a profile with no provider to sample", () => {
+ expect(sampleRequestProfile({ query: "SELECT 1" })).toBeNull();
+ expect(sampleRequestProfile({ provider: { type: "" }, query: "SELECT 1" })).toBeNull();
+ expect(sampleRequestProfile(undefined)).toBeNull();
+ });
+});
+
+describe("evaluateJsonPath", () => {
+ afterEach(() => vi.unstubAllGlobals());
+
+ it("sends the expression, its root and the row the caller is already holding", async () => {
+ const response = { matches: ["OPEN"], count: 1, filterField: "payload.status" };
+ const fetchMock = vi.fn(async () => new Response(JSON.stringify(response), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ }));
+ vi.stubGlobal("fetch", fetchMock);
+
+ const row = { payload: '{"status":"OPEN"}' };
+ await expect(evaluateJsonPath({ jsonpath: "$.status", source: "payload", row })).resolves.toEqual(response);
+
+ const [url, init] = fetchMock.mock.calls[0]!;
+ expect(url).toBe("/api/v1/profile/sample/jsonpath");
+ expect(init?.method).toBe("POST");
+ expect(JSON.parse(init?.body as string)).toEqual({ jsonpath: "$.status", source: "payload", row });
+ });
+});
diff --git a/packages/ui/src/profiles/jsonPathSampleRow.tsx b/packages/ui/src/profiles/jsonPathSampleRow.tsx
new file mode 100644
index 00000000..b3086d43
--- /dev/null
+++ b/packages/ui/src/profiles/jsonPathSampleRow.tsx
@@ -0,0 +1,18 @@
+import { type ReactNode } from "react";
+import { JsonPathSampleRowsContext, useJsonPathSample } from "./jsonPathSample";
+
+export function JsonPathProfileProvider({
+ profile,
+ children
+}: {
+ profile: unknown;
+ children: ReactNode;
+}) {
+ const rows = useJsonPathSample(profile);
+ return (
+
+ {children}
+
+ );
+}
+
diff --git a/packages/ui/src/profiles/profileApi.ts b/packages/ui/src/profiles/profileApi.ts
new file mode 100644
index 00000000..e466f01e
--- /dev/null
+++ b/packages/ui/src/profiles/profileApi.ts
@@ -0,0 +1,80 @@
+/**
+ * Boot-time configuration: where the profile engine is mounted, and the schema
+ * that describes a profile.
+ *
+ * Both are the host's to supply. The components talk to commons-db's profile
+ * service — sampling a draft, evaluating a JSONPath against a sampled row,
+ * browsing a connection's catalog — and every host mounts that service
+ * somewhere; a library that hardcodes one host's prefix is broken for the next.
+ * The schema is generated from commons-db's Go types, so it ships with the
+ * server, not with this package: vendoring a copy here would silently drift
+ * from the source of truth the server validates against.
+ *
+ * This is module state rather than a React context on purpose: both values are
+ * one per application, fixed before the first render, and read from plain
+ * functions (browserBaseUrl, profileSchemaProjection) as well as components.
+ * Threading a context through the whole tree would buy nothing a single
+ * boot-time call does not.
+ */
+
+import type { JsonSchemaObject } from "../components/json-schema-form-types";
+import { stripTrailingSlashes } from "../lib/string";
+
+/** ProfileSchema is commons-db's profile.json, with its $defs preserved. */
+export type ProfileSchema = JsonSchemaObject & {
+ $defs?: Record;
+};
+
+const DEFAULT_BASE = "/api/v1";
+
+let base = DEFAULT_BASE;
+let schema: ProfileSchema | null = null;
+
+/**
+ * configureProfiles points the components at a mount and gives them the profile
+ * schema. Call it once at startup, before rendering.
+ *
+ * basePath defaults to /api/v1 and may be omitted by a host that mounts there.
+ * schema has no default: the editor cannot describe a profile it has no schema
+ * for, and inventing one would disagree with the server that validates it.
+ */
+export function configureProfiles(options: { basePath?: string; schema: ProfileSchema }): void {
+ if (options.basePath !== undefined) {
+ const trimmed = stripTrailingSlashes(options.basePath.trim());
+ if (!trimmed.startsWith("/")) {
+ throw new Error(
+ `profile API basePath must start with "/", got ${JSON.stringify(options.basePath)}`,
+ );
+ }
+ base = trimmed;
+ }
+ schema = options.schema;
+}
+
+/** profileApiBase is the configured mount point, without a trailing slash. */
+export function profileApiBase(): string {
+ return base;
+}
+
+/**
+ * profileApiPath joins a service-relative path onto the mount point, e.g.
+ * profileApiPath("profile/sample") -> "/api/v1/profile/sample".
+ */
+export function profileApiPath(suffix: string): string {
+ return `${base}/${suffix.replace(/^\/+/, "")}`;
+}
+
+/**
+ * profileSchema is the configured schema. It throws rather than returning an
+ * empty document: a form rendered from a missing schema shows no fields at all,
+ * which reads as "this profile has nothing to configure" instead of as the
+ * setup error it is.
+ */
+export function profileSchema(): ProfileSchema {
+ if (!schema) {
+ throw new Error(
+ "profile schema is not configured — call configureProfiles({ schema }) before rendering the profile editor",
+ );
+ }
+ return schema;
+}
diff --git a/packages/ui/src/profiles/profileBuilder.test.ts b/packages/ui/src/profiles/profileBuilder.test.ts
new file mode 100644
index 00000000..3ef19aed
--- /dev/null
+++ b/packages/ui/src/profiles/profileBuilder.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from "vitest";
+import { profileBuilderModalClassName } from "./profileBuilderWorkspace";
+import { mapTimestampColumn, profileColumnTypeLabel } from "./profileColumnModel";
+
+describe("Build Profile workspace layout", () => {
+ it("bounds the modal body and delegates scrolling to its panes", () => {
+ expect(profileBuilderModalClassName).toContain("h-[calc(100dvh-2rem)]");
+ // The body must shrink and stop owning scroll, and its panes must share the
+ // height. Asserted as utilities because a stylesheet shipped alongside the
+ // library silently does nothing in a consumer that never imports it.
+ expect(profileBuilderModalClassName).toContain(
+ "[&>[data-slot=modal-body]]:min-h-0",
+ );
+ expect(profileBuilderModalClassName).toContain(
+ "[&>[data-slot=modal-body]]:overflow-hidden",
+ );
+ expect(profileBuilderModalClassName).toContain(
+ "[&>[data-slot=modal-body]>*]:flex-1",
+ );
+ });
+});
+
+describe("Build Profile timestamp mapping", () => {
+ it("marks exactly one sampled column as the timestamp date-range column", () => {
+ expect(
+ mapTimestampColumn(
+ [
+ { name: "created_at", type: "string" },
+ { name: "updated_at", type: "datetime", kind: "timestamp" },
+ ],
+ "created_at",
+ ),
+ ).toEqual([
+ { name: "created_at", type: "datetime", kind: "timestamp" },
+ { name: "updated_at", type: "datetime" },
+ ]);
+ });
+});
+
+describe("Build Profile structured type labels", () => {
+ it("uses readable labels without changing serialized values", () => {
+ expect(profileColumnTypeLabel("key_value")).toBe("KeyValue{}");
+ expect(profileColumnTypeLabel("key_values")).toBe("[]KeyValue");
+ expect(profileColumnTypeLabel("json")).toBe("JSON");
+ expect(profileColumnTypeLabel("duration")).toBe("duration");
+ });
+});
diff --git a/packages/ui/src/profiles/profileBuilder.tsx b/packages/ui/src/profiles/profileBuilder.tsx
new file mode 100644
index 00000000..c28e4f41
--- /dev/null
+++ b/packages/ui/src/profiles/profileBuilder.tsx
@@ -0,0 +1,87 @@
+import { Button } from "../components/button";
+import { Icon } from "../data/Icon";
+import { UiDatabase } from "../icons";
+import {
+ createContext,
+ useContext,
+ useEffect,
+ useRef,
+ useState,
+ type ReactNode
+} from "react";
+import { savedConnectionID } from "./connectionBrowserModel";
+import {
+ ProfileBuilderWorkspace,
+ type ProfileDraft
+} from "./profileBuilderWorkspace";
+
+const ProfileBuilderAutoOpenContext = createContext(false);
+
+export function ProfileBuilderAutoOpen({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function ProfileQueryBuilderField({
+ input,
+ rootValue,
+ onRootChange
+}: {
+ input: ReactNode;
+ rootValue: ProfileDraft;
+ onRootChange?: ((next: Record) => void) | undefined;
+}) {
+ const [open, setOpen] = useState(false);
+ const autoOpen = useContext(ProfileBuilderAutoOpenContext);
+ const autoOpened = useRef(false);
+ const connection = rootValue.provider?.connection ?? "";
+ const connectionID = savedConnectionID(connection);
+
+ useEffect(() => {
+ if (!autoOpen || autoOpened.current || !connectionID || !onRootChange) {
+ return;
+ }
+ autoOpened.current = true;
+ setOpen(true);
+ }, [autoOpen, connectionID, onRootChange]);
+
+ return (
+
+ {input}
+
+ setOpen(true)}
+ >
+
+ Build from connection
+
+ {!connectionID ? (
+
+ Choose a saved connection to browse its catalog and sample rows.
+ Inline URLs can still be configured manually.
+
+ ) : null}
+
+ {open && connectionID && onRootChange ? (
+
setOpen(false)}
+ />
+ ) : null}
+
+ );
+}
diff --git a/packages/ui/src/profiles/profileBuilderExtension.tsx b/packages/ui/src/profiles/profileBuilderExtension.tsx
new file mode 100644
index 00000000..dda69a53
--- /dev/null
+++ b/packages/ui/src/profiles/profileBuilderExtension.tsx
@@ -0,0 +1,32 @@
+/**
+ * The JSON-schema form extension that opens the profile builder for a query
+ * field.
+ *
+ * It lives apart from profileBuilder.tsx because it is not a component, and a
+ * module that exports components must export nothing else for Fast Refresh to
+ * work (react/only-export-components).
+ */
+
+import type { PostExtension } from "../components/json-schema-form-types";
+import { ProfileQueryBuilderField } from "./profileBuilder";
+import type { ProfileDraft } from "./profileBuilderWorkspace";
+
+const profileQueryBuilderPost: PostExtension = (field, nodes, ctx) => {
+ if (field.schema["x-clicky-component"] !== "profile-query-builder") {
+ return nodes;
+ }
+ return {
+ label: nodes.label,
+ value: (
+
+ ),
+ };
+};
+
+export const profileBuilderFormExtensions = {
+ post: [profileQueryBuilderPost],
+};
diff --git a/packages/ui/src/profiles/profileBuilderWorkspace.tsx b/packages/ui/src/profiles/profileBuilderWorkspace.tsx
new file mode 100644
index 00000000..40e98cc2
--- /dev/null
+++ b/packages/ui/src/profiles/profileBuilderWorkspace.tsx
@@ -0,0 +1,428 @@
+import { JsonSchemaForm } from "../components/JsonSchemaForm";
+import { Button } from "../components/button";
+import type { JsonSchemaObject, JsonSchemaProperty } from "../components/json-schema-form-types";
+import { Icon } from "../data/Icon";
+import type { QueryBrowserResult } from "../data/query-browser/QueryBrowser.types";
+import { Modal } from "../overlay/Modal";
+import { UiCheck, UiColumns, UiSqlColumn } from "../icons";
+import { useQuery } from "@tanstack/react-query";
+import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
+import {
+ browserBaseUrl,
+ fetchJSON,
+ mergeProviderOptions,
+ useInspection,
+ type BrowserDescriptor,
+ type ProfileRowLimits,
+} from "./connectionBrowserModel";
+import { ConnectionQueryWorkspace } from "./connectionQueryWorkspace";
+import type { EsSearch } from "./esQueryBuilderModel";
+import {
+ ColumnPicker,
+ type ProfileColumn,
+} from "./profileColumnPicker";
+import { withProfileLimits, type ParamDraft, type ProfileProvider } from "./profileWizardModel";
+import { defaultParamValues, paramRoles } from "./esQueryBuilderForm";
+import { mapTimestampColumn } from "./profileColumnModel";
+
+// Same story as ProfileColumn: one ProfileProvider, defined with the draft
+// model. The copy here had drifted to carry `role`, which the canonical type's
+// index signature already admits.
+export type { ProfileProvider };
+
+export type ProfileDraft = Record & {
+ profile?: string;
+ query?: string;
+ provider?: ProfileProvider;
+ params?: ParamDraft[];
+ columns?: ProfileColumn[];
+ /** The row caps this profile sets for itself; unset ones take their default. */
+ limits?: ProfileRowLimits;
+};
+
+type SampleResult = QueryBrowserResult & {
+ columns: ProfileColumn[];
+ renderedQuery: string;
+};
+
+// Modal's body is a flex child. It must be allowed to shrink and must not own
+// scrolling, otherwise QueryBrowser's intrinsic minimum height expands the
+// whole workspace and pushes the editor/results below the dialog viewport.
+//
+// These are utilities rather than a stylesheet on purpose: a library CSS asset
+// only reaches a consumer that remembers to import it, whereas Tailwind scans
+// this source and folds the rules into the dist/styles.css every consumer
+// already loads.
+export const profileBuilderModalClassName =
+ "h-[calc(100dvh-2rem)] [&>[data-slot=modal-body]]:flex [&>[data-slot=modal-body]]:min-h-0 [&>[data-slot=modal-body]]:overflow-hidden [&>[data-slot=modal-body]>*]:flex-1 [&>[data-slot=modal-body]>*]:h-auto [&>[data-slot=modal-body]>*]:min-h-0";
+
+export function ProfileBuilderWorkspace({
+ connectionID,
+ rootValue,
+ onApply,
+ onClose,
+}: {
+ connectionID: string;
+ rootValue: ProfileDraft;
+ onApply: (next: Record) => void;
+ onClose: () => void;
+}) {
+ const baseUrl = browserBaseUrl(connectionID);
+ const descriptor = useQuery({
+ queryKey: ["profile-builder-descriptor", connectionID],
+ queryFn: () => fetchJSON(baseUrl),
+ retry: 0,
+ });
+ const initialProviderOptions = useMemo(
+ () => ({ ...rootValue.provider?.options }),
+ [rootValue.provider?.options],
+ );
+ const [query, setQuery] = useState(rootValue.query ?? "");
+ const [search, setSearch] = useState(
+ () => initialProviderOptions.search as EsSearch | undefined,
+ );
+ const [params, setParams] = useState(
+ () => rootValue.params ?? [],
+ );
+ const [liveOptions, setLiveOptions] = useState>(
+ initialProviderOptions,
+ );
+ const [catalogOptions, setCatalogOptions] = useState>(
+ {},
+ );
+ const [sampleParams, setSampleParams] = useState>(
+ () => defaultParamValues(params),
+ );
+ const [sampleColumns, setSampleColumns] = useState([]);
+ const [selectedColumns, setSelectedColumns] = useState>(
+ () => new Set(),
+ );
+ const [timestampColumn, setTimestampColumn] = useState(
+ () =>
+ rootValue.columns?.find((column) => column.kind === "timestamp")?.name ??
+ "",
+ );
+ const [selectedDatabase, setSelectedDatabase] = useState("");
+ const [limits, setLimits] = useState(
+ () => rootValue.limits,
+ );
+
+ useEffect(() => {
+ if (!query && descriptor.data?.defaultQuery) {
+ setQuery(descriptor.data.defaultQuery);
+ }
+ }, [descriptor.data?.defaultQuery, query]);
+
+ const explicitTargetKind =
+ liveOptions.targetKind ?? initialProviderOptions.targetKind;
+ const inspection = useInspection({
+ cacheKey: "profile-builder-inspection",
+ id: connectionID,
+ baseUrl,
+ enabled: descriptor.data?.catalog === true,
+ database: selectedDatabase,
+ fallbackDatabase: String(initialProviderOptions.database ?? ""),
+ target: String(liveOptions.index ?? initialProviderOptions.index ?? ""),
+ ...(typeof explicitTargetKind === "string"
+ ? { targetKind: explicitTargetKind }
+ : {}),
+ });
+ const browserOptions = useMemo(
+ () =>
+ mergeProviderOptions({
+ layers: [
+ descriptor.data?.initialOptions,
+ initialProviderOptions,
+ catalogOptions,
+ ],
+ database: inspection.sqlDatabase,
+ keepTargetKind: true,
+ }),
+ [
+ catalogOptions,
+ descriptor.data?.initialOptions,
+ initialProviderOptions,
+ inspection.sqlDatabase,
+ ],
+ );
+ // The specification is authored here, not merged from a layer, so it is
+ // stamped on last — including its absence, which a lower layer would
+ // otherwise reinstate after the author switched back to raw DSL.
+ const effectiveOptions = useCallback(
+ (options: Record) => {
+ const merged = mergeProviderOptions({
+ layers: [initialProviderOptions, catalogOptions, options],
+ database: inspection.sqlDatabase,
+ });
+ if (search) merged.search = search;
+ else delete merged.search;
+ return merged;
+ },
+ [catalogOptions, initialProviderOptions, inspection.sqlDatabase, search],
+ );
+
+ const paramSchema = useMemo(() => sampleParamSchema(params), [params]);
+ const existingColumns = rootValue.columns ?? [];
+ const existingNames = useMemo(
+ () => new Set(existingColumns.map((column) => column.name)),
+ [existingColumns],
+ );
+
+ const applyDraft = (mode: "query" | "merge" | "replace") => {
+ const chosen = mapTimestampColumn(
+ sampleColumns.filter((column) => selectedColumns.has(column.name)),
+ timestampColumn,
+ );
+ let columns = existingColumns;
+ if (mode === "merge") {
+ columns = mapTimestampColumn(
+ [
+ ...existingColumns,
+ ...chosen.filter((column) => !existingNames.has(column.name)),
+ ],
+ timestampColumn,
+ );
+ } else if (mode === "replace") {
+ if (
+ existingColumns.length > 0 &&
+ !window.confirm(
+ `Replace ${existingColumns.length} configured column${existingColumns.length === 1 ? "" : "s"}?`,
+ )
+ ) {
+ return;
+ }
+ columns = chosen;
+ }
+ const next: ProfileDraft = withProfileLimits(
+ {
+ ...rootValue,
+ query,
+ params,
+ provider: {
+ ...rootValue.provider,
+ options: effectiveOptions(liveOptions),
+ },
+ ...(mode === "query" ? {} : { columns }),
+ },
+ limits,
+ );
+ onApply(next);
+ onClose();
+ };
+
+ const footer = (
+
+
+ Cancel
+
+ applyDraft("query")}
+ >
+
+ Use query
+
+ applyDraft("merge")}
+ >
+
+ Merge selected
+
+ applyDraft("replace")}
+ >
+
+ Replace columns
+
+
+ );
+
+ return (
+
+
+ {Object.keys(paramSchema.properties ?? {}).length > 0 ? (
+
+
+ Temporary sample parameters (not saved)
+
+
+
+ ) : null}
+ {descriptor.isLoading ? (
+
Loading connection browser…
+ ) : descriptor.isError ? (
+
+ {errorMessage(
+ descriptor.error,
+ "Unable to load this connection browser",
+ )}
+
+ ) : descriptor.data ? (
+
{
+ setSearch(transition.search);
+ setQuery(transition.query);
+ }}
+ {...(limits ? { limits } : {})}
+ onLimitsChange={setLimits}
+ params={params}
+ onParamMappingChange={(edit) => {
+ setSearch(edit.search);
+ setParams(edit.params);
+ }}
+ paramValues={sampleParams}
+ paramRoles={paramRoles(params)}
+ compileBaseUrl={baseUrl}
+ className="h-full min-h-0"
+ onCatalogSelect={(node) => {
+ if (node.query) setQuery(node.query);
+ const nextOptions = node.options ?? {};
+ setCatalogOptions(nextOptions);
+ setLiveOptions({ ...browserOptions, ...nextOptions });
+ }}
+ execute={async (request) => {
+ const result = await fetchJSON(
+ "/api/v1/profile/sample",
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ profile: {
+ ...rootValue,
+ params,
+ profile: rootValue.profile || "sample",
+ query: request.query,
+ provider: {
+ ...rootValue.provider,
+ options: effectiveOptions(request.options),
+ },
+ },
+ params: sampleParams,
+ ...(request.pagination
+ ? { pagination: request.pagination }
+ : {}),
+ ...(request.debug ? { debug: true } : {}),
+ }),
+ },
+ );
+ setSampleColumns(result.columns ?? []);
+ setSelectedColumns(
+ new Set((result.columns ?? []).map((column) => column.name)),
+ );
+ return result;
+ }}
+ renderResults={({ defaultView }) => (
+
+
{defaultView}
+ {sampleColumns.length > 0 ? (
+
+ ) : null}
+
+ )}
+ />
+ ) : (
+
+ This saved connection does not expose a query browser.
+
+ )}
+
+
+ );
+}
+
+function WorkspaceMessage({
+ children,
+ error = false,
+}: {
+ children: ReactNode;
+ error?: boolean;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+function sampleParamSchema(params: ParamDraft[]): JsonSchemaObject {
+ const properties: Record = {};
+ const required: string[] = [];
+ for (const param of params) {
+ const name = param.name?.trim();
+ if (!name) continue;
+ const property: JsonSchemaProperty = {
+ title: param.label || name,
+ ...(param.description ? { description: param.description } : {}),
+ ...(param.default !== undefined ? { default: param.default } : {}),
+ };
+ switch (param.type) {
+ case "number":
+ property.type = "number";
+ break;
+ case "boolean":
+ property.type = "boolean";
+ break;
+ case "date":
+ property.type = "string";
+ property.format = "date-time";
+ break;
+ default:
+ property.type = "string";
+ }
+ if (param.options?.length) property.enum = param.options;
+ properties[name] = property;
+ if (param.required) required.push(name);
+ }
+ return {
+ type: "object",
+ properties,
+ ...(required.length ? { required } : {}),
+ };
+}
+
+function errorMessage(error: unknown, fallback: string): string {
+ return error instanceof Error && error.message.trim()
+ ? error.message.trim()
+ : fallback;
+}
diff --git a/packages/ui/src/profiles/profileColumnFilter.test.ts b/packages/ui/src/profiles/profileColumnFilter.test.ts
new file mode 100644
index 00000000..cf4badbb
--- /dev/null
+++ b/packages/ui/src/profiles/profileColumnFilter.test.ts
@@ -0,0 +1,112 @@
+import { renderToStaticMarkup } from "react-dom/server";
+import { createElement } from "react";
+import { describe, expect, it } from "vitest";
+import { ProfileFieldEditorForm } from "./profileFieldEditor";
+import {
+ inferredFilterKind,
+ patchColumnFilter,
+ patchProfileField,
+ PROFILE_FILTER_DEFAULT_LIMIT,
+ type ProfileColumn,
+} from "./profileWizardModel";
+
+describe("patchColumnFilter", () => {
+ it("merges one knob without disturbing the others", () => {
+ expect(patchColumnFilter({ kind: "terms", limit: 10 }, { multi: false })).toEqual({
+ kind: "terms",
+ limit: 10,
+ multi: false,
+ });
+ });
+
+ // The distinction the server reads: an absent block means "infer this", an
+ // empty one would mean "override it with nothing".
+ it("drops the block once the last knob is cleared", () => {
+ expect(patchColumnFilter({ limit: 10 }, { limit: undefined })).toBeUndefined();
+ });
+
+ it("drops a block that was never anything", () => {
+ expect(patchColumnFilter(undefined, { field: undefined })).toBeUndefined();
+ });
+
+ it("creates the block on the first knob set", () => {
+ expect(patchColumnFilter(undefined, { limit: 25 })).toEqual({ limit: 25 });
+ });
+
+ // false and 0 are values an author chose; only undefined means "unset".
+ it("keeps a knob deliberately turned off", () => {
+ expect(patchColumnFilter(undefined, { lookup: false })).toEqual({ lookup: false });
+ });
+});
+
+describe("inferredFilterKind", () => {
+ it.each([
+ ["number", "range"],
+ ["duration", "range"],
+ ["bytes", "range"],
+ ["datetime", "time"],
+ ["boolean", "boolean"],
+ ["json", "none"],
+ ["key_values", "none"],
+ ["string", "terms"],
+ ["status", "terms"],
+ [undefined, "terms"],
+ ])("reads %s as %s, matching the server", (type, expected) => {
+ expect(inferredFilterKind({ name: "c", ...(type ? { type } : {}) })).toBe(expected);
+ });
+});
+
+describe("the filter block survives editing the rest of the column", () => {
+ it("is untouched by a label edit", () => {
+ const column: ProfileColumn = {
+ name: "tenant",
+ type: "string",
+ filter: { limit: 10, lookup: true },
+ };
+ expect(patchProfileField(column, { label: "Tenant" }).filter).toEqual({
+ limit: 10,
+ lookup: true,
+ });
+ });
+});
+
+describe("the column inspector", () => {
+ const render = (field: ProfileColumn) =>
+ renderToStaticMarkup(createElement(ProfileFieldEditorForm, { field, onChange: () => {} }));
+
+ it("offers the lookup limit for a value selection", () => {
+ const markup = render({ name: "tenant", type: "string" });
+
+ expect(markup).toContain("Values offered");
+ // Blank means the server's default, so the placeholder has to name it.
+ expect(markup).toContain(`placeholder="${PROFILE_FILTER_DEFAULT_LIMIT}"`);
+ expect(markup).toContain(`top ${PROFILE_FILTER_DEFAULT_LIMIT}`);
+ });
+
+ // A range is typed rather than picked, so a cap on a list it does not have
+ // would be a control with nothing behind it.
+ it("offers no lookup limit for a range", () => {
+ expect(render({ name: "latency_ms", type: "number" })).not.toContain("Values offered");
+ });
+
+ it("shows a declared limit in the collapsed summary", () => {
+ expect(render({ name: "tenant", type: "string", filter: { limit: 7 } })).toContain("top 7");
+ });
+
+ it("reports a filter turned off without claiming a control", () => {
+ const markup = render({ name: "tenant", type: "string", filter: { disabled: true } });
+ expect(markup).toContain(">off<");
+ });
+
+ // Enumerated values are the answer a lookup would fetch, so the two cannot
+ // both be on — and a disabled checkbox says so better than a silent override.
+ it("disables the lookup toggle once values are listed", () => {
+ const markup = render({
+ name: "tenant",
+ type: "string",
+ filter: { options: ["prod", "dev"] },
+ });
+ expect(markup).toContain("Values are listed above");
+ expect(markup).toContain("prod, dev");
+ });
+});
diff --git a/packages/ui/src/profiles/profileColumnModel.ts b/packages/ui/src/profiles/profileColumnModel.ts
new file mode 100644
index 00000000..1f265b24
--- /dev/null
+++ b/packages/ui/src/profiles/profileColumnModel.ts
@@ -0,0 +1,34 @@
+
+// One ProfileColumn, defined where the draft model is. The picker used to keep
+// a narrower copy of the same shape; two definitions of one concept drifted
+// apart unnoticed while they lived in separate modules and only collided once
+// the package exported both.
+import type { ProfileColumn } from "./profileWizardModel";
+
+export function profileColumnTypeLabel(type?: string) {
+ return type ? (PROFILE_COLUMN_TYPE_LABELS[type] ?? type) : "string";
+}
+
+/**
+ * mapTimestampColumn marks the chosen column as the profile's time range and
+ * clears the mark from whichever column previously held it.
+ */
+export function mapTimestampColumn(
+ columns: ProfileColumn[],
+ timestampColumn: string,
+): ProfileColumn[] {
+ return columns.map((column) => {
+ if (column.name === timestampColumn) {
+ return { ...column, type: "datetime", kind: "timestamp" };
+ }
+ if (column.kind !== "timestamp") return column;
+ const { kind: _kind, ...rest } = column;
+ return rest;
+ });
+}
+
+const PROFILE_COLUMN_TYPE_LABELS: Record = {
+ key_value: "KeyValue{}",
+ key_values: "[]KeyValue",
+ json: "JSON"
+};
diff --git a/packages/ui/src/profiles/profileColumnPicker.tsx b/packages/ui/src/profiles/profileColumnPicker.tsx
new file mode 100644
index 00000000..462f13c0
--- /dev/null
+++ b/packages/ui/src/profiles/profileColumnPicker.tsx
@@ -0,0 +1,82 @@
+import { Icon } from "../data/Icon";
+import { UiSqlColumn } from "../icons";
+
+// One ProfileColumn, defined where the draft model is. The picker used to keep
+// a narrower copy of the same shape; two definitions of one concept drifted
+// apart unnoticed while they lived in separate modules and only collided once
+// the package exported both.
+import type { ProfileColumn } from "./profileWizardModel";
+import { profileColumnTypeLabel } from "./profileColumnModel";
+export type { ProfileColumn };
+
+
+export function ColumnPicker({
+ columns,
+ selected,
+ existing,
+ onChange,
+ timestampColumn,
+ onTimestampColumnChange,
+}: {
+ columns: ProfileColumn[];
+ selected: Set;
+ existing: Set;
+ onChange: (next: Set) => void;
+ timestampColumn: string;
+ onTimestampColumnChange: (next: string) => void;
+}) {
+ return (
+
+
+
+ Columns from sample
+
+
+
+ );
+}
+
diff --git a/packages/ui/src/profiles/profileEditor.tsx b/packages/ui/src/profiles/profileEditor.tsx
new file mode 100644
index 00000000..e140ac9d
--- /dev/null
+++ b/packages/ui/src/profiles/profileEditor.tsx
@@ -0,0 +1,472 @@
+import { Button } from "../components/button";
+import { Workspace } from "../layout/Workspace";
+import type { WorkspacePaneSpec } from "../layout/Workspace";
+import { Modal } from "../overlay/Modal";
+import { useOperationLookupFetcher } from "../rpc/operationLookupFetcher";
+import type { ResolvedOperation } from "../rpc/types";
+import type { OperationsApiClient } from "../rpc/useOperations";
+import { UiColumns, UiListTree, UiSliders, UiTable } from "../icons";
+import { lazy, Suspense, useEffect, useMemo, useState } from "react";
+import {
+ cloneProfileDraft,
+ mergeSampledProfileColumns,
+ profileAdvancedKeys,
+ profileColumnResetState,
+ profileEditorSectionStatus,
+ profileEditorSections,
+ profileSampleSignature,
+ profileUpdateConflictTarget,
+ resetProfileColumns,
+ validateProfileEditorDraft,
+ type ProfileEditorSection,
+} from "./profileEditorModel";
+import { ProfileEditorPreview } from "./profileEditorPreview";
+import { ProfileEditorRail } from "./profileEditorRail";
+import {
+ ProfileGeneralSection,
+ ProfileSchemaSection,
+ ProfileSourceSection,
+} from "./profileEditorSections";
+import {
+ ProfileFieldEditorActions,
+ ProfileFieldEditorForm,
+ profileFieldEditorEmptyMessage,
+} from "./profileFieldEditor";
+import { ProfileFieldFilters, ProfileFieldGrid } from "./profileFieldGrid";
+import { useProfileFieldState } from "./profileFieldState";
+import type { ProfileSample } from "./profileWizardQueryStep";
+import { JsonPathProfileProvider } from "./jsonPathSampleRow";
+import type { ProfileColumn, ProfileWizardDraft } from "./profileWizardModel";
+import { resolveProfileUpdatePath } from "./profileEditorRoutes";
+
+const ProfileEditorRaw = lazy(() =>
+ import("./profileEditorRaw").then((module) => ({
+ default: module.ProfileEditorRaw,
+ })),
+);
+
+/**
+ * The profile editor as a route rather than a dialog.
+ *
+ * Six sections, ~130 discoverable fields and a CEL editor per column outgrew a
+ * modal: the layout is a clicky-ui Workspace, so the section rail, the column
+ * grid, the field inspector and the sampled preview are panes the user can
+ * resize, collapse and keep across visits.
+ */
+export function ProfileEditor({
+ client,
+ action,
+ surfaceKey,
+ initialValue,
+ onClose,
+ onSuccess,
+}: {
+ client: OperationsApiClient;
+ action: ResolvedOperation;
+ surfaceKey: string;
+ initialValue: Record;
+ onClose: () => void;
+ onSuccess: (name: string) => void | Promise;
+}) {
+ const lookupFetcher = useOperationLookupFetcher(client);
+ const initialDraft = useMemo(() => cloneProfileDraft(initialValue), [initialValue]);
+ const initialSerialized = useMemo(() => JSON.stringify(initialDraft), [initialDraft]);
+ const [draft, setDraft] = useState(initialDraft);
+ const [section, setSection] = useState("general");
+ const [discovered, setDiscovered] = useState(initialDraft.columns ?? []);
+ const [sampledColumns, setSampledColumns] = useState([]);
+ const [sampleRows, setSampleRows] = useState[]>([]);
+ const [activeField, setActiveField] = useState(initialDraft.columns?.[0]?.name ?? "");
+ const [lastSampleSignature, setLastSampleSignature] = useState(() => profileSampleSignature(initialDraft));
+ const [rawValid, setRawValid] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState("");
+ const [replaceTarget, setReplaceTarget] = useState("");
+ const [confirmDiscard, setConfirmDiscard] = useState(false);
+ const [confirmResetColumns, setConfirmResetColumns] = useState(false);
+ const dirty = JSON.stringify(draft) !== initialSerialized;
+ const validationError = validateProfileEditorDraft(draft);
+ const sampleStale = profileSampleSignature(draft) !== lastSampleSignature;
+ const resetState = profileColumnResetState({
+ providerType: draft.provider?.type ?? "",
+ sampledColumnCount: sampledColumns.length,
+ sampleStale,
+ });
+
+ const fields = useProfileFieldState({
+ discovered,
+ configured: draft.columns ?? [],
+ activeName: activeField,
+ onConfiguredChange: (columns) => setDraft((current) => ({ ...current, columns })),
+ onActiveNameChange: setActiveField,
+ });
+
+ // A route can be refreshed or navigated away from; the dialog used to guard
+ // unsaved edits with confirmClose, so the route has to guard them too.
+ useEffect(() => {
+ if (!dirty) return;
+ const warn = (event: BeforeUnloadEvent) => event.preventDefault();
+ window.addEventListener("beforeunload", warn);
+ return () => window.removeEventListener("beforeunload", warn);
+ }, [dirty]);
+
+ const save = async (replaceExisting = false) => {
+ if (validationError) {
+ setError(validationError);
+ return;
+ }
+ if (!client.submitForm) {
+ setError("Profile updates are unavailable");
+ return;
+ }
+ setSaving(true);
+ setError("");
+ try {
+ const body = {
+ ...draft,
+ id: surfaceKey,
+ ...(replaceExisting ? { replaceExisting: true } : {}),
+ };
+ const response = await client.submitForm(
+ resolveProfileUpdatePath(action.path, surfaceKey),
+ action.method,
+ body,
+ );
+ if (!response.success) {
+ const message = response.error || response.message || "Profile update failed";
+ const target = profileUpdateConflictTarget(message);
+ if (target && !replaceExisting) {
+ setReplaceTarget(target);
+ return;
+ }
+ throw new Error(message);
+ }
+ await onSuccess(draft.profile!.trim());
+ } catch (saveError) {
+ setError(saveError instanceof Error ? saveError.message : "Profile update failed");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const acceptSample = ({ columns, rows, sourceDraft }: ProfileSample) => {
+ setSampledColumns(structuredClone(columns));
+ setDiscovered(columns);
+ setSampleRows(rows);
+ setDraft((current) => ({
+ ...current,
+ columns: mergeSampledProfileColumns(current.columns ?? [], columns),
+ }));
+ setActiveField((current) => current || columns[0]?.name || "");
+ setLastSampleSignature(profileSampleSignature(sourceDraft));
+ };
+
+ const resetColumns = () => {
+ const first = sampledColumns[0];
+ if (!first) throw new Error("cannot reset columns without a sample");
+ setDraft((current) => resetProfileColumns(current, sampledColumns));
+ setActiveField(first.name);
+ setConfirmResetColumns(false);
+ };
+
+ const sectionContent = (
+
+ {section === "general" ?
: null}
+ {section === "source" ? (
+
+ ) : null}
+ {section === "parameters" ? (
+
+ ) : null}
+ {section === "advanced" ? (
+
+ ) : null}
+ {section === "raw" ? (
+
Loading YAML editor…}>
+
+
+ ) : null}
+
+ );
+
+ const panes = useMemo(() => {
+ const rail: WorkspacePaneSpec = {
+ id: "sections",
+ label: "Profile",
+ icon: ,
+ location: "left",
+ width: 240,
+ minWidth: 180,
+ maxWidth: 340,
+ content: (
+
+ ),
+ };
+
+ if (section !== "columns") {
+ return [rail, { id: "section", label: sectionLabel(section), location: "center", collapsible: false, contentClassName: "p-0", content: sectionContent }];
+ }
+
+ return [
+ rail,
+ {
+ id: "columns",
+ label: "Columns",
+ icon: ,
+ location: "center",
+ collapsible: false,
+ contentClassName: "flex flex-col overflow-hidden",
+ slots: {
+ headerTrailing: (
+ <>
+
+ {fields.configuredCount} of {fields.available.length} included
+
+ {resetState.visible ? (
+ setConfirmResetColumns(true)}
+ >
+ Reset columns
+
+ ) : null}
+
+ Add column
+
+ >
+ ),
+ },
+ content: (
+ <>
+
+
+ >
+ ),
+ },
+ {
+ id: "field",
+ label: fields.activeField?.name ?? "Field",
+ icon: ,
+ location: "right",
+ width: 380,
+ minWidth: 300,
+ maxWidth: 560,
+ contentClassName: "flex flex-col overflow-hidden",
+ // The pane header already names the field, so this composes the editor's
+ // parts rather than nesting its carded, self-titling variant.
+ content: fields.activeField ? (
+ <>
+
+
{
+ if (fields.activeField) fields.setFieldSelection(fields.activeField, selected);
+ }}
+ canMoveUp={fields.canMoveUp}
+ canMoveDown={fields.canMoveDown}
+ onMoveUp={() => fields.moveActive(-1)}
+ onMoveDown={() => fields.moveActive(1)}
+ onRemove={fields.removeActive}
+ />
+
+
+ >
+ ) : (
+
+ {profileFieldEditorEmptyMessage}
+
+ ),
+ },
+ {
+ id: "preview",
+ label: "Preview",
+ icon: ,
+ location: "bottom",
+ height: 220,
+ minHeight: 120,
+ maxHeight: 520,
+ slots: {
+ headerTrailing: (
+
+ {sampleRows.length
+ ? `${sampleRows.length} sampled rows · updates as you edit`
+ : "No sample yet"}
+
+ ),
+ },
+ content: ,
+ },
+ ];
+ }, [draft, fields, resetState, sampleRows, sampleStale, section, sectionContent]);
+
+ const leave = () => (dirty ? setConfirmDiscard(true) : onClose());
+
+ return (
+
+
+
+
+
+ Profiles
+
+ /
+
+ {initialDraft.profile ?? surfaceKey}
+
+ /
+ Edit
+
+ {error ? {error} : null}
+
+ {dirty ? "Unsaved changes" : "No changes"}
+
+
+ Discard
+
+ void save()}
+ >
+ {saving ? "Saving…" : "Save profile"}
+
+
+
+
+
+
+ {confirmDiscard ? (
+ setConfirmDiscard(false)}
+ title="Discard profile changes?"
+ size="sm"
+ footer={
+
+ setConfirmDiscard(false)}>
+ Keep editing
+
+
+ Discard changes
+
+
+ }
+ >
+
+ Your unsaved profile changes will be lost.
+
+
+ ) : null}
+
+ {confirmResetColumns ? (
+ setConfirmResetColumns(false)}
+ title="Reset columns from latest sample?"
+ size="sm"
+ footer={
+
+ setConfirmResetColumns(false)}>
+ Cancel
+
+
+ Reset columns
+
+
+ }
+ >
+
+ Replace {fields.configuredCount} configured column{fields.configuredCount === 1 ? "" : "s"} with {sampledColumns.length} column{sampledColumns.length === 1 ? "" : "s"} from the latest sample. Custom labels, expressions, formatting, filtering, ordering, and manually added columns will be removed. The profile will remain unsaved until you save it.
+
+
+ ) : null}
+
+ {replaceTarget ? (
+ setReplaceTarget("")}
+ title={`Replace ${replaceTarget}?`}
+ size="sm"
+ footer={
+
+ setReplaceTarget("")}>Cancel
+ {
+ setReplaceTarget("");
+ void save(true);
+ }}>
+ Replace profile
+
+
+ }
+ >
+
+ A profile named {replaceTarget} already exists. Its definition will be
+ overwritten, the current profile will be renamed, and dependent imports
+ will be updated atomically.
+
+
+ ) : null}
+
+ );
+}
+
+function sectionLabel(section: ProfileEditorSection): string {
+ return profileEditorSections.find((entry) => entry.id === section)!.label;
+}
+
diff --git a/packages/ui/src/profiles/profileEditorModel.test.ts b/packages/ui/src/profiles/profileEditorModel.test.ts
new file mode 100644
index 00000000..b0e2806d
--- /dev/null
+++ b/packages/ui/src/profiles/profileEditorModel.test.ts
@@ -0,0 +1,208 @@
+import type { JsonSchemaObject } from "../components/json-schema-form-types";
+import { beforeAll, describe, expect, it } from "vitest";
+import { configureProfiles } from "./profileApi";
+import { testProfileSchema } from "./testSchema";
+import {
+ mergeProfileProjection,
+ mergeSampledProfileColumns,
+ profileAdvancedKeys,
+ profileEditRoute,
+ profileEditSurfaceKey,
+ profileColumnResetState,
+ profileEditorSections,
+ profileRoute,
+ profileSampleSignature,
+ profileSchemaProjection,
+ profileUpdateConflictTarget,
+ resetProfileColumns,
+ validateProfileEditorDraft,
+} from "./profileEditorModel";
+
+describe("profile editor model", () => {
+ // The schema is the host's to supply; these tests supply a small one so the
+ // projection assertions below have an expected result that is obvious by inspection.
+ beforeAll(() => configureProfiles({ schema: testProfileSchema }));
+
+ it("defines a custom sectioned workspace independent of the schema layout", () => {
+ expect(profileEditorSections.map((section) => section.id)).toEqual([
+ "general",
+ "source",
+ "columns",
+ "parameters",
+ "advanced",
+ "raw",
+ ]);
+ });
+
+ it("merges projected schema fields without dropping opaque profile fields", () => {
+ const draft = {
+ profile: "OS",
+ params: [{ name: "host" }],
+ trace: { interval: "5s" },
+ context: { policy: { query: "select 1" } },
+ };
+
+ expect(
+ mergeProfileProjection(draft, ["params"], {
+ params: [{ name: "namespace" }],
+ }),
+ ).toEqual({
+ profile: "OS",
+ params: [{ name: "namespace" }],
+ trace: { interval: "5s" },
+ context: { policy: { query: "select 1" } },
+ });
+ expect(profileSchemaProjection(["params"]).properties).toHaveProperty("params");
+ });
+
+ // Which presets `use` offers is commons-db's schema to state, and its own
+ // tests assert that. What matters here is that a projection reaches nested
+ // properties intact rather than flattening or dropping them.
+ it("projects the advanced section down to the schema, nested properties included", () => {
+ expect(profileAdvancedKeys).toContain("processors");
+
+ const projection = profileSchemaProjection(profileAdvancedKeys);
+ const processors = projection.properties?.processors as JsonSchemaObject;
+ const use = (processors?.items as JsonSchemaObject)?.properties
+ ?.use as JsonSchemaObject;
+
+ expect(use?.enum).toEqual(["example.processor"]);
+ // An advanced key the schema does not declare contributes nothing, rather
+ // than an empty property the form would render as a blank control.
+ expect(projection.properties).not.toHaveProperty("aliases");
+ // profile and provider are required, but neither is in this projection.
+ expect(projection.required).toEqual([]);
+ });
+
+ it("merges samples by name while retaining configured and missing fields", () => {
+ expect(
+ mergeSampledProfileColumns(
+ [
+ { name: "message", type: "string", label: "Message" },
+ { name: "legacy", type: "string" },
+ ],
+ [
+ { name: "message", type: "json" },
+ { name: "duration", type: "duration" },
+ ],
+ ),
+ ).toEqual([
+ { name: "message", type: "string", label: "Message" },
+ { name: "legacy", type: "string" },
+ { name: "duration", type: "duration" },
+ ]);
+ });
+
+ it("resets configured columns to the latest sample order and metadata", () => {
+ const draft = {
+ profile: "OS",
+ provider: { type: "opensearch" },
+ columns: [
+ { name: "message", type: "string", label: "Message", cel: "message.trim()" },
+ { name: "manual", type: "boolean" },
+ ],
+ output: { unwrap: "hits" },
+ };
+ const sampled = [
+ { name: "duration", type: "number" },
+ { name: "message", type: "json" },
+ ];
+
+ const reset = resetProfileColumns(draft, sampled);
+
+ expect(reset).toEqual({
+ ...draft,
+ columns: sampled,
+ });
+ expect(reset.columns).not.toBe(sampled);
+ expect(reset.columns?.[0]).not.toBe(sampled[0]);
+ expect(() => resetProfileColumns(draft, [])).toThrow(
+ "Cannot reset profile columns without sampled columns",
+ );
+ });
+
+ it("offers reset only for OpenSearch with a current non-empty sample", () => {
+ expect(
+ profileColumnResetState({
+ providerType: "sql",
+ sampledColumnCount: 2,
+ sampleStale: false,
+ }),
+ ).toEqual({ visible: false, disabled: true, title: "" });
+ expect(
+ profileColumnResetState({
+ providerType: "opensearch",
+ sampledColumnCount: 0,
+ sampleStale: false,
+ }),
+ ).toEqual({
+ visible: true,
+ disabled: true,
+ title: "Run a sample before resetting columns",
+ });
+ expect(
+ profileColumnResetState({
+ providerType: "opensearch",
+ sampledColumnCount: 2,
+ sampleStale: true,
+ }),
+ ).toEqual({
+ visible: true,
+ disabled: true,
+ title: "Run another sample for the current source and query",
+ });
+ expect(
+ profileColumnResetState({
+ providerType: "opensearch",
+ sampledColumnCount: 2,
+ sampleStale: false,
+ }),
+ ).toEqual({
+ visible: true,
+ disabled: false,
+ title: "Replace configured columns with the latest sample",
+ });
+ });
+
+ it("tracks source changes and validates only editor-owned invariants", () => {
+ const draft = {
+ profile: "OS",
+ provider: { type: "sql", connection: "connection://db" },
+ query: "select 1",
+ columns: [{ name: "id" }],
+ };
+ expect(profileSampleSignature(draft)).not.toBe(
+ profileSampleSignature({ ...draft, query: "select 2" }),
+ );
+ expect(validateProfileEditorDraft(draft)).toBeNull();
+ expect(
+ validateProfileEditorDraft({
+ ...draft,
+ columns: [{ name: "id" }, { name: "id" }],
+ }),
+ ).toBe('Column name "id" is duplicated');
+ });
+
+ it("derives rename routes and structured conflict targets", () => {
+ expect(profileRoute("Service Logs.v2")).toBe("/profile-service-logs-v2");
+ expect(
+ profileUpdateConflictTarget(
+ 'PROFILE_NAME_CONFLICT: profile "OS" conflicts with existing profile "Linux"',
+ ),
+ ).toBe("Linux");
+ });
+
+ it("round-trips the editor route so a refresh reopens the same profile", () => {
+ const surfaceKey = "profile-service-logs-v2";
+ expect(profileEditRoute(surfaceKey)).toBe("/profile-service-logs-v2/edit");
+ expect(profileEditSurfaceKey(profileEditRoute(surfaceKey))).toBe(surfaceKey);
+ expect(profileEditSurfaceKey("/profile-service-logs-v2/edit/")).toBe(surfaceKey);
+ });
+
+ it("claims only profile edit routes, leaving detail and collection paths alone", () => {
+ expect(profileEditSurfaceKey("/profile-os2")).toBeNull();
+ expect(profileEditSurfaceKey("/profiles/edit")).toBeNull();
+ expect(profileEditSurfaceKey("/connection/edit")).toBeNull();
+ expect(profileEditSurfaceKey("/profile-os2/edit/columns")).toBeNull();
+ });
+});
diff --git a/packages/ui/src/profiles/profileEditorModel.ts b/packages/ui/src/profiles/profileEditorModel.ts
new file mode 100644
index 00000000..3f7abada
--- /dev/null
+++ b/packages/ui/src/profiles/profileEditorModel.ts
@@ -0,0 +1,224 @@
+import type { JsonSchemaObject } from "../components/json-schema-form-types";
+import { stripSurroundingDashes } from "../lib/string";
+import { profileSchema } from "./profileApi";
+import { validateProfileParams } from "./profileParamModel";
+import type { ProfileColumn, ProfileWizardDraft } from "./profileWizardModel";
+
+/**
+ * The schema the raw-YAML editor validates against. It is read through the
+ * configured accessor rather than bundled: the document is generated from
+ * commons-db's Go types and served by the host, so a copy here would drift.
+ */
+export function profileEditorSchema(): JsonSchemaObject {
+ return profileSchema();
+}
+
+export const profileEditorSections = [
+ { id: "general", label: "General", hint: "Name, namespace, render mode" },
+ { id: "source", label: "Source & Query", hint: "Provider, connection, sample" },
+ { id: "columns", label: "Columns", hint: "Fields, labels, expressions" },
+ { id: "parameters", label: "Parameters", hint: "Named query inputs" },
+ { id: "advanced", label: "Advanced", hint: "Imports, aliases, processors, output" },
+ { id: "raw", label: "Raw YAML", hint: "Edit the document directly" },
+] as const;
+
+export type ProfileEditorSection = (typeof profileEditorSections)[number]["id"];
+
+export const profileAdvancedKeys = ["imports", "aliases", "ignore", "processors", "output"];
+
+export type ProfileSectionStatus = {
+ badge?: string | undefined;
+ attention?: boolean | undefined;
+};
+
+/**
+ * Rail annotations per section. The route replaced tabs with a vertical rail,
+ * which has room to say how much each section holds — so a stale sample or an
+ * empty column set is visible without opening the section.
+ */
+export function profileEditorSectionStatus({
+ draft,
+ availableColumns,
+ sampleStale,
+}: {
+ draft: ProfileWizardDraft;
+ availableColumns: number;
+ sampleStale: boolean;
+}): Record {
+ const configured = draft.columns?.length ?? 0;
+ const params = Array.isArray(draft.params) ? draft.params.length : 0;
+ const advanced = profileAdvancedKeys.filter((key) =>
+ Object.prototype.hasOwnProperty.call(draft, key),
+ ).length;
+ return {
+ general: { attention: !draft.profile?.trim() },
+ source: {
+ badge: draft.provider?.type || undefined,
+ attention: sampleStale || !draft.provider?.type?.trim(),
+ },
+ columns: {
+ badge: `${configured}/${Math.max(availableColumns, configured)}`,
+ attention: configured === 0,
+ },
+ parameters: { badge: params ? String(params) : undefined },
+ advanced: { badge: advanced ? String(advanced) : undefined },
+ raw: {},
+ };
+}
+
+export function cloneProfileDraft(
+ value: Record,
+): ProfileWizardDraft {
+ return structuredClone(value) as ProfileWizardDraft;
+}
+
+export function profileSchemaProjection(keys: string[]): JsonSchemaObject {
+ const properties = Object.fromEntries(
+ keys.flatMap((key) => {
+ const property = profileSchema().properties?.[key];
+ return property ? [[key, property]] : [];
+ }),
+ );
+ return {
+ type: "object",
+ properties,
+ required: (profileSchema().required ?? []).filter((key) => keys.includes(key)),
+ };
+}
+
+export function providerOptionsSchema(providerType: string): JsonSchemaObject {
+ const definition = profileSchema().$defs?.[providerType];
+ const options = definition?.properties?.options;
+ if (!options || options.type !== "object") {
+ return { type: "object", properties: {}, additionalProperties: true };
+ }
+ return options as JsonSchemaObject;
+}
+
+export function providerTypes(): string[] {
+ const values = profileSchema().properties?.provider?.properties?.type?.enum;
+ return Array.isArray(values)
+ ? values.filter((value): value is string => typeof value === "string")
+ : [];
+}
+
+export function mergeProfileProjection(
+ draft: ProfileWizardDraft,
+ keys: string[],
+ next: Record,
+): ProfileWizardDraft {
+ const merged = { ...draft };
+ for (const key of keys) delete merged[key];
+ for (const key of keys) {
+ if (Object.prototype.hasOwnProperty.call(next, key)) merged[key] = next[key];
+ }
+ return merged;
+}
+
+export function mergeSampledProfileColumns(
+ configured: ProfileColumn[],
+ sampled: ProfileColumn[],
+): ProfileColumn[] {
+ const configuredNames = new Set(configured.map((column) => column.name));
+ return [
+ ...configured.map((column) => ({ ...column })),
+ ...sampled
+ .filter((column) => !configuredNames.has(column.name))
+ .map((column) => ({ ...column })),
+ ];
+}
+
+export function resetProfileColumns(
+ draft: ProfileWizardDraft,
+ sampled: ProfileColumn[],
+): ProfileWizardDraft {
+ if (sampled.length === 0) {
+ throw new Error("Cannot reset profile columns without sampled columns");
+ }
+ return { ...draft, columns: structuredClone(sampled) };
+}
+
+export function profileColumnResetState({
+ providerType,
+ sampledColumnCount,
+ sampleStale,
+}: {
+ providerType: string;
+ sampledColumnCount: number;
+ sampleStale: boolean;
+}) {
+ if (providerType !== "opensearch") {
+ return { visible: false, disabled: true, title: "" };
+ }
+ if (sampledColumnCount === 0) {
+ return {
+ visible: true,
+ disabled: true,
+ title: "Run a sample before resetting columns",
+ };
+ }
+ if (sampleStale) {
+ return {
+ visible: true,
+ disabled: true,
+ title: "Run another sample for the current source and query",
+ };
+ }
+ return {
+ visible: true,
+ disabled: false,
+ title: "Replace configured columns with the latest sample",
+ };
+}
+
+export function profileSampleSignature(draft: ProfileWizardDraft): string {
+ return JSON.stringify({
+ provider: draft.provider ?? {},
+ query: draft.query ?? "",
+ });
+}
+
+export function validateProfileEditorDraft(
+ draft: ProfileWizardDraft,
+): string | null {
+ if (!draft.profile?.trim()) return "Profile name is required";
+ if (!draft.provider?.type?.trim()) return "Provider type is required";
+ const names = new Set();
+ for (const column of draft.columns ?? []) {
+ const name = column.name.trim();
+ if (!name) return "Every column needs a name";
+ if (names.has(name)) return `Column name "${name}" is duplicated`;
+ names.add(name);
+ }
+ return validateProfileParams(draft.params, draft.provider?.type);
+}
+
+export function profileUpdateConflictTarget(error: string): string | null {
+ if (!error.includes("PROFILE_NAME_CONFLICT")) return null;
+ return error.match(/existing profile "([^"]+)"/)?.[1] ?? null;
+}
+
+export function profileRoute(name: string): string {
+ const slug = stripSurroundingDashes(
+ Array.from(name.trim().toLowerCase())
+ .map((character) =>
+ /[a-z0-9]/.test(character)
+ ? character
+ : /[ ._/-]/.test(character)
+ ? "-"
+ : "",
+ )
+ .join(""),
+ );
+ return `/profile-${slug}`;
+}
+
+/** Deep-linkable editor route for a profile surface (`profile-os2`). */
+export function profileEditRoute(surfaceKey: string): string {
+ return `/${surfaceKey}/edit`;
+}
+
+/** Surface key an edit route addresses, or null when the path is not one. */
+export function profileEditSurfaceKey(pathname: string): string | null {
+ return pathname.match(/^\/(profile-[^/]+)\/edit\/?$/)?.[1] ?? null;
+}
diff --git a/packages/ui/src/profiles/profileEditorPreview.tsx b/packages/ui/src/profiles/profileEditorPreview.tsx
new file mode 100644
index 00000000..543b810f
--- /dev/null
+++ b/packages/ui/src/profiles/profileEditorPreview.tsx
@@ -0,0 +1,71 @@
+import type { ProfileColumn } from "./profileWizardModel";
+
+/**
+ * The configured columns applied to the rows returned by the last sample, so
+ * the effect of including a field, relabelling it or hiding it is visible
+ * without leaving the editor. Rows only exist after a sample runs — there is no
+ * placeholder data, because a fabricated preview is worse than none.
+ */
+export function ProfileEditorPreview({
+ columns,
+ rows,
+}: {
+ columns: ProfileColumn[];
+ rows: Record[];
+}) {
+ const shown = columns.filter((column) => !column.hidden);
+
+ if (rows.length === 0 || shown.length === 0) {
+ return (
+
+ {shown.length === 0
+ ? "No columns included yet."
+ : "Run a sample in Source & Query to preview rows."}
+
+ );
+ }
+
+ return (
+
+
+
+
+ {shown.map((column) => (
+
+ {column.label ?? column.name}
+
+ ))}
+
+
+
+ {rows.map((row, index) => (
+
+ {shown.map((column) => (
+
+ {formatPreviewCell(
+ Object.prototype.hasOwnProperty.call(row, column.name)
+ ? row[column.name]
+ : column.source
+ ? row[column.source]
+ : undefined,
+ )}
+
+ ))}
+
+ ))}
+
+
+
+ );
+}
+
+function formatPreviewCell(value: unknown): string {
+ if (value === null || value === undefined) return "—";
+ return typeof value === "object" ? JSON.stringify(value) : String(value);
+}
diff --git a/packages/ui/src/profiles/profileEditorRail.tsx b/packages/ui/src/profiles/profileEditorRail.tsx
new file mode 100644
index 00000000..1436b5a4
--- /dev/null
+++ b/packages/ui/src/profiles/profileEditorRail.tsx
@@ -0,0 +1,58 @@
+import {
+ profileEditorSections,
+ type ProfileEditorSection,
+ type ProfileSectionStatus,
+} from "./profileEditorModel";
+
+/** Vertical section nav for the editor route, in place of the modal's tabs. */
+export function ProfileEditorRail({
+ value,
+ status,
+ onChange,
+}: {
+ value: ProfileEditorSection;
+ status: Record;
+ onChange: (section: ProfileEditorSection) => void;
+}) {
+ return (
+
+ {profileEditorSections.map((section) => {
+ const active = section.id === value;
+ const { badge, attention } = status[section.id];
+ return (
+ onChange(section.id)}
+ >
+
+
+ {section.label}
+
+
+ {section.hint}
+
+
+ {attention ? (
+
+ ) : null}
+ {badge ? (
+
+ {badge}
+
+ ) : null}
+
+ );
+ })}
+
+ );
+}
diff --git a/packages/ui/src/profiles/profileEditorRaw.test.tsx b/packages/ui/src/profiles/profileEditorRaw.test.tsx
new file mode 100644
index 00000000..2c7a1a4a
--- /dev/null
+++ b/packages/ui/src/profiles/profileEditorRaw.test.tsx
@@ -0,0 +1,64 @@
+import { renderToStaticMarkup } from "react-dom/server";
+import { beforeAll, describe, expect, it, vi } from "vitest";
+import { configureProfiles } from "./profileApi";
+import { testProfileSchema } from "./testSchema";
+import { ProfileEditorRaw } from "./profileEditorRaw";
+import { parseProfileYamlDocument, profileYamlFilename } from "./profileYaml";
+
+vi.mock("../monaco", () => ({
+ MonacoSchemaEditor: ({ height, value }: { height?: string | number; value: string }) => (
+
+ {value}
+
+ ),
+}));
+
+const draft = {
+ profile: "service-logs",
+ provider: { type: "opensearch" },
+ query: "GET service-logs/_search",
+};
+
+describe("raw profile YAML", () => {
+ // The Monaco editor validates against the host-supplied schema.
+ beforeAll(() => configureProfiles({ schema: testProfileSchema }));
+
+ it("fills the editor frame and exposes YAML import and export", () => {
+ const html = renderToStaticMarkup(
+ undefined}
+ onValidityChange={() => undefined}
+ />,
+ );
+
+ expect(html).toContain("Import YAML");
+ expect(html).toContain("Export YAML");
+ expect(html).toContain('accept=".yaml,.yml,application/yaml,text/yaml,text/x-yaml"');
+ expect(html).toContain('data-slot="profile-yaml-editor-frame"');
+ expect(html).toContain("[&>[data-slot=monaco-editor]]:h-full");
+ expect(html).toContain('data-height="100%"');
+ });
+
+ it("parses an imported YAML object", () => {
+ expect(
+ parseProfileYamlDocument(`
+profile: service-logs
+provider:
+ type: opensearch
+query: GET service-logs/_search
+`),
+ ).toEqual(draft);
+ });
+
+ it("rejects an imported YAML document that is not an object", () => {
+ expect(() => parseProfileYamlDocument("- service-logs\n")).toThrow(
+ "Profile YAML must contain an object",
+ );
+ });
+
+ it("uses a filesystem-safe YAML export name", () => {
+ expect(profileYamlFilename(" Service Logs / Prod ")).toBe("Service-Logs-Prod.yaml");
+ expect(profileYamlFilename(" ")).toBe("profile.yaml");
+ });
+});
diff --git a/packages/ui/src/profiles/profileEditorRaw.tsx b/packages/ui/src/profiles/profileEditorRaw.tsx
new file mode 100644
index 00000000..2c71e47c
--- /dev/null
+++ b/packages/ui/src/profiles/profileEditorRaw.tsx
@@ -0,0 +1,115 @@
+import { Button } from "../components/button";
+import { UiDownload, UiUpload } from "../icons";
+import { MonacoSchemaEditor } from "../monaco";
+import { useRef, useState, type ChangeEvent } from "react";
+import { stringify } from "yaml";
+import { profileEditorSchema } from "./profileEditorModel";
+import type { ProfileWizardDraft } from "./profileWizardModel";
+import { parseProfileYamlDocument, profileYamlFilename } from "./profileYaml";
+
+export function ProfileEditorRaw({
+ draft,
+ onChange,
+ onValidityChange
+}: {
+ draft: ProfileWizardDraft;
+ onChange: (draft: ProfileWizardDraft) => void;
+ onValidityChange: (valid: boolean) => void;
+}) {
+ const [value, setValue] = useState(() => stringify(draft));
+ const [parseError, setParseError] = useState("");
+ const fileInput = useRef(null);
+
+ const updateValue = (next: string) => {
+ setValue(next);
+ try {
+ const parsed = parseProfileYamlDocument(next);
+ setParseError("");
+ onValidityChange(true);
+ onChange(parsed);
+ } catch (error) {
+ setParseError(error instanceof Error ? error.message : "Invalid YAML");
+ onValidityChange(false);
+ }
+ };
+
+ const importYaml = async (event: ChangeEvent) => {
+ const input = event.currentTarget;
+ const file = input.files?.[0];
+ input.value = "";
+ if (!file) return;
+ try {
+ updateValue(await file.text());
+ } catch (error) {
+ setParseError(
+ `Unable to read ${file.name}: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ onValidityChange(false);
+ }
+ };
+
+ return (
+
+
+
+ Raw YAML edits the complete profile. Invalid text is kept here while you
+ work; returning to another tab restores the last valid structured value.
+ YAML comments and key ordering are not preserved after structured edits.
+
+
+ void importYaml(event)}
+ />
+ fileInput.current?.click()}>
+
+ Import YAML
+
+ downloadProfileYaml(value, profileYamlFilename(draft.profile))}
+ >
+
+ Export YAML
+
+
+
+ {parseError ? (
+
{parseError}
+ ) : null}
+
+ {
+ if (!parseError) onValidityChange(state.status !== "invalid");
+ }}
+ onChange={updateValue}
+ />
+
+
+ );
+}
+
+function downloadProfileYaml(value: string, filename: string) {
+ const url = URL.createObjectURL(new Blob([value], { type: "application/yaml;charset=utf-8" }));
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = filename;
+ link.click();
+ URL.revokeObjectURL(url);
+}
+
diff --git a/packages/ui/src/profiles/profileEditorRoutes.ts b/packages/ui/src/profiles/profileEditorRoutes.ts
new file mode 100644
index 00000000..4f916c0b
--- /dev/null
+++ b/packages/ui/src/profiles/profileEditorRoutes.ts
@@ -0,0 +1,4 @@
+
+export function resolveProfileUpdatePath(path: string, id: string): string {
+ return path.replace("{id}", encodeURIComponent(id)).replace(":id", encodeURIComponent(id));
+}
diff --git a/packages/ui/src/profiles/profileEditorSections.tsx b/packages/ui/src/profiles/profileEditorSections.tsx
new file mode 100644
index 00000000..f6834d87
--- /dev/null
+++ b/packages/ui/src/profiles/profileEditorSections.tsx
@@ -0,0 +1,252 @@
+import { JsonSchemaForm } from "../components/JsonSchemaForm";
+import type { FormLayout, LookupFetcher } from "../components/json-schema-form-types";
+import { Icon } from "../data/Icon";
+import type { ReactNode } from "react";
+import {
+ mergeProfileProjection,
+ profileSchemaProjection,
+ providerOptionsSchema,
+ providerTypes,
+} from "./profileEditorModel";
+import { ProfileWizardQueryStep, type ProfileSample } from "./profileWizardQueryStep";
+import {
+ profileConnectionID,
+ type ProfileColumn,
+ type ProfileWizardDraft,
+} from "./profileWizardModel";
+
+const inputClassName =
+ "w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:border-primary focus:ring-2 focus:ring-primary/15";
+
+export function ProfileGeneralSection({
+ draft,
+ onChange,
+}: EditorSectionProps) {
+ return (
+
+
+
+ );
+}
+
+export function ProfileSourceSection({
+ draft,
+ discovered,
+ sampleStale,
+ onChange,
+ onSample,
+}: EditorSectionProps & {
+ discovered: ProfileColumn[];
+ sampleStale: boolean;
+ onSample: (sample: ProfileSample) => void;
+}) {
+ const connectionID = profileConnectionID(draft.provider?.connection ?? "");
+ const providerType = draft.provider?.type ?? "";
+ return (
+
+
+
+
+
+ onChange({
+ ...draft,
+ provider: { ...draft.provider, type: event.target.value },
+ })
+ }
+ >
+ Choose a provider
+ {providerTypes().map((type) => {type} )}
+
+
+
+
+ onChange({
+ ...draft,
+ provider: { ...draft.provider, connection: event.target.value },
+ })
+ }
+ />
+
+
+ {sampleStale ? (
+
+ Source or query settings changed after the latest sample. You can save,
+ but sampling again will verify the current field shape.
+
+ ) : null}
+
+ {connectionID ? (
+
+ ) : (
+
+
+
+
+
+ onChange({
+ ...draft,
+ provider: { ...draft.provider, options },
+ })
+ }
+ showPreferencesMenu={false}
+ />
+
+
+ )}
+
+ );
+}
+
+export function ProfileSchemaSection({
+ draft,
+ keys,
+ title,
+ description,
+ idPrefix,
+ layout,
+ lookupFetcher,
+ onChange,
+}: EditorSectionProps & {
+ keys: string[];
+ title: string;
+ description: string;
+ idPrefix: string;
+ // Presentation is the call site's, not this component's — it is shared with
+ // Advanced composition, which must keep the plain stacked defaults.
+ layout?: FormLayout;
+ // JsonSchemaForm always installs its own lookup provider, so an outer one is
+ // shadowed rather than inherited: without this, x-clicky-lookup fields (the
+ // imports and reconcile.dest profile pickers) render with no options at all.
+ lookupFetcher?: LookupFetcher;
+}) {
+ return (
+
+ Object.prototype.hasOwnProperty.call(draft, key) ? [[key, draft[key]]] : []))}
+ onChange={(next) => onChange(mergeProfileProjection(draft, keys, next))}
+ showPreferencesMenu={false}
+ {...(layout ? { layout } : {})}
+ {...(lookupFetcher ? { lookupFetcher } : {})}
+ />
+
+ );
+}
+
+type EditorSectionProps = {
+ draft: ProfileWizardDraft;
+ onChange: (draft: ProfileWizardDraft) => void;
+};
+
+function SectionCard({
+ title,
+ description,
+ children,
+}: {
+ title: string;
+ description: string;
+ children: ReactNode;
+}) {
+ return (
+
+ {title}
+ {description}
+ {children}
+
+ );
+}
+
+function EditorField({
+ label,
+ required,
+ children,
+}: {
+ label: string;
+ required?: boolean;
+ children: ReactNode;
+}) {
+ return (
+
+ {label}{required ? * : null}
+ {children}
+
+ );
+}
diff --git a/packages/ui/src/profiles/profileFieldEditor.tsx b/packages/ui/src/profiles/profileFieldEditor.tsx
new file mode 100644
index 00000000..92b68bd6
--- /dev/null
+++ b/packages/ui/src/profiles/profileFieldEditor.tsx
@@ -0,0 +1,315 @@
+import { JSONPathField } from "../components/JSONPathField";
+import { Button } from "../components/button";
+import type { ReactNode } from "react";
+import {
+ inferredFilterKind,
+ patchColumnFilter,
+ PROFILE_COLUMN_FORMAT_OPTIONS,
+ PROFILE_COLUMN_UNIT_OPTIONS,
+ PROFILE_FILTER_DEFAULT_LIMIT,
+ PROFILE_FILTER_KIND_OPTIONS,
+ PROFILE_FILTER_MAX_LIMIT,
+ type Patch,
+ type ProfileColumn,
+ type ProfileColumnFilter,
+} from "./profileWizardModel";
+import { evaluateJsonPath, useJsonPathSampleRows } from "./jsonPathSample";
+
+const inputClassName =
+ "w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:border-primary focus:ring-2 focus:ring-primary/15";
+
+type FieldActions = {
+ selected: boolean;
+ canMoveUp: boolean;
+ canMoveDown: boolean;
+ onSelectedChange: (selected: boolean) => void;
+ onMoveUp: () => void;
+ onMoveDown: () => void;
+ onRemove: () => void;
+};
+
+export const profileFieldEditorEmptyMessage =
+ "Add a column or run a sample to discover fields.";
+
+/** Ordering, removal and inclusion — the operations that act on the field as a
+ * whole rather than on one of its properties. */
+export function ProfileFieldEditorActions({
+ canMoveUp,
+ canMoveDown,
+ selected,
+ onSelectedChange,
+ onMoveUp,
+ onMoveDown,
+ onRemove,
+}: FieldActions) {
+ return (
+ <>
+
+ Move up
+
+
+ Move down
+
+
+ Remove
+
+
+ onSelectedChange(event.target.checked)}
+ />
+ Include
+
+ >
+ );
+}
+
+/**
+ * Everything about a column that a grid cell cannot hold: role, format, unit
+ * and the CEL expression. `columns` is explicit rather than a `sm:` breakpoint
+ * because the editor route mounts this in a ~380px pane on a wide viewport,
+ * where viewport-based breakpoints would wrongly go two-up.
+ */
+export function ProfileFieldEditorForm({
+ field,
+ columns = 2,
+ onChange,
+}: {
+ field: ProfileColumn;
+ columns?: 1 | 2;
+ onChange: (patch: Patch) => void;
+}) {
+ const wide = columns === 2 ? "sm:col-span-2" : "";
+ const rows = useJsonPathSampleRows();
+ return (
+
+
+ onChange({ name: event.target.value })} />
+
+
+ onChange({ label: event.target.value || undefined })} />
+
+
+ onChange({ type: event.target.value || undefined })}>
+ Auto detect
+ {["string", "number", "boolean", "datetime", "duration", "bytes", "status", "health", "key_value", "key_values", "json"].map((type) => (
+ {type}
+ ))}
+
+
+
+ onChange({ kind: event.target.value || undefined })}>
+ Standard field
+ Timestamp
+ Tags
+ Status
+
+
+
+ onChange({ format: event.target.value || undefined })}>
+ From Type
+ {PROFILE_COLUMN_FORMAT_OPTIONS.map((option) => {option.label} )}
+
+
+
+ onChange({ unit: event.target.value || undefined })}>
+ No unit
+ {PROFILE_COLUMN_UNIT_OPTIONS.map((option) => {option.label} )}
+
+
+
+ onChange({ width: event.target.value ? Number(event.target.value) : undefined })} />
+
+
+
+
+
+
+
+ onChange({ jsonpath: next || undefined })}
+ // This editor owns the whole column, so a path picked out of a
+ // JSON-encoded column sets the Source it needs in the same edit
+ // rather than leaving the author to pair the two by hand. A path
+ // picked outside one clears it: alongside a jsonpath, Source is the
+ // root, and a stale root re-roots the new path at a column it was
+ // never written against.
+ onSelectPath={(next, { root }) =>
+ onChange({ jsonpath: next || undefined, source: root })
+ }
+ {...(field.source ? { source: field.source } : {})}
+ {...(rows.length === 0 ? {} : { json: rows[0], rows })}
+ evaluate={evaluateJsonPath}
+ />
+
+
+
+ onChange({ hidden: event.target.checked })} />
+ Hide this field in the default table
+
+
+
+ );
+}
+
+/**
+ * How this column is filtered. Collapsed by default: every field here overrides
+ * an inference the server already makes correctly for most columns, so opening
+ * it should be a deliberate act rather than the price of editing a label.
+ */
+function ProfileFieldFilterEditor({
+ field,
+ columns,
+ onChange,
+}: {
+ field: ProfileColumn;
+ columns: 1 | 2;
+ onChange: (patch: Patch) => void;
+}) {
+ const filter = field.filter ?? {};
+ const set = (patch: Patch) =>
+ onChange({ filter: patchColumnFilter(field.filter, patch) });
+
+ // A value selection is the only kind with a list to enumerate; a range, a
+ // toggle and a substring are typed rather than picked.
+ const kind = filter.kind ?? inferredFilterKind(field);
+ const picksFromAList = kind === "terms";
+ const enumerated = (filter.options?.length ?? 0) > 0;
+ // Declaring the values IS the answer the lookup would go and fetch, so the two
+ // are mutually exclusive — the server enforces this and the form mirrors it.
+ const looksUp = picksFromAList && !enumerated && (filter.lookup ?? true);
+
+ return (
+
+
+ Filtering
+
+ {filter.disabled ? "off" : summarizeFilter(kind, enumerated, looksUp, filter.limit)}
+
+
+
+
+ );
+}
+
+function parseFilterOptions(raw: string): string[] | undefined {
+ const values = raw.split(",").map((value) => value.trim()).filter(Boolean);
+ return values.length ? values : undefined;
+}
+
+function summarizeFilter(kind: string, enumerated: boolean, looksUp: boolean, limit?: number) {
+ const control = PROFILE_FILTER_KIND_OPTIONS.find((option) => option.value === kind)?.label ?? kind;
+ if (enumerated) return `${control}, listed`;
+ if (!looksUp) return control;
+ return `${control}, top ${limit ?? PROFILE_FILTER_DEFAULT_LIMIT}`;
+}
+
+/** The wizard's carded inspector: names the field itself, because its field
+ * list has no header to do it. The editor route composes the parts instead,
+ * since its Workspace pane header already carries the name. */
+export function ProfileFieldEditor({
+ field,
+ onChange,
+ ...actions
+}: FieldActions & {
+ field?: ProfileColumn | undefined;
+ onChange: (patch: Patch) => void;
+}) {
+ if (!field) {
+ return (
+
+ {profileFieldEditorEmptyMessage}
+
+ );
+ }
+
+ return (
+
+
+
+
+ Field editor
+
+
+ {field.name}
+
+
+
+
+
+
+ );
+}
+
+function EditorField({ label, help, children }: { label: string; help?: string; children: ReactNode }) {
+ return (
+
+ {label}
+ {children}
+ {help ? {help} : null}
+
+ );
+}
diff --git a/packages/ui/src/profiles/profileFieldGrid.tsx b/packages/ui/src/profiles/profileFieldGrid.tsx
new file mode 100644
index 00000000..38ea36b1
--- /dev/null
+++ b/packages/ui/src/profiles/profileFieldGrid.tsx
@@ -0,0 +1,312 @@
+import { useState } from "react";
+import { IconButton } from "../components/IconButton";
+import {
+ UiDotsVertical,
+ UiEye,
+ UiEyeClosed,
+ UiTrash,
+} from "../icons";
+import type { ProfileFieldState } from "./profileFieldState";
+import type { ProfileColumn, ProfileFieldFilter } from "./profileWizardModel";
+import { PROFILE_FIELD_TYPES } from "./profileFieldTypes";
+
+const inputClassName =
+ "w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:border-primary focus:ring-2 focus:ring-primary/15";
+
+const inputClassNameSm =
+ "w-full rounded-md border border-input bg-background px-2 py-1 text-xs outline-none focus:border-primary focus:ring-2 focus:ring-primary/15";
+
+/** Search, type and selection filters. `compact` lays them out on one row for
+ * the editor route's pinned toolbar; the wizard stacks them in its card. */
+export function ProfileFieldFilters({
+ state,
+ compact = false,
+}: {
+ state: ProfileFieldState;
+ compact?: boolean;
+}) {
+ const className = compact ? inputClassNameSm : inputClassName;
+ const patch = (next: Partial) =>
+ state.setFilter((current) => ({ ...current, ...next }));
+ const search = (
+ patch({ query: event.target.value })}
+ placeholder={`Search ${state.available.length} fields`}
+ aria-label="Search fields"
+ className={className}
+ />
+ );
+ const selects = (
+ <>
+ patch({ type: event.target.value })}
+ >
+ All types
+ {state.types.map((type) => (
+ {type}
+ ))}
+
+
+ patch({ selection: event.target.value as ProfileFieldFilter["selection"] })
+ }
+ >
+ All fields
+ Selected
+ Not selected
+
+ >
+ );
+ if (compact) {
+ return {search}{selects}
;
+ }
+ return (
+
+ );
+}
+
+/** The in-flight row drag, shared by every row so a row can tell whether it is
+ * the one being dragged and which of its edges the drop would land on. */
+type ProfileFieldDrag = {
+ /** Name of the field being dragged, or "" when no drag is in flight. */
+ source: string;
+ /** Which edge of `name` an insertion line belongs on, if any. */
+ edgeFor: (name: string) => "top" | "bottom" | null;
+ start: (name: string) => void;
+ over: (name: string) => void;
+ drop: (name: string) => void;
+ end: () => void;
+};
+
+/**
+ * Spreadsheet-style column list: include, label and type are edited in place,
+ * with quick visibility and delete actions on every row. Anything that does not
+ * fit a grid cell — role, format, unit, width, CEL — stays in the inspector pane.
+ *
+ * Rows are dragged by their handle to reorder the profile's columns. Only
+ * configured fields take part: a deleted one has no position of its own, so it
+ * is neither draggable nor a drop target.
+ */
+export function ProfileFieldGrid({ state }: { state: ProfileFieldState }) {
+ const [source, setSource] = useState("");
+ const [target, setTarget] = useState("");
+ const order = state.visibleFields.map((field) => field.name);
+ const end = () => {
+ setSource("");
+ setTarget("");
+ };
+ const drag: ProfileFieldDrag = {
+ source,
+ edgeFor: (name) =>
+ !source || target !== name || source === name
+ ? null
+ : order.indexOf(source) < order.indexOf(name)
+ ? "bottom"
+ : "top",
+ start: setSource,
+ over: setTarget,
+ drop: (name) => {
+ state.reorderField(source, name);
+ end();
+ },
+ end,
+ };
+ return (
+
+
+
+
+ {["Actions", "Field", "Display label", "Type", "CEL"].map((heading) => (
+
+ {heading}
+
+ ))}
+
+
+
+ {state.visibleFields.map((field) => (
+
+ ))}
+
+
+ {state.visibleFields.length === 0 ? (
+
+ No fields match these filters.
+
+ ) : null}
+
+ );
+}
+
+const dropEdgeClassName = {
+ top: "[&>td]:shadow-[inset_0_2px_0_0_var(--color-primary)]",
+ bottom: "[&>td]:shadow-[inset_0_-2px_0_0_var(--color-primary)]",
+} as const;
+
+function ProfileFieldGridRow({
+ field,
+ state,
+ drag,
+ selected,
+ active,
+}: {
+ field: ProfileColumn;
+ state: ProfileFieldState;
+ drag: ProfileFieldDrag;
+ selected: boolean;
+ active: boolean;
+}) {
+ // The row carries text inputs, so it stays undraggable until the handle is
+ // pressed — otherwise selecting text in a cell starts a reorder.
+ const [grabbed, setGrabbed] = useState(false);
+ const fieldState = !selected ? "deleted" : field.hidden ? "hidden" : "visible";
+ const stateClassName =
+ fieldState === "deleted"
+ ? "text-muted-foreground line-through opacity-60 [&_input]:line-through [&_select]:line-through"
+ : fieldState === "hidden"
+ ? "text-muted-foreground opacity-60"
+ : "";
+ const dragging = drag.source === field.name;
+ const droppable = selected && Boolean(drag.source) && !dragging;
+ const edge = droppable ? drag.edgeFor(field.name) : null;
+ const release = () => {
+ setGrabbed(false);
+ drag.end();
+ };
+ return (
+ {
+ event.dataTransfer.effectAllowed = "move";
+ event.dataTransfer.setData("text/plain", field.name);
+ drag.start(field.name);
+ }}
+ onDragOver={(event) => {
+ if (!droppable) return;
+ event.preventDefault();
+ event.dataTransfer.dropEffect = "move";
+ drag.over(field.name);
+ }}
+ onDrop={(event) => {
+ if (!droppable) return;
+ event.preventDefault();
+ setGrabbed(false);
+ drag.drop(field.name);
+ }}
+ onDragEnd={release}
+ className={[
+ "border-b border-border",
+ active ? "bg-primary/10" : "hover:bg-muted/40",
+ stateClassName,
+ dragging ? "opacity-40" : "",
+ edge ? dropEdgeClassName[edge] : "",
+ ]
+ .filter(Boolean)
+ .join(" ")}
+ onClick={() => state.setActiveName(field.name)}
+ >
+ event.stopPropagation()}>
+
+ setGrabbed(true)}
+ onMouseUp={release}
+ onKeyDown={(event) => {
+ const offset =
+ event.key === "ArrowUp" ? -1 : event.key === "ArrowDown" ? 1 : 0;
+ if (!offset) return;
+ event.preventDefault();
+ state.moveField(field, offset);
+ }}
+ />
+ state.patchField(field, { hidden: !field.hidden })}
+ />
+ state.removeField(field)}
+ />
+
+
+
+
+ state.patchField(field, { name: event.target.value })
+ }
+ />
+
+
+
+ state.patchField(field, { label: event.target.value || undefined })
+ }
+ />
+
+
+
+ state.patchField(field, { type: event.target.value || undefined })
+ }
+ >
+ auto
+ {PROFILE_FIELD_TYPES.map((type) => (
+ {type}
+ ))}
+
+
+
+ {field.cel ? (
+
+ set
+
+ ) : (
+ —
+ )}
+
+
+ );
+}
diff --git a/packages/ui/src/profiles/profileFieldManager.tsx b/packages/ui/src/profiles/profileFieldManager.tsx
new file mode 100644
index 00000000..4e4a9b27
--- /dev/null
+++ b/packages/ui/src/profiles/profileFieldManager.tsx
@@ -0,0 +1,102 @@
+import { Button } from "../components/button";
+import { ProfileFieldEditor } from "./profileFieldEditor";
+import { ProfileFieldFilters } from "./profileFieldGrid";
+import { useProfileFieldState, type ProfileFieldStateProps } from "./profileFieldState";
+
+/**
+ * The wizard's two-pane column step: a field list beside the inspector.
+ *
+ * The editor route spreads the same state across Workspace panes instead; both
+ * read `useProfileFieldState`, so selection and filtering behave identically.
+ */
+export function ProfileFieldManager(props: ProfileFieldStateProps) {
+ const state = useProfileFieldState(props);
+ const { activeField } = state;
+
+ return (
+
+
+
+
+
+
Fields
+
+ {state.configuredCount} of {state.available.length} fields selected
+
+
+
+
+ Add column
+
+ state.setVisibleSelection(true)}
+ >
+ Select visible
+
+ |
+ state.setVisibleSelection(false)}
+ >
+ Clear visible
+
+
+
+
+
+
+ {state.visibleFields.map((field) => (
+
+ state.setFieldSelection(field, event.target.checked)}
+ />
+ state.setActiveName(field.name)}
+ >
+
+ {field.name}
+
+
+ {field.type || "auto"}
+
+
+
+ ))}
+ {state.visibleFields.length === 0 ? (
+
+ No fields match these filters.
+
+ ) : null}
+
+
+
+
{
+ if (activeField) state.setFieldSelection(activeField, selected);
+ }}
+ onChange={state.updateActiveField}
+ canMoveUp={state.canMoveUp}
+ canMoveDown={state.canMoveDown}
+ onMoveUp={() => state.moveActive(-1)}
+ onMoveDown={() => state.moveActive(1)}
+ onRemove={state.removeActive}
+ />
+
+ );
+}
diff --git a/packages/ui/src/profiles/profileFieldState.test.tsx b/packages/ui/src/profiles/profileFieldState.test.tsx
new file mode 100644
index 00000000..4d9b7e53
--- /dev/null
+++ b/packages/ui/src/profiles/profileFieldState.test.tsx
@@ -0,0 +1,209 @@
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it } from "vitest";
+import { ProfileEditorPreview } from "./profileEditorPreview";
+import { ProfileFieldGrid } from "./profileFieldGrid";
+import { useProfileFieldState } from "./profileFieldState";
+import {
+ applyVisibleFieldSelection,
+ availableProfileFields,
+ renameProfileField,
+ reorderProfileColumns,
+ type ProfileColumn,
+} from "./profileWizardModel";
+
+/** What the last sample (or the stored profile at open time) reported. */
+const discovered: ProfileColumn[] = [
+ { name: "created_at", type: "datetime" },
+ { name: "body", type: "string" },
+];
+
+function GridProbe({ configured }: { configured: ProfileColumn[] }) {
+ const state = useProfileFieldState({
+ discovered,
+ configured,
+ activeName: "created_at",
+ onConfiguredChange: () => undefined,
+ onActiveNameChange: () => undefined,
+ });
+ return ;
+}
+
+/** The `value` of the input carrying `ariaLabel`, or "" when it has none. */
+const valueOf = (html: string, ariaLabel: string): string => {
+ const at = html.indexOf(`aria-label="${ariaLabel}"`);
+ expect(at, `no element labelled ${ariaLabel}`).toBeGreaterThan(-1);
+ const tag = html.slice(html.lastIndexOf("<", at), html.indexOf(">", at));
+ return tag.match(/value="([^"]*)"/)?.[1] ?? "";
+};
+
+describe("column configuration in the field grid", () => {
+ it("leads with quick actions without selection or width controls", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+
+ expect(html).toContain('aria-label="Hide created_at"');
+ expect(html).toContain('aria-label="Show body"');
+ expect(html).toContain('aria-label="Delete created_at"');
+ expect(html).toContain('aria-label="Rename created_at"');
+ expect(html.indexOf(">Actions")).toBeLessThan(
+ html.indexOf(">Field"),
+ );
+ expect(html).not.toContain('aria-label="Include created_at"');
+ expect(html).not.toContain('aria-label="Width for created_at"');
+ expect(html).not.toContain(">WIDTH");
+ expect(html).toContain(' {
+ const html = renderToStaticMarkup(
+ ,
+ );
+
+ expect({
+ label: valueOf(html, "Label for created_at"),
+ }).toEqual({ label: "Created" });
+ });
+
+ it("strikes deleted fields and mutes hidden fields", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+
+ expect(html).toMatch(/data-field-state="hidden"[^>]*text-muted-foreground[^>]*opacity-60/);
+ expect(html).toMatch(/data-field-state="deleted"[^>]*line-through[^>]*opacity-60/);
+ });
+
+ it("offers the configured field to editors, so a grid edit composes onto inspector edits", () => {
+ expect(
+ availableProfileFields(discovered, [
+ { name: "created_at", type: "datetime", label: "Created", cel: "row.created_at" },
+ { name: "computed", type: "string", cel: "row.a + row.b" },
+ ]),
+ ).toEqual([
+ { name: "created_at", type: "datetime", label: "Created", cel: "row.created_at" },
+ { name: "body", type: "string" },
+ { name: "computed", type: "string", cel: "row.a + row.b" },
+ ]);
+ });
+
+ it("replaces a discovered field with its renamed output field", () => {
+ expect(
+ availableProfileFields(discovered, [
+ { name: "created", source: "created_at", type: "datetime" },
+ { name: "body", type: "string" },
+ ]),
+ ).toEqual([
+ { name: "created", source: "created_at", type: "datetime" },
+ { name: "body", type: "string" },
+ ]);
+ });
+
+ it("offers a drag handle on configured fields and withholds it from deleted ones", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+
+ /** The opening tag of the reorder handle for `name`. */
+ const handle = (name: string) => {
+ const at = html.indexOf(`aria-label="Reorder ${name}"`);
+ expect(at, `no reorder handle for ${name}`).toBeGreaterThan(-1);
+ return html.slice(html.lastIndexOf("<", at), html.indexOf(">", at));
+ };
+
+ expect(handle("created_at")).not.toMatch(/\sdisabled=/);
+ expect(handle("body")).toMatch(/\sdisabled=/);
+ });
+
+ it("records the original provider key when a direct field is renamed", () => {
+ expect(renameProfileField({ name: "created_at", type: "datetime" }, "created"))
+ .toEqual({ name: "created", source: "created_at", type: "datetime" });
+ expect(renameProfileField(
+ { name: "created", source: "created_at", type: "datetime" },
+ "created_on",
+ )).toEqual({ name: "created_on", source: "created_at", type: "datetime" });
+ expect(renameProfileField(
+ { name: "calculated", cel: "row.a + row.b" },
+ "total",
+ )).toEqual({ name: "total", cel: "row.a + row.b" });
+ });
+
+ it("previews the renamed value before another sample is run", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+
+ expect(html).toContain(">created");
+ expect(html).toContain(">2026-08-04");
+ expect(html).not.toContain(">created_at");
+ });
+});
+
+/** Reordering is what a row drag writes, so these cover the order the grid then
+ * shows and the order the profile saves. */
+describe("column order", () => {
+ const columns: ProfileColumn[] = [
+ { name: "created_at" },
+ { name: "body" },
+ { name: "level" },
+ ];
+
+ it("drops a dragged column onto the target position, shifting the ones between", () => {
+ expect(reorderProfileColumns(columns, "level", "created_at").map((c) => c.name))
+ .toEqual(["level", "created_at", "body"]);
+ expect(reorderProfileColumns(columns, "created_at", "level").map((c) => c.name))
+ .toEqual(["body", "level", "created_at"]);
+ });
+
+ it("leaves the order alone when either end of the drag is not configured", () => {
+ expect(reorderProfileColumns(columns, "missing", "body")).toBe(columns);
+ expect(reorderProfileColumns(columns, "body", "missing")).toBe(columns);
+ expect(reorderProfileColumns(columns, "body", "body")).toBe(columns);
+ });
+
+ it("shows the grid the configured order, not the order the source reported", () => {
+ expect(
+ availableProfileFields(discovered, [
+ { name: "body", type: "string" },
+ { name: "created_at", type: "datetime" },
+ ]).map((field) => field.name),
+ ).toEqual(["body", "created_at"]);
+ });
+
+ it("keeps a deleted field where the grid last showed it, after its sample neighbour", () => {
+ expect(
+ availableProfileFields(
+ [{ name: "created_at" }, { name: "body" }, { name: "level" }],
+ [{ name: "level" }, { name: "created_at" }],
+ ).map((field) => field.name),
+ ).toEqual(["level", "created_at", "body"]);
+ });
+
+ it("survives a deletion, which must not resort the rest into sample order", () => {
+ expect(
+ applyVisibleFieldSelection(
+ [{ name: "created_at" }, { name: "body" }, { name: "level" }],
+ [{ name: "level" }, { name: "created_at" }, { name: "body" }],
+ new Set(["body"]),
+ false,
+ ).map((field) => field.name),
+ ).toEqual(["level", "created_at"]);
+ });
+});
diff --git a/packages/ui/src/profiles/profileFieldState.ts b/packages/ui/src/profiles/profileFieldState.ts
new file mode 100644
index 00000000..e0545394
--- /dev/null
+++ b/packages/ui/src/profiles/profileFieldState.ts
@@ -0,0 +1,187 @@
+import { useMemo, useState } from "react";
+import {
+ applyVisibleFieldSelection,
+ availableProfileFields,
+ filterProfileFields,
+ patchProfileField,
+ renameProfileField,
+ reorderProfileColumns,
+ type Patch,
+ type ProfileColumn,
+ type ProfileFieldFilter,
+} from "./profileWizardModel";
+
+export type ProfileFieldStateProps = {
+ discovered: ProfileColumn[];
+ configured: ProfileColumn[];
+ activeName: string;
+ onConfiguredChange: (columns: ProfileColumn[]) => void;
+ onActiveNameChange: (name: string) => void;
+};
+
+export type ProfileFieldState = ReturnType;
+
+/**
+ * Column editing shared by the wizard's fields step and the editor route.
+ *
+ * The route splits the list and the inspector into separate Workspace panes,
+ * which cannot each own this state, so it lives here and both surfaces read the
+ * same derivations rather than re-deriving selection and filtering apart.
+ */
+export function useProfileFieldState({
+ discovered,
+ configured,
+ activeName,
+ onConfiguredChange,
+ onActiveNameChange,
+}: ProfileFieldStateProps) {
+ const [filter, setFilter] = useState({
+ query: "",
+ type: "",
+ selection: "all",
+ });
+ const available = useMemo(
+ () => availableProfileFields(discovered, configured),
+ [configured, discovered],
+ );
+ const selectedNames = useMemo(
+ () => new Set(configured.map((field) => field.name)),
+ [configured],
+ );
+ const visibleFields = useMemo(
+ () => filterProfileFields(available, selectedNames, filter),
+ [available, filter, selectedNames],
+ );
+ const types = useMemo(
+ () =>
+ Array.from(
+ new Set(available.map((field) => field.type).filter(Boolean)),
+ ).sort() as string[],
+ [available],
+ );
+ const activeField =
+ configured.find((field) => field.name === activeName) ??
+ available.find((field) => field.name === activeName) ??
+ configured[0] ??
+ available[0];
+ const activeIndex = activeField
+ ? configured.findIndex((field) => field.name === activeField.name)
+ : -1;
+
+ const setVisibleSelection = (selected: boolean) => {
+ onConfiguredChange(
+ applyVisibleFieldSelection(
+ available,
+ configured,
+ new Set(visibleFields.map((field) => field.name)),
+ selected,
+ ),
+ );
+ };
+
+ const setFieldSelection = (field: ProfileColumn, selected: boolean) => {
+ onConfiguredChange(
+ applyVisibleFieldSelection(
+ available,
+ configured,
+ new Set([field.name]),
+ selected,
+ ),
+ );
+ };
+
+ const patchField = (field: ProfileColumn, patch: Patch) => {
+ const { name, ...properties } = patch;
+ const updated =
+ typeof name === "string"
+ ? patchProfileField(renameProfileField(field, name), properties)
+ : patchProfileField(field, patch);
+ const exists = configured.some((entry) => entry.name === field.name);
+ onConfiguredChange(
+ exists
+ ? configured.map((entry) => (entry.name === field.name ? updated : entry))
+ : [...configured, updated],
+ );
+ if (typeof name === "string") onActiveNameChange(name);
+ };
+
+ const updateActiveField = (patch: Patch) => {
+ if (activeField) patchField(activeField, patch);
+ };
+
+ const addField = () => {
+ const names = new Set(available.map((field) => field.name));
+ let index = configured.length + 1;
+ let name = `column_${index}`;
+ while (names.has(name)) name = `column_${++index}`;
+ onConfiguredChange([...configured, { name, type: "string" }]);
+ onActiveNameChange(name);
+ };
+
+ const moveField = (field: ProfileColumn, offset: number) => {
+ const from = configured.findIndex((entry) => entry.name === field.name);
+ const target = from + offset;
+ if (from < 0 || target < 0 || target >= configured.length) return;
+ const next = [...configured];
+ const moved = next[from];
+ const displaced = next[target];
+ if (!moved || !displaced)
+ throw new Error(`cannot move field ${field.name}: index out of range`);
+ next[from] = displaced;
+ next[target] = moved;
+ onConfiguredChange(next);
+ };
+
+ const moveActive = (offset: number) => {
+ if (activeField) moveField(activeField, offset);
+ };
+
+ /**
+ * Drops the dragged field onto the target's position. Only configured fields
+ * have a position to move: an unselected field is anchored to the sample, so
+ * it is neither a source nor a destination.
+ */
+ const reorderField = (sourceName: string, targetName: string) => {
+ const next = reorderProfileColumns(configured, sourceName, targetName);
+ if (next !== configured) onConfiguredChange(next);
+ };
+
+ const removeField = (field: ProfileColumn) => {
+ const removedIndex = configured.findIndex((entry) => entry.name === field.name);
+ if (removedIndex < 0) return;
+ const next = configured.filter((entry) => entry.name !== field.name);
+ onConfiguredChange(next);
+ if (activeField?.name === field.name) {
+ onActiveNameChange(next[Math.max(0, removedIndex - 1)]?.name ?? "");
+ }
+ };
+
+ const removeActive = () => {
+ if (activeField) removeField(activeField);
+ };
+
+ return {
+ available,
+ selectedNames,
+ visibleFields,
+ types,
+ filter,
+ activeField,
+ activeIndex,
+ canMoveUp: activeIndex > 0,
+ canMoveDown: activeIndex >= 0 && activeIndex < configured.length - 1,
+ configuredCount: configured.length,
+ setFilter,
+ setActiveName: onActiveNameChange,
+ setVisibleSelection,
+ setFieldSelection,
+ patchField,
+ updateActiveField,
+ addField,
+ moveActive,
+ moveField,
+ reorderField,
+ removeField,
+ removeActive,
+ };
+}
diff --git a/packages/ui/src/profiles/profileFieldTypes.ts b/packages/ui/src/profiles/profileFieldTypes.ts
new file mode 100644
index 00000000..c94fb8ca
--- /dev/null
+++ b/packages/ui/src/profiles/profileFieldTypes.ts
@@ -0,0 +1,20 @@
+/**
+ * The data types a profile field may declare.
+ *
+ * Kept apart from profileFieldGrid.tsx so that module exports only components
+ * (react/only-export-components).
+ */
+
+export const PROFILE_FIELD_TYPES = [
+ "string",
+ "number",
+ "boolean",
+ "datetime",
+ "duration",
+ "bytes",
+ "status",
+ "health",
+ "key_value",
+ "key_values",
+ "json",
+] as const;
diff --git a/packages/ui/src/profiles/profileParamModel.test.ts b/packages/ui/src/profiles/profileParamModel.test.ts
new file mode 100644
index 00000000..1497ae2a
--- /dev/null
+++ b/packages/ui/src/profiles/profileParamModel.test.ts
@@ -0,0 +1,139 @@
+import { describe, expect, it } from "vitest";
+import { paramHasOptions } from "./profileWizardModel";
+import { validateProfileParams } from "./profileParamModel";
+
+describe("paramHasOptions", () => {
+ it("offers an options picker for the types drawn from a fixed set", () => {
+ expect(paramHasOptions({ type: "enum" })).toBe(true);
+ expect(paramHasOptions({ type: "list" })).toBe(true);
+ });
+
+ it("offers none for a free-text or numeric parameter", () => {
+ expect(paramHasOptions({ type: "string" })).toBe(false);
+ expect(paramHasOptions({ type: "number" })).toBe(false);
+ expect(paramHasOptions({})).toBe(false);
+ });
+});
+
+describe("validateProfileParams", () => {
+ it("accepts a profile with no parameters", () => {
+ expect(validateProfileParams([], "opensearch")).toBeNull();
+ });
+
+ it("requires a name", () => {
+ expect(validateProfileParams([{ label: "Region" }], "sql")).toContain("name");
+ });
+
+ it("rejects a duplicated name", () => {
+ const error = validateProfileParams([{ name: "region" }, { name: "region" }], "sql");
+ expect(error).toContain("region");
+ });
+
+ it("rejects a name that squats the column-filter prefix", () => {
+ expect(validateProfileParams([{ name: "filter.service" }], "sql")).toContain("filter.");
+ });
+
+ // The server drops these keys before params are built, so such a parameter
+ // would silently never receive a value.
+ it("rejects a name that collides with a reserved request key", () => {
+ expect(validateProfileParams([{ name: "format" }], "sql")).toContain("format");
+ });
+
+ it("allows a reserved paging key when the parameter claims that role", () => {
+ expect(validateProfileParams([{ name: "limit", role: "limit" }], "sql")).toBeNull();
+ });
+
+ describe("a bound list parameter", () => {
+ it("is rejected on a provider that applies no native filters", () => {
+ const error = validateProfileParams(
+ [{ name: "regions", type: "list", field: "region" }],
+ "sql",
+ );
+ expect(error).toContain("sql");
+ });
+
+ it("is accepted on opensearch", () => {
+ expect(
+ validateProfileParams([{ name: "regions", type: "list", field: "region" }], "opensearch"),
+ ).toBeNull();
+ });
+
+ it("is accepted on opentelemetry", () => {
+ expect(
+ validateProfileParams(
+ [{ name: "regions", type: "list", field: "region" }],
+ "opentelemetry",
+ ),
+ ).toBeNull();
+ });
+
+ it("is allowed unbound on any provider, since it can hold no exclusion", () => {
+ expect(validateProfileParams([{ name: "regions", type: "list" }], "sql")).toBeNull();
+ });
+ });
+
+ it("rejects a field on a parameter that is not a list", () => {
+ const error = validateProfileParams(
+ [{ name: "region", type: "enum", field: "region" }],
+ "opensearch",
+ );
+ expect(error).toContain("list");
+ });
+
+ it("rejects a list parameter claiming a paging role", () => {
+ const error = validateProfileParams(
+ [{ name: "rows", type: "list", role: "limit" }],
+ "opensearch",
+ );
+ expect(error).toContain("rows");
+ });
+
+ describe("options that cannot survive the wire format", () => {
+ it("rejects an option containing a comma", () => {
+ const error = validateProfileParams(
+ [{ name: "regions", type: "list", options: ["us-east", "eu,west"] }],
+ "opensearch",
+ );
+ expect(error).toContain("eu,west");
+ });
+
+ it("rejects an option whose leading ! would read as an exclusion", () => {
+ const error = validateProfileParams(
+ [{ name: "regions", type: "list", options: ["!eu"] }],
+ "opensearch",
+ );
+ expect(error).toContain("!eu");
+ });
+
+ it("allows a comma in an enum option, which is sent whole", () => {
+ expect(
+ validateProfileParams([{ name: "region", type: "enum", options: ["eu,west"] }], "sql"),
+ ).toBeNull();
+ });
+ });
+
+ it("rejects a default that is not among the declared options", () => {
+ const error = validateProfileParams(
+ [{ name: "region", type: "enum", options: ["us", "eu"], default: "mars" }],
+ "sql",
+ );
+ expect(error).toContain("mars");
+ });
+
+ it("accepts a default drawn from the options", () => {
+ expect(
+ validateProfileParams(
+ [{ name: "region", type: "enum", options: ["us", "eu"], default: "eu" }],
+ "sql",
+ ),
+ ).toBeNull();
+ });
+
+ it("checks every value of a list default against the options", () => {
+ const error = validateProfileParams(
+ [{ name: "regions", type: "list", options: ["us", "eu"], default: ["eu", "mars"] }],
+ "sql",
+ );
+ expect(error).toContain("mars");
+ });
+});
diff --git a/packages/ui/src/profiles/profileParamModel.ts b/packages/ui/src/profiles/profileParamModel.ts
new file mode 100644
index 00000000..27cf8ad7
--- /dev/null
+++ b/packages/ui/src/profiles/profileParamModel.ts
@@ -0,0 +1,108 @@
+/**
+ * Author-time checks on a profile's parameters, mirroring what the server
+ * rejects (query.validateParams) so a mistake is caught in the editor rather
+ * than on the first run — and so the failures that would otherwise be *silent*
+ * are caught at all: a parameter named after a reserved request key simply never
+ * receives a value.
+ */
+
+import type { ParamDraft } from "./profileWizardModel";
+
+/** Query-string keys the server consumes as transport concerns before params are
+ * built (see IsReservedParam in cmd/query/profiles/execution.go). */
+const RESERVED_REQUEST_KEYS = [
+ "format",
+ "scope",
+ "page",
+ "limit",
+ "offset",
+ "filename",
+ "_download",
+ "args",
+ "__schema",
+ "__lookup",
+ "__lookup_filter",
+ "__lookup_q",
+];
+
+const COLUMN_FILTER_PREFIX = "filter.";
+
+/** Providers that turn include/exclude selections into backend query clauses
+ * (query.SupportsNativeFilters). */
+const NATIVE_FILTER_PROVIDERS = ["opensearch", "opentelemetry"];
+
+export function validateProfileParams(
+ params: ParamDraft[] | undefined,
+ providerType: string | undefined,
+): string | null {
+ const seen = new Set();
+ for (const param of params ?? []) {
+ const name = param.name?.trim() ?? "";
+ if (!name) return "Every parameter needs a name";
+ if (seen.has(name)) return `Parameter name "${name}" is duplicated`;
+ seen.add(name);
+
+ if (name.startsWith(COLUMN_FILTER_PREFIX)) {
+ return `Parameter "${name}" must not start with "${COLUMN_FILTER_PREFIX}", which is reserved for column filters`;
+ }
+ // limit and offset are exactly the keys a profile may rename by claiming
+ // their role, so only a plain filter parameter collides.
+ const isFilter = param.role === undefined || param.role === "filter";
+ if (isFilter && RESERVED_REQUEST_KEYS.includes(name)) {
+ return `Parameter "${name}" collides with a reserved request key and would never receive a value`;
+ }
+
+ const error = validateOneParam(param, name, providerType);
+ if (error) return error;
+ }
+ return null;
+}
+
+function validateOneParam(
+ param: ParamDraft,
+ name: string,
+ providerType: string | undefined,
+): string | null {
+ if (param.type === "list" && param.role !== undefined && param.role !== "filter") {
+ return `Parameter "${name}" is a list, which cannot take the "${param.role}" role`;
+ }
+
+ const field = param.field?.trim();
+ if (field) {
+ if (param.type !== "list") {
+ return `Parameter "${name}" sets a field but is not a list; only a list binds to a backend field`;
+ }
+ if (!NATIVE_FILTER_PROVIDERS.includes(providerType ?? "")) {
+ return `Parameter "${name}" binds to field "${field}", but provider "${providerType ?? ""}" applies no native filters, so an excluded value would be dropped`;
+ }
+ }
+
+ // A list's values travel comma-joined with "!" marking an exclusion, and the
+ // wire form has no escape — an option carrying either could never be selected.
+ if (param.type === "list") {
+ for (const option of param.options ?? []) {
+ if (option.includes(",")) {
+ return `Parameter "${name}" option "${option}" contains a comma, which separates values on the wire`;
+ }
+ if (option.startsWith("!")) {
+ return `Parameter "${name}" option "${option}" starts with !, which marks an exclusion on the wire`;
+ }
+ }
+ }
+
+ return validateDefault(param, name);
+}
+
+function validateDefault(param: ParamDraft, name: string): string | null {
+ const options = param.options ?? [];
+ if (options.length === 0 || param.default === undefined || param.default === null) return null;
+
+ const chosen = Array.isArray(param.default) ? param.default : [param.default];
+ for (const value of chosen) {
+ if (typeof value !== "string") continue;
+ if (!options.includes(value)) {
+ return `Parameter "${name}" default "${value}" is not one of its options`;
+ }
+ }
+ return null;
+}
diff --git a/packages/ui/src/profiles/profileSamplePayload.test.ts b/packages/ui/src/profiles/profileSamplePayload.test.ts
new file mode 100644
index 00000000..7a1eef83
--- /dev/null
+++ b/packages/ui/src/profiles/profileSamplePayload.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it } from "vitest";
+import { profileSamplePayload } from "./profileSamplePayload";
+import type { ProfileWizardDraft } from "./profileWizardModel";
+
+const connectionID = "source-stub";
+const query = "SELECT message FROM logs";
+const options = { database: "observability" };
+const params = [{ name: "limit", type: "number" as const, default: 25 }];
+
+describe("profileSamplePayload", () => {
+ it("keeps UI-only profile state out of the strict sample request", () => {
+ const draft: ProfileWizardDraft = {
+ _id: "profile-record-1",
+ profile: "events",
+ provider: {
+ type: "sql",
+ connection: `connection://${connectionID}`,
+ options,
+ },
+ query,
+ params,
+ columns: [{ name: "message", type: "string" }],
+ render: "logs",
+ };
+
+ expect(
+ profileSamplePayload(draft, {
+ query,
+ options,
+ pagination: { limit: 25 },
+ debug: true,
+ }),
+ ).toEqual({
+ profile: {
+ profile: "events",
+ provider: {
+ type: "sql",
+ connection: `connection://${connectionID}`,
+ options,
+ },
+ query,
+ params,
+ },
+ params: {},
+ pagination: { limit: 25 },
+ debug: true,
+ });
+ });
+
+ it("rejects a draft without a provider", () => {
+ expect(() =>
+ profileSamplePayload(
+ { profile: "events", query },
+ { query, options: {} },
+ ),
+ ).toThrow("Cannot sample a profile without a provider");
+ });
+});
diff --git a/packages/ui/src/profiles/profileSamplePayload.ts b/packages/ui/src/profiles/profileSamplePayload.ts
new file mode 100644
index 00000000..7de119a9
--- /dev/null
+++ b/packages/ui/src/profiles/profileSamplePayload.ts
@@ -0,0 +1,17 @@
+import type { QueryBrowserRequest } from "../data/query-browser/QueryBrowser.types";
+import { sampleRequestProfile } from "./jsonPathSample";
+import type { ProfileWizardDraft } from "./profileWizardModel";
+
+export function profileSamplePayload(
+ draft: ProfileWizardDraft,
+ request: QueryBrowserRequest,
+) {
+ const profile = sampleRequestProfile(draft);
+ if (!profile) throw new Error("Cannot sample a profile without a provider");
+ return {
+ profile,
+ params: {},
+ ...(request.pagination ? { pagination: request.pagination } : {}),
+ ...(request.debug ? { debug: true } : {}),
+ };
+}
diff --git a/packages/ui/src/profiles/profileWizard.test.tsx b/packages/ui/src/profiles/profileWizard.test.tsx
new file mode 100644
index 00000000..b943a5ac
--- /dev/null
+++ b/packages/ui/src/profiles/profileWizard.test.tsx
@@ -0,0 +1,185 @@
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it, vi } from "vitest";
+import { ProfileFieldManager } from "./profileFieldManager";
+import {
+ applyVisibleFieldSelection,
+ filterProfileFields,
+ patchProfileField,
+ PROFILE_COLUMN_FORMAT_OPTIONS,
+ PROFILE_COLUMN_UNIT_OPTIONS,
+ profileWizardStepReady,
+ profileWizardSteps,
+ withProfileLimits,
+ type ProfileColumn,
+} from "./profileWizardModel";
+
+const discoveredFields: ProfileColumn[] = [
+ { name: "@timestamp", type: "datetime" },
+ ...Array.from({ length: 125 }, (_, index) => ({
+ name: `field_${String(index + 1).padStart(3, "0")}`,
+ type: index % 2 === 0 ? "string" : "number",
+ })),
+];
+
+describe("profile wizard flow", () => {
+ it("uses four task-focused steps instead of exposing the raw schema", () => {
+ expect(profileWizardSteps).toEqual([
+ { id: "source", label: "Choose source", description: "Connection" },
+ { id: "query", label: "Explore & sample", description: "Query" },
+ { id: "fields", label: "Name & shape", description: "Fields" },
+ { id: "review", label: "Review", description: "Save" },
+ ]);
+ });
+});
+
+describe("writing the row caps onto a draft", () => {
+ const draft = { profile: "os", limits: { maxExportRows: 250000 } };
+
+ it("stores the caps the profile sets for itself", () => {
+ expect(withProfileLimits(draft, { pageSize: 200 })).toEqual({
+ profile: "os",
+ limits: { pageSize: 200 },
+ });
+ });
+
+ it("leaves no block on a profile that caps nothing", () => {
+ expect(withProfileLimits(draft, undefined)).toEqual({ profile: "os" });
+ expect(withProfileLimits(draft, undefined)).not.toHaveProperty("limits");
+ });
+});
+
+describe("advancing past the query step", () => {
+ const sampled: ProfileColumn[] = [{ name: "@timestamp", type: "datetime" }];
+
+ it("accepts a raw query once fields have been sampled", () => {
+ expect(
+ profileWizardStepReady("query", { query: "select 1" }, sampled),
+ ).toBe(true);
+ });
+
+ it("accepts a structured search, which stores no raw query at all", () => {
+ expect(
+ profileWizardStepReady(
+ "query",
+ { query: "", provider: { options: { search: { query: { op: "bool" } } } } },
+ sampled,
+ ),
+ ).toBe(true);
+ });
+
+ it("blocks a draft that says neither", () => {
+ expect(
+ profileWizardStepReady("query", { query: " ", provider: {} }, sampled),
+ ).toBe(false);
+ });
+
+ it("blocks a query that has never been sampled", () => {
+ expect(profileWizardStepReady("query", { query: "select 1" }, [])).toBe(
+ false,
+ );
+ });
+});
+
+describe("large profile field sets", () => {
+ it("filters every discovered field by search, type, and selection state", () => {
+ const selectedNames = new Set(
+ discoveredFields.slice(0, 48).map((field) => field.name),
+ );
+
+ expect(
+ filterProfileFields(discoveredFields, selectedNames, {
+ query: "field_12",
+ type: "number",
+ selection: "unselected",
+ }).map((field) => field.name),
+ ).toEqual(["field_120", "field_122", "field_124"]);
+ });
+
+ it("bulk-selects only visible fields while preserving configured metadata", () => {
+ const configured = [
+ {
+ name: "@timestamp",
+ type: "datetime",
+ kind: "timestamp",
+ label: "Observed at",
+ },
+ ];
+ const next = applyVisibleFieldSelection(
+ discoveredFields,
+ configured,
+ new Set(["@timestamp", "field_002"]),
+ true,
+ );
+
+ expect(next).toEqual([
+ {
+ name: "@timestamp",
+ type: "datetime",
+ kind: "timestamp",
+ label: "Observed at",
+ },
+ { name: "field_002", type: "number" },
+ ]);
+ });
+
+ it("patches an edited field without dropping opaque schema properties", () => {
+ expect(
+ patchProfileField(
+ {
+ name: "duration_ms",
+ type: "number",
+ format: "float",
+ vendor: { source: "sample" },
+ },
+ { label: "Duration", width: 140, hidden: true },
+ ),
+ ).toEqual({
+ name: "duration_ms",
+ type: "number",
+ label: "Duration",
+ format: "float",
+ width: 140,
+ hidden: true,
+ vendor: { source: "sample" },
+ });
+ });
+
+ it("renders the full selection summary and the active field editor", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+
+ expect(html).toContain("48 of 126 fields selected");
+ expect(html).toContain("Search 126 fields");
+ expect(html).toContain("Field editor");
+ expect(html).toContain("Display label");
+ expect(html).toContain("CEL expression");
+ expect(html).toContain("@timestamp");
+ });
+
+ it("uses canonical Format and Unit dropdowns with explanatory help", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ for (const option of [
+ ...PROFILE_COLUMN_FORMAT_OPTIONS,
+ ...PROFILE_COLUMN_UNIT_OPTIONS,
+ ]) {
+ expect(html).toContain(`value="${option.value}"`);
+ }
+ expect(html).toContain("independent of Type");
+ expect(html).toContain("Max width (characters)");
+ });
+});
diff --git a/packages/ui/src/profiles/profileWizard.tsx b/packages/ui/src/profiles/profileWizard.tsx
new file mode 100644
index 00000000..dd56b303
--- /dev/null
+++ b/packages/ui/src/profiles/profileWizard.tsx
@@ -0,0 +1,308 @@
+import { Button } from "../components/button";
+import type { ClickyNode } from "../data/Clicky";
+import { Modal } from "../overlay/Modal";
+import type { ResolvedOperation } from "../rpc/types";
+import type { OperationsApiClient } from "../rpc/useOperations";
+import { useQuery } from "@tanstack/react-query";
+import { useDeferredValue, useMemo, useState } from "react";
+import { ProfileWizardQueryStep, type ProfileSample } from "./profileWizardQueryStep";
+import {
+ profileConnectionID,
+ profileWizardErrorMessage,
+ profileWizardStepReady,
+ profileWizardSteps,
+ providerTypeFromConnectionLabel,
+ type ProfileColumn,
+ type ProfileWizardDraft,
+} from "./profileWizardModel";
+import {
+ FieldsStep,
+ ReviewStep,
+ SourceStep,
+ WizardProgress,
+ type ConnectionChoice,
+} from "./profileWizardSteps";
+import { stepHelp } from "./profileWizardHelp";
+
+type ProfileWizardProps = {
+ client: OperationsApiClient;
+ action: ResolvedOperation;
+ initialValue: Record;
+ onClose: () => void;
+ onSuccess: () => void | Promise;
+};
+
+export function ProfileWizard({
+ client,
+ action,
+ initialValue,
+ onClose,
+ onSuccess,
+}: ProfileWizardProps) {
+ const initialDraft = useMemo(() => cloneInitialDraft(initialValue), [initialValue]);
+ const [stepIndex, setStepIndex] = useState(0);
+ const [draft, setDraft] = useState(initialDraft);
+ const [discovered, setDiscovered] = useState(
+ initialDraft.columns ?? [],
+ );
+ const [activeField, setActiveField] = useState(
+ initialDraft.columns?.[0]?.name ?? "",
+ );
+ const [connectionSearch, setConnectionSearch] = useState("");
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState("");
+ const deferredConnectionSearch = useDeferredValue(connectionSearch);
+ const connections = useQuery({
+ queryKey: ["profile-wizard-connections", deferredConnectionSearch],
+ queryFn: () => {
+ if (!client.lookupFilterOptions) {
+ throw new Error("Connection lookup is unavailable");
+ }
+ return client.lookupFilterOptions(
+ "/api/v1/connection",
+ "GET",
+ "connection",
+ deferredConnectionSearch,
+ );
+ },
+ retry: 0,
+ });
+ const connectionChoices = useMemo(
+ () =>
+ Object.entries(connections.data?.options ?? {})
+ .map(([value, node]) => connectionChoice(value, node))
+ .filter((choice): choice is ConnectionChoice => choice != null)
+ .sort((left, right) => left.name.localeCompare(right.name)),
+ [connections.data?.options],
+ );
+ const step = profileWizardSteps[stepIndex];
+ // stepIndex only ever moves between 0 and the last step, so an out-of-range
+ // one is a broken navigation guard rather than a state the UI should render
+ // around — say so instead of rendering a wizard with no current step.
+ if (!step) throw new Error(`wizard step ${stepIndex} is out of range`);
+ const connectionID = profileConnectionID(draft.provider?.connection ?? "");
+ const currentStepReady = profileWizardStepReady(
+ step.id,
+ draft,
+ discovered,
+ );
+
+ const updateQueryDraft = (next: ProfileWizardDraft) => {
+ if (next.query !== draft.query) setDiscovered([]);
+ setDraft(next);
+ };
+
+ const acceptSample = ({ columns }: ProfileSample) => {
+ setDiscovered(columns);
+ setActiveField((current) =>
+ columns.some((field) => field.name === current)
+ ? current
+ : (columns[0]?.name ?? ""),
+ );
+ setDraft((current) => {
+ const configuredByName = new Map(
+ (current.columns ?? []).map((field) => [field.name, field]),
+ );
+ return {
+ ...current,
+ columns:
+ configuredByName.size === 0
+ ? columns
+ : columns
+ .filter((field) => configuredByName.has(field.name))
+ .map((field) => ({
+ ...field,
+ ...configuredByName.get(field.name),
+ })),
+ };
+ });
+ };
+
+ const save = async () => {
+ setSaving(true);
+ setError("");
+ try {
+ if (!client.submitForm) {
+ throw new Error("Profile creation is unavailable");
+ }
+ const response = await client.submitForm(action.path, action.method, draft);
+ if (!response.success) {
+ throw new Error(
+ response.error || response.message || "Profile creation failed",
+ );
+ }
+ await onSuccess();
+ } catch (saveError) {
+ setError(profileWizardErrorMessage(saveError, "Profile creation failed"));
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+ 0 || draft.query
+ ? {
+ title: "Discard this profile?",
+ message: "Your query and field configuration will be lost.",
+ confirmLabel: "Discard profile",
+ }
+ : false
+ }
+ title="Build a profile"
+ subtitle={
+ {
+ if (next < stepIndex) setStepIndex(next);
+ }}
+ />
+ }
+ size="full"
+ className="h-[calc(100dvh-2rem)]"
+ scrollBody={false}
+ footer={
+
+
+ Step {stepIndex + 1} of {profileWizardSteps.length}
+
+ {error ? (
+
{error}
+ ) : null}
+
+
+ Cancel
+
+ {stepIndex > 0 ? (
+ setStepIndex((current) => current - 1)}
+ >
+ Back
+
+ ) : null}
+ {stepIndex < profileWizardSteps.length - 1 ? (
+ setStepIndex((current) => current + 1)}
+ >
+ Continue
+
+ ) : (
+ void save()}
+ >
+ {saving ? "Saving…" : "Save profile"}
+
+ )}
+
+
+ }
+ >
+
+
+
+ {step.description}
+
+
{step.label}
+
+ {stepHelp[step.id]}
+
+
+
+ {step.id === "source" ? (
+
{
+ if (choice.value === draft.provider?.connection) return;
+ setDraft({
+ ...(draft.namespace !== undefined
+ ? { namespace: draft.namespace }
+ : {}),
+ provider: {
+ type: choice.providerType,
+ connection: choice.value,
+ },
+ });
+ setDiscovered([]);
+ setActiveField("");
+ }}
+ />
+ ) : null}
+ {step.id === "query" && connectionID ? (
+
+ ) : null}
+ {step.id === "fields" ? (
+
+ ) : null}
+ {step.id === "review" ? (
+
+ ) : null}
+
+
+
+ );
+}
+
+function cloneInitialDraft(value: Record): ProfileWizardDraft {
+ const draft = value as ProfileWizardDraft;
+ return {
+ ...draft,
+ provider: draft.provider ? { ...draft.provider } : {},
+ columns: draft.columns?.map((column) => ({ ...column })) ?? [],
+ };
+}
+
+function connectionChoice(
+ value: string,
+ node: ClickyNode,
+): ConnectionChoice | null {
+ const label = clickyNodeText(node) || value;
+ const providerType = providerTypeFromConnectionLabel(label);
+ if (!providerType) return null;
+ return {
+ value,
+ label,
+ name: profileConnectionID(value) ?? label,
+ providerType,
+ };
+}
+
+function clickyNodeText(node: ClickyNode): string {
+ if (node.plain) return node.plain;
+ if (node.text) return node.text;
+ return (node.children ?? []).map(clickyNodeText).join("");
+}
diff --git a/packages/ui/src/profiles/profileWizardHelp.ts b/packages/ui/src/profiles/profileWizardHelp.ts
new file mode 100644
index 00000000..72920777
--- /dev/null
+++ b/packages/ui/src/profiles/profileWizardHelp.ts
@@ -0,0 +1,13 @@
+/**
+ * What each wizard step asks the author to do.
+ *
+ * Kept apart from profileWizardSteps.tsx so that module exports only components
+ * (react/only-export-components).
+ */
+
+export const stepHelp = {
+ source: "Start with a saved connection. We will tailor the query workspace to its provider.",
+ query: "Browse the catalog, write a query, and run a safe sample to discover fields.",
+ fields: "Name the profile, choose the fields to expose, and tune how each field is displayed.",
+ review: "Check the source, query, and field shape before creating the profile.",
+};
diff --git a/packages/ui/src/profiles/profileWizardModel.ts b/packages/ui/src/profiles/profileWizardModel.ts
new file mode 100644
index 00000000..ad0d9367
--- /dev/null
+++ b/packages/ui/src/profiles/profileWizardModel.ts
@@ -0,0 +1,394 @@
+import type { ProfileRowLimits } from "./connectionBrowserModel";
+
+/** How a column is filtered at the backend; every field overrides an inference
+ * the server would otherwise make from the column itself. */
+export type ProfileColumnFilter = {
+ /** Backend field the selection applies to; blank infers it from the column. */
+ field?: string;
+ /** terms, range, time, boolean, text or none; blank infers it from the type. */
+ kind?: string;
+ /** Enumerated values, replacing the backend lookup. */
+ options?: string[];
+ /** Ask the backend for this field's distinct values. */
+ lookup?: boolean;
+ /** How many of those values the control offers before the rest are typed for. */
+ limit?: number;
+ /** Allow several values at once. */
+ multi?: boolean;
+ /** Offer no filter while keeping the column rendered. */
+ disabled?: boolean;
+};
+
+/**
+ * A patch of T: every key optional, and explicitly settable to `undefined` to
+ * clear it.
+ *
+ * Not Partial. Under `exactOptionalPropertyTypes` an optional key may be
+ * absent but not present-and-undefined, and "clear this field" — which is what
+ * every editor control sends when its input goes empty — is precisely a present
+ * `undefined`. The patch appliers drop those keys; the type has to permit them.
+ */
+export type Patch = { [K in keyof T]?: T[K] | undefined };
+
+/**
+ * Applies a patch, removing the keys it clears instead of leaving them present
+ * and undefined.
+ *
+ * Spreading a patch would keep them, and these objects are serialized to the
+ * server, where a present `field: undefined` is a key to interpret rather than
+ * an absent one.
+ */
+export function applyPatch(value: T, patch: Patch): T {
+ const next = { ...value };
+ for (const [key, entry] of Object.entries(patch)) {
+ if (entry === undefined) delete next[key as keyof T];
+ else next[key as keyof T] = entry as T[keyof T];
+ }
+ return next;
+}
+
+export type ProfileColumn = {
+ name: string;
+ source?: string;
+ label?: string;
+ type?: string;
+ kind?: string;
+ format?: string;
+ unit?: string;
+ width?: number;
+ cel?: string;
+ /** Path computing the value, rooted at the row or at `source` when set. */
+ jsonpath?: string;
+ hidden?: boolean;
+ filter?: ProfileColumnFilter;
+ [key: string]: unknown;
+};
+
+export type ProfileProvider = {
+ type?: string;
+ connection?: string;
+ options?: Record;
+ [key: string]: unknown;
+};
+
+/** The parameter types the profile schema accepts. */
+export type ParamDraftType = "string" | "number" | "boolean" | "date" | "enum" | "list";
+
+/** filter, limit, offset, time-from or time-to; empty behaves as filter. */
+export type ParamDraftRole = "filter" | "limit" | "offset" | "time-from" | "time-to";
+
+export type ParamDraft = {
+ name?: string | undefined;
+ label?: string | undefined;
+ type?: ParamDraftType | undefined;
+ role?: ParamDraftRole | undefined;
+ default?: unknown | undefined;
+ options?: string[] | undefined;
+ required?: boolean | undefined;
+ description?: string | undefined;
+ /** Value rewrite; {value} is the supplied value. */
+ template?: string | undefined;
+ /** Backend field a list parameter filters on. Setting it lets a value be
+ * excluded as well as included; it requires an OpenSearch-backed provider. */
+ field?: string | undefined;
+};
+
+/** PARAM_TYPES_WITH_OPTIONS are the types whose values come from a fixed set, so
+ * the editor offers an options picker for them. */
+export const PARAM_TYPES_WITH_OPTIONS: ParamDraftType[] = ["enum", "list"];
+
+export function paramHasOptions(param: ParamDraft): boolean {
+ return param.type !== undefined && PARAM_TYPES_WITH_OPTIONS.includes(param.type);
+}
+
+export type ProfileWizardDraft = Record & {
+ namespace?: string | undefined;
+ profile?: string | undefined;
+ /** Overrides the sidebar/picker glyph the provider type would otherwise give. */
+ icon?: string | undefined;
+ provider?: ProfileProvider | undefined;
+ query?: string | undefined;
+ columns?: ProfileColumn[] | undefined;
+ /** The inputs a run binds — by name in the query, the provider options or the
+ * connection. The query workspace previews against their defaults. */
+ params?: ParamDraft[] | undefined;
+ /** The row caps this profile sets for itself; unset ones take their default. */
+ limits?: ProfileRowLimits | undefined;
+};
+
+/**
+ * A profile that caps nothing carries no block at all, so the defaults are what
+ * it inherits rather than what it froze at the moment it was edited. Both draft
+ * hosts write the caps through this, so neither can leave an empty map behind.
+ */
+export function withProfileLimits(
+ draft: T,
+ limits: ProfileRowLimits | undefined,
+): T {
+ const next = { ...draft };
+ if (limits) next.limits = limits;
+ else delete next.limits;
+ return next;
+}
+
+export type ProfileFieldFilter = {
+ query: string;
+ type: string;
+ selection: "all" | "selected" | "unselected";
+};
+
+export const PROFILE_COLUMN_FORMAT_OPTIONS = [
+ { value: "date", label: "Date/time" },
+ { value: "float", label: "Number" },
+ { value: "duration", label: "Duration" },
+ { value: "bytes", label: "Bytes" },
+ { value: "currency", label: "Currency" },
+] as const;
+
+export const PROFILE_COLUMN_UNIT_OPTIONS = [
+ { value: "none", label: "Compact count" },
+ { value: "short", label: "Short number" },
+ { value: "percent", label: "Percent (0-100)" },
+ { value: "percentunit", label: "Percent (0-1)" },
+ { value: "bytes", label: "Bytes (IEC)" },
+ { value: "decbytes", label: "Bytes (SI)" },
+ { value: "Bps", label: "Bytes/sec" },
+ { value: "binBps", label: "Binary bytes/sec" },
+ { value: "ms", label: "Milliseconds" },
+ { value: "s", label: "Seconds" },
+] as const;
+
+export const profileWizardSteps = [
+ { id: "source", label: "Choose source", description: "Connection" },
+ { id: "query", label: "Explore & sample", description: "Query" },
+ { id: "fields", label: "Name & shape", description: "Fields" },
+ { id: "review", label: "Review", description: "Save" },
+] as const;
+
+export function filterProfileFields(
+ fields: ProfileColumn[],
+ selectedNames: Set,
+ filter: ProfileFieldFilter,
+): ProfileColumn[] {
+ const query = filter.query.trim().toLowerCase();
+ return fields.filter((field) => {
+ const selected = selectedNames.has(field.name);
+ if (filter.selection === "selected" && !selected) return false;
+ if (filter.selection === "unselected" && selected) return false;
+ if (filter.type && field.type !== filter.type) return false;
+ if (!query) return true;
+ return `${field.name} ${field.label ?? ""}`.toLowerCase().includes(query);
+ });
+}
+
+/**
+ * Every field the editors can show, each in its configured form. A discovered
+ * field is a snapshot of what the source reported — the configured one carries
+ * the user's edits, so it is the only version safe to render into a control or
+ * to patch on top of.
+ *
+ * The configuration owns the order, because it is the order the profile renders
+ * its columns in and the one the user drags rows into. A discovered field that
+ * was never configured (not selected, or dropped from the profile) has no place
+ * of its own, so it keeps the one it had: it is anchored just after whichever
+ * configured field preceded it in the sample.
+ */
+export function availableProfileFields(
+ discovered: ProfileColumn[],
+ configured: ProfileColumn[],
+): ProfileColumn[] {
+ const configuredByName = new Map(
+ configured.map((field) => [field.name, field]),
+ );
+ const configuredBySource = new Map(
+ configured
+ .filter((field) => field.source)
+ .map((field) => [field.source as string, field]),
+ );
+ const ordered = [...configured];
+ let anchor = 0;
+ for (const field of discovered) {
+ const match =
+ configuredByName.get(field.name) ?? configuredBySource.get(field.name);
+ if (match) {
+ anchor = ordered.indexOf(match) + 1;
+ continue;
+ }
+ ordered.splice(anchor, 0, field);
+ anchor += 1;
+ }
+ return ordered;
+}
+
+/**
+ * Moves the named column onto the target's position, shifting the columns
+ * between them — the drop semantics the field grid's row drag needs. Columns
+ * are addressed by name because a drag only ever carries the row's identity.
+ * A name that is not configured has no position, so the order is returned
+ * unchanged rather than guessed at.
+ */
+export function reorderProfileColumns(
+ columns: ProfileColumn[],
+ sourceName: string,
+ targetName: string,
+): ProfileColumn[] {
+ const from = columns.findIndex((column) => column.name === sourceName);
+ const to = columns.findIndex((column) => column.name === targetName);
+ if (from < 0 || to < 0 || from === to) return columns;
+ const next = [...columns];
+ next.splice(to, 0, ...next.splice(from, 1));
+ return next;
+}
+
+/**
+ * Selects or deselects the named fields, keeping the configured order intact —
+ * a re-selected field returns to where the grid already showed it rather than
+ * to the back of the list or to its position in the sample.
+ */
+export function applyVisibleFieldSelection(
+ discovered: ProfileColumn[],
+ configured: ProfileColumn[],
+ visibleNames: Set,
+ selected: boolean,
+): ProfileColumn[] {
+ const selectedNames = new Set(configured.map((field) => field.name));
+ for (const name of visibleNames) {
+ if (selected) selectedNames.add(name);
+ else selectedNames.delete(name);
+ }
+ return availableProfileFields(discovered, configured).filter((field) =>
+ selectedNames.has(field.name),
+ );
+}
+
+/** The control kinds a column filter can render as, with the server's own
+ * wording. Mirrors query.ColumnFilterKindValues() and the x-enum-labels the
+ * profile schema carries for them. */
+export const PROFILE_FILTER_KIND_OPTIONS = [
+ { value: "terms", label: "Value selection" },
+ { value: "range", label: "Numeric range" },
+ { value: "time", label: "Time range" },
+ { value: "boolean", label: "Yes/no" },
+ { value: "text", label: "Substring" },
+ { value: "none", label: "Not filterable" },
+] as const;
+
+/** The server's own default, so an unset limit can be shown for what it does.
+ * Mirrors query.DefaultFilterLookupLimit. */
+export const PROFILE_FILTER_DEFAULT_LIMIT = 50;
+
+/** Mirrors query.MaxFilterLookupLimit — the largest head a lookup will serve. */
+export const PROFILE_FILTER_MAX_LIMIT = 200;
+
+/**
+ * Merges one filter knob into a column's filter block, dropping the block once
+ * nothing is left in it.
+ *
+ * The distinction matters on save: `filter: {}` is a declaration that declares
+ * nothing, and the server reads a present-but-empty block differently from an
+ * absent one — unchecking the last box has to mean "infer this again", not
+ * "override it with silence".
+ */
+export function patchColumnFilter(
+ filter: ProfileColumnFilter | undefined,
+ patch: Patch,
+): ProfileColumnFilter | undefined {
+ const merged = Object.fromEntries(
+ Object.entries({ ...filter, ...patch }).filter(([, value]) => value !== undefined),
+ ) as ProfileColumnFilter;
+ return Object.keys(merged).length ? merged : undefined;
+}
+
+/** What the server picks for a column that declares no filter kind. Mirrors
+ * columnFilterKindFor; text is absent there on purpose, so it is here too. */
+export function inferredFilterKind(column: ProfileColumn): string {
+ switch (column.type) {
+ case "number":
+ case "duration":
+ case "bytes":
+ return "range";
+ case "datetime":
+ return "time";
+ case "boolean":
+ return "boolean";
+ case "key_value":
+ case "key_values":
+ case "json":
+ return "none";
+ default:
+ return "terms";
+ }
+}
+
+export function patchProfileField(
+ field: ProfileColumn,
+ patch: Patch,
+): ProfileColumn {
+ return Object.fromEntries(
+ Object.entries({ ...field, ...patch }).filter(
+ ([, value]) => value !== undefined,
+ ),
+ ) as ProfileColumn;
+}
+
+export function renameProfileField(
+ field: ProfileColumn,
+ name: string,
+): ProfileColumn {
+ const source = field.source ?? (field.cel ? undefined : field.name);
+ return patchProfileField(field, {
+ name,
+ source: source === name ? undefined : source,
+ });
+}
+
+export function providerTypeFromConnectionLabel(label: string): string | null {
+ const match = label.match(/\(([^()]+)\)\s*$/);
+ return match?.[1]?.trim() || null;
+}
+
+export function profileConnectionID(value: string): string | null {
+ const prefix = "connection://";
+ if (!value.startsWith(prefix)) return null;
+ return value.slice(prefix.length).trim() || null;
+}
+
+export function profileWizardErrorMessage(
+ error: unknown,
+ fallback: string,
+): string {
+ return error instanceof Error && error.message.trim()
+ ? error.message.trim()
+ : fallback;
+}
+
+/**
+ * A profile says what to fetch either as a raw query or as a structured search
+ * specification — never both, which is why this is an either/or rather than a
+ * check on `query` alone.
+ */
+export function profileWizardHasQuery(draft: ProfileWizardDraft): boolean {
+ return Boolean(
+ draft.query?.trim() || draft.provider?.options?.search !== undefined,
+ );
+}
+
+export function profileWizardStepReady(
+ step: (typeof profileWizardSteps)[number]["id"],
+ draft: ProfileWizardDraft,
+ discovered: ProfileColumn[],
+): boolean {
+ if (step === "source") {
+ return Boolean(draft.provider?.connection && draft.provider.type);
+ }
+ if (step === "query") {
+ return Boolean(profileWizardHasQuery(draft) && discovered.length > 0);
+ }
+ if (step === "fields") {
+ return Boolean(draft.profile?.trim() && draft.columns?.length);
+ }
+ return (
+ Boolean(draft.profile?.trim() && draft.provider?.connection) &&
+ discovered.length > 0
+ );
+}
diff --git a/packages/ui/src/profiles/profileWizardQueryStep.test.tsx b/packages/ui/src/profiles/profileWizardQueryStep.test.tsx
new file mode 100644
index 00000000..2b226228
--- /dev/null
+++ b/packages/ui/src/profiles/profileWizardQueryStep.test.tsx
@@ -0,0 +1,100 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it, vi } from "vitest";
+import type { BrowserDescriptor } from "./connectionBrowserModel";
+import { EsCompileRequest } from "./esQueryCompile";
+import { ProfileWizardQueryStep } from "./profileWizardQueryStep";
+import type { ProfileWizardDraft } from "./profileWizardModel";
+
+// The compile request only leaves the browser once effects run, which server
+// rendering never does — so the wiring is asserted on what the hook was handed.
+const compileInputs = vi.hoisted(() => [] as EsCompileRequest[]);
+vi.mock("./esQueryCompile", async (importOriginal) => {
+ const original = await importOriginal();
+ return {
+ ...original,
+ useCompiledSearch: (input: EsCompileRequest) => {
+ compileInputs.push(input);
+ return original.useCompiledSearch(input);
+ },
+ };
+});
+
+const connectionID = "os-stub";
+
+const descriptor: BrowserDescriptor = {
+ kind: "query",
+ provider: "opensearch",
+ language: "json",
+ catalog: true,
+ targetLabel: "Index",
+ optionsSchema: {
+ type: "object",
+ properties: {
+ search: {
+ type: "object",
+ "x-clicky-component": "es-query-builder",
+ "x-es-operators": [
+ { op: "term", label: "term", arity: "single", fieldTypes: ["keyword"] },
+ ],
+ },
+ },
+ },
+};
+
+const draft = (params: ProfileWizardDraft["params"]): ProfileWizardDraft => ({
+ profile: "kenya",
+ provider: {
+ type: "opensearch",
+ connection: `connection://${connectionID}`,
+ options: {
+ index: "logs",
+ search: {
+ query: { op: "term", field: "service.name", value: "{{.params.service}}" },
+ },
+ },
+ },
+ ...(params ? { params } : {}),
+});
+
+const renderStep = (params: ProfileWizardDraft["params"]) => {
+ compileInputs.length = 0;
+ const client = new QueryClient();
+ // The step renders nothing until the browser descriptor resolves, and server
+ // rendering never fetches — so it is seeded rather than awaited.
+ client.setQueryData(["profile-wizard-descriptor", connectionID], descriptor);
+ renderToStaticMarkup(
+
+ {}}
+ onSample={() => {}}
+ />
+ ,
+ );
+ return compileInputs;
+};
+
+// The editor's Source section previews through this step, and the preview is
+// compiled server-side: without the declared parameter values a {{.params.…}}
+// operand compiles to the compiler's refusal to guess rather than to the DSL a
+// run produces.
+describe("ProfileWizardQueryStep compilation", () => {
+ it("compiles the specification against the declared parameter defaults", () => {
+ const inputs = renderStep([
+ { name: "service", type: "enum", default: "payments" },
+ { name: "since", type: "string", default: "now-1h", role: "time-from" },
+ ]);
+ expect(inputs.length).toBeGreaterThan(0);
+ expect(inputs[0]?.params).toEqual({ service: "payments", since: "now-1h" });
+ expect(inputs[0]?.roles).toEqual({ since: "time-from" });
+ });
+
+ it("sends no parameter values when the profile declares none", () => {
+ const inputs = renderStep(undefined);
+ expect(inputs.length).toBeGreaterThan(0);
+ expect(inputs[0]?.params).toEqual({});
+ });
+});
diff --git a/packages/ui/src/profiles/profileWizardQueryStep.tsx b/packages/ui/src/profiles/profileWizardQueryStep.tsx
new file mode 100644
index 00000000..9f61c648
--- /dev/null
+++ b/packages/ui/src/profiles/profileWizardQueryStep.tsx
@@ -0,0 +1,269 @@
+import type { QueryBrowserResult } from "../data/query-browser/QueryBrowser.types";
+import { useQuery } from "@tanstack/react-query";
+import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
+import {
+ browserBaseUrl,
+ fetchJSON,
+ mergeProviderOptions,
+ queryBrowserOptionsSchema,
+ useInspection,
+ type BrowserDescriptor,
+} from "./connectionBrowserModel";
+import { ConnectionQueryWorkspace } from "./connectionQueryWorkspace";
+import { profileApiPath } from "./profileApi";
+import { profileSamplePayload } from "./profileSamplePayload";
+import type { EsSearch } from "./esQueryBuilderModel";
+import {
+ profileWizardErrorMessage,
+ withProfileLimits,
+ type ProfileColumn,
+ type ProfileWizardDraft,
+} from "./profileWizardModel";
+import { supportsQueryBuilder } from "./connectionQueryWorkspaceModel";
+import { defaultParamValues, paramRoles } from "./esQueryBuilderForm";
+
+type SampleResult = QueryBrowserResult & {
+ columns: ProfileColumn[];
+ renderedQuery: string;
+};
+
+/** What a sample yields: the discovered column shape plus the rows it came
+ * from, so the editor can preview the configured columns against real data. */
+export type ProfileSample = {
+ columns: ProfileColumn[];
+ rows: Record[];
+ sourceDraft: ProfileWizardDraft;
+};
+
+type ProfileWizardQueryStepProps = {
+ connectionID: string;
+ draft: ProfileWizardDraft;
+ discovered: ProfileColumn[];
+ onDraftChange: (draft: ProfileWizardDraft) => void;
+ onSample: (sample: ProfileSample) => void;
+};
+
+export function ProfileWizardQueryStep({
+ connectionID,
+ draft,
+ discovered,
+ onDraftChange,
+ onSample,
+}: ProfileWizardQueryStepProps) {
+ const baseUrl = browserBaseUrl(connectionID);
+ const descriptor = useQuery({
+ queryKey: ["profile-wizard-descriptor", connectionID],
+ queryFn: () => fetchJSON(baseUrl),
+ retry: 0,
+ });
+ const providerOptions = useMemo(
+ () => ({ ...draft.provider?.options }),
+ [draft.provider?.options],
+ );
+ const [query, setQuery] = useState(draft.query ?? "");
+ const [liveOptions, setLiveOptions] = useState(providerOptions);
+ const [catalogOptions, setCatalogOptions] = useState>(
+ {},
+ );
+ const [selectedDatabase, setSelectedDatabase] = useState("");
+
+ // The starter query is for a source whose artifact is the query. Where the
+ // workspace builds filters instead, the specification is the artifact and a
+ // query seeded beside it would be the pair the server rejects.
+ const rawArtifact =
+ descriptor.data !== undefined && !supportsQueryBuilder(descriptor.data);
+ useEffect(() => {
+ if (rawArtifact && !query && descriptor.data?.defaultQuery) {
+ setQuery(descriptor.data.defaultQuery);
+ onDraftChange({ ...draft, query: descriptor.data.defaultQuery });
+ }
+ }, [descriptor.data?.defaultQuery, draft, onDraftChange, query, rawArtifact]);
+
+ const explicitTargetKind = liveOptions.targetKind ?? providerOptions.targetKind;
+ const inspection = useInspection({
+ cacheKey: "profile-wizard-inspection",
+ id: connectionID,
+ baseUrl,
+ enabled: descriptor.data?.catalog === true,
+ database: selectedDatabase,
+ fallbackDatabase: String(providerOptions.database ?? ""),
+ target: String(liveOptions.index ?? providerOptions.index ?? ""),
+ ...(typeof explicitTargetKind === "string"
+ ? { targetKind: explicitTargetKind }
+ : {}),
+ });
+ const browserOptions = useMemo(
+ () =>
+ mergeProviderOptions({
+ layers: [
+ descriptor.data?.initialOptions,
+ providerOptions,
+ catalogOptions,
+ ],
+ database: inspection.sqlDatabase,
+ keepTargetKind: true,
+ }),
+ [
+ catalogOptions,
+ descriptor.data?.initialOptions,
+ inspection.sqlDatabase,
+ providerOptions,
+ ],
+ );
+ const effectiveOptions = useCallback(
+ (options: Record) =>
+ mergeProviderOptions({
+ layers: [providerOptions, catalogOptions, options],
+ database: inspection.sqlDatabase,
+ }),
+ [catalogOptions, inspection.sqlDatabase, providerOptions],
+ );
+
+ if (descriptor.isLoading) {
+ return Loading connection browser… ;
+ }
+ if (descriptor.isError) {
+ return (
+
+ {profileWizardErrorMessage(
+ descriptor.error,
+ "Unable to load this connection browser",
+ )}
+
+ );
+ }
+ if (!descriptor.data) {
+ return (
+
+ This saved connection does not expose a query browser.
+
+ );
+ }
+
+ return (
+
+
+
+
Explore the source, then run a sample
+
+ Sampling discovers fields without saving the profile.
+
+
+
+ {discovered.length
+ ? `${discovered.length} fields discovered`
+ : "No sample yet"}
+
+
+
{
+ // Built from the merged options so a delete actually removes the key
+ // rather than being reinstated by a lower layer on the next merge.
+ const options = effectiveOptions(liveOptions);
+ if (transition.search) options.search = transition.search;
+ else delete options.search;
+ setQuery(transition.query);
+ onDraftChange({
+ ...draft,
+ query: transition.query,
+ provider: { ...draft.provider, options },
+ });
+ }}
+ params={draft.params ?? []}
+ onParamMappingChange={(edit) => {
+ const options = effectiveOptions(liveOptions);
+ options.search = edit.search;
+ setQuery("");
+ onDraftChange({
+ ...draft,
+ query: "",
+ params: edit.params,
+ provider: { ...draft.provider, options },
+ });
+ }}
+ paramValues={defaultParamValues(draft.params)}
+ paramRoles={paramRoles(draft.params)}
+ {...(draft.limits ? { limits: draft.limits } : {})}
+ onLimitsChange={(limits) => onDraftChange(withProfileLimits(draft, limits))}
+ compileBaseUrl={baseUrl}
+ className="min-h-0 flex-1"
+ onQueryChange={(nextQuery) => {
+ setQuery(nextQuery);
+ onDraftChange({ ...draft, query: nextQuery });
+ }}
+ onOptionsChange={(options) => {
+ setLiveOptions(options);
+ onDraftChange({
+ ...draft,
+ provider: {
+ ...draft.provider,
+ options: effectiveOptions(options),
+ },
+ });
+ }}
+ onCatalogSelect={(node) => {
+ const nextQuery = node.query ?? query;
+ const nextOptions = node.options ?? {};
+ setQuery(nextQuery);
+ setCatalogOptions(nextOptions);
+ setLiveOptions({ ...browserOptions, ...nextOptions });
+ onDraftChange({
+ ...draft,
+ query: nextQuery,
+ provider: {
+ ...draft.provider,
+ options: effectiveOptions({ ...browserOptions, ...nextOptions }),
+ },
+ });
+ }}
+ execute={async (request) => {
+ const nextDraft = {
+ ...draft,
+ query: request.query,
+ provider: {
+ ...draft.provider,
+ options: effectiveOptions(request.options),
+ },
+ };
+ onDraftChange(nextDraft);
+ const result = await fetchJSON(profileApiPath("profile/sample"), {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(profileSamplePayload(nextDraft, request)),
+ });
+ onSample({
+ columns: result.columns ?? [],
+ rows: result.rows ?? [],
+ sourceDraft: nextDraft,
+ });
+ return result;
+ }}
+ />
+
+ );
+}
+
+function QueryStepMessage({
+ children,
+ error = false,
+}: {
+ children: ReactNode;
+ error?: boolean;
+}) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/ui/src/profiles/profileWizardSteps.tsx b/packages/ui/src/profiles/profileWizardSteps.tsx
new file mode 100644
index 00000000..9fc8f0fa
--- /dev/null
+++ b/packages/ui/src/profiles/profileWizardSteps.tsx
@@ -0,0 +1,285 @@
+import type { ReactNode } from "react";
+import { ProfileFieldManager } from "./profileFieldManager";
+import {
+ profileConnectionID,
+ profileWizardErrorMessage,
+ profileWizardSteps,
+ type ProfileColumn,
+ type ProfileWizardDraft,
+} from "./profileWizardModel";
+
+export type ConnectionChoice = {
+ value: string;
+ label: string;
+ name: string;
+ providerType: string;
+};
+
+export function WizardProgress({
+ stepIndex,
+ onStepChange,
+}: {
+ stepIndex: number;
+ onStepChange: (index: number) => void;
+}) {
+ return (
+
+ {profileWizardSteps.map((step, index) => (
+
+ stepIndex}
+ aria-current={index === stepIndex ? "step" : undefined}
+ className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left ${index === stepIndex ? "bg-primary/10 text-foreground" : "text-muted-foreground"}`}
+ onClick={() => onStepChange(index)}
+ >
+
+ {index + 1}
+
+
+ {step.label}
+ {step.description}
+
+
+
+ ))}
+
+ );
+}
+
+export function SourceStep({
+ choices,
+ loading,
+ error,
+ truncated,
+ total,
+ search,
+ selected,
+ selectedType,
+ onSearchChange,
+ onSelect,
+}: {
+ choices: ConnectionChoice[];
+ loading: boolean;
+ error: unknown;
+ truncated?: boolean;
+ total?: number;
+ search: string;
+ selected: string;
+ selectedType: string;
+ onSearchChange: (search: string) => void;
+ onSelect: (choice: ConnectionChoice) => void;
+}) {
+ return (
+
+
+
+
+ Find a saved connection
+ onSearchChange(event.target.value)}
+ />
+
+
+
+ {choices.map((choice) => (
+
onSelect(choice)}
+ >
+
+ {choice.name}
+
+ Saved connection
+
+
+
+ {choice.providerType}
+
+
+ ))}
+ {loading ? (
+
+ Loading connections…
+
+ ) : null}
+ {error ? (
+
+ {profileWizardErrorMessage(error, "Unable to load connections")}
+
+ ) : null}
+ {!loading && !error && choices.length === 0 ? (
+
+ No saved connections match this search.
+
+ ) : null}
+
+ {truncated ? (
+
+ Showing the first {choices.length} of {total ?? "many"}. Search to narrow the list.
+
+ ) : null}
+
+
+
+ Selected source
+
+ {selected ? (
+ <>
+ {profileConnectionID(selected)}
+ {selectedType}
+
+ The next step loads this connection's catalog, query language,
+ and sampling controls.
+
+ >
+ ) : (
+
+ Choose a connection to begin. Connection credentials stay on the
+ server and are never copied into the profile.
+
+ )}
+
+
+ );
+}
+
+export function FieldsStep({
+ draft,
+ discovered,
+ activeField,
+ onDraftChange,
+ onActiveFieldChange,
+}: {
+ draft: ProfileWizardDraft;
+ discovered: ProfileColumn[];
+ activeField: string;
+ onDraftChange: (draft: ProfileWizardDraft) => void;
+ onActiveFieldChange: (name: string) => void;
+}) {
+ return (
+
+
+
onDraftChange({ ...draft, columns })}
+ onActiveNameChange={onActiveFieldChange}
+ />
+
+ );
+}
+
+export function ReviewStep({
+ draft,
+ discovered,
+}: {
+ draft: ProfileWizardDraft;
+ discovered: ProfileColumn[];
+}) {
+ const connection = profileConnectionID(draft.provider?.connection ?? "");
+ return (
+
+
+ {draft.namespace ? `Namespace: ${draft.namespace}` : "Default namespace"}
+
+
+ {draft.provider?.type || "Unknown provider"}
+
+
+
+
+
+ Fields
+
+
+ {draft.columns?.length ?? 0} of {discovered.length} included
+
+
+
+ {draft.columns?.filter((field) => field.hidden).length ?? 0} hidden
+
+
+
+ {(draft.columns ?? []).slice(0, 16).map((field) => (
+
+ {field.label || field.name}
+
+ {field.type || "auto"}
+
+
+ ))}
+ {(draft.columns?.length ?? 0) > 16 ? (
+
+ +{(draft.columns?.length ?? 0) - 16} more
+
+ ) : null}
+
+
+
+
+ Query
+
+
+ {draft.query}
+
+
+
+ );
+}
+
+function ReviewCard({
+ label,
+ value,
+ children,
+}: {
+ label: string;
+ value: ReactNode;
+ children: ReactNode;
+}) {
+ return (
+
+
+ {label}
+
+ {value}
+ {children}
+
+ );
+}
diff --git a/packages/ui/src/profiles/profileYaml.ts b/packages/ui/src/profiles/profileYaml.ts
new file mode 100644
index 00000000..a50dc068
--- /dev/null
+++ b/packages/ui/src/profiles/profileYaml.ts
@@ -0,0 +1,21 @@
+import { parse } from "yaml";
+import { stripSurroundingDashes } from "../lib/string";
+import type { ProfileWizardDraft } from "./profileWizardModel";
+
+export function parseProfileYamlDocument(value: string): ProfileWizardDraft {
+ const parsed = parse(value);
+ if (!isRecord(parsed)) throw new Error("Profile YAML must contain an object");
+ return parsed as ProfileWizardDraft;
+}
+
+export function profileYamlFilename(name?: string): string {
+ const safeName =
+ name === undefined
+ ? undefined
+ : stripSurroundingDashes(name.trim().replace(/[^a-zA-Z0-9._-]+/g, "-"));
+ return `${safeName || "profile"}.yaml`;
+}
+
+function isRecord(value: unknown): value is Record {
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
+}
diff --git a/packages/ui/src/profiles/prometheusResults.tsx b/packages/ui/src/profiles/prometheusResults.tsx
new file mode 100644
index 00000000..e6e620d2
--- /dev/null
+++ b/packages/ui/src/profiles/prometheusResults.tsx
@@ -0,0 +1,70 @@
+import { TimeseriesPanel } from "../data/TimeseriesPanel";
+import type { TimeseriesResponse, TimeseriesSeries } from "../data/TimeseriesPanel.model";
+import type { QueryBrowserResult } from "../data/query-browser/QueryBrowser.types";
+import { useMemo, type ReactNode } from "react";
+
+export function PrometheusResults({
+ result,
+ fallback,
+}: {
+ result: QueryBrowserResult;
+ fallback: ReactNode;
+}) {
+ const chart = useMemo(
+ () => prometheusSeries(result.rows ?? []),
+ [result.rows],
+ );
+ if (!chart) return fallback;
+ return (
+
+ {
+ const id = url.split("?")[0]?.split("/").filter(Boolean).pop() ?? "";
+ return chart.responses[id] ?? { id, points: [] };
+ }}
+ />
+ {fallback}
+
+ );
+}
+
+function prometheusSeries(rows: Record[]): {
+ series: TimeseriesSeries[];
+ responses: Record;
+} | null {
+ const withTime = rows.filter(
+ (row) => row.timestamp != null && typeof row.value === "number",
+ );
+ if (withTime.length < 2) return null;
+ const groups = new Map<
+ string,
+ { label: string; points: { at: string; value: number }[] }
+ >();
+ for (const row of withTime) {
+ const labels = Object.entries(row)
+ .filter(([key]) => key !== "timestamp" && key !== "value")
+ .sort(([a], [b]) => a.localeCompare(b));
+ const label =
+ labels.map(([key, value]) => `${key}=${String(value)}`).join(", ") ||
+ "value";
+ const group = groups.get(label) ?? { label, points: [] };
+ group.points.push({
+ at: new Date(String(row.timestamp)).toISOString(),
+ value: Number(row.value),
+ });
+ groups.set(label, group);
+ }
+ const series: TimeseriesSeries[] = [];
+ const responses: Record = {};
+ [...groups.values()].forEach((group, index) => {
+ const id = `series-${index}`;
+ series.push({ id, label: group.label });
+ responses[id] = { id, points: group.points };
+ });
+ return { series, responses };
+}
diff --git a/packages/ui/src/profiles/queryRowLimits.test.tsx b/packages/ui/src/profiles/queryRowLimits.test.tsx
new file mode 100644
index 00000000..f6565486
--- /dev/null
+++ b/packages/ui/src/profiles/queryRowLimits.test.tsx
@@ -0,0 +1,67 @@
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it } from "vitest";
+import { QueryRowLimits } from "./queryRowLimits";
+import { applyRowLimit } from "./queryRowLimitsModel";
+
+const defaults = { pageSize: 100, maxPageSize: 1000, maxExportRows: 100000 };
+
+describe("applyRowLimit", () => {
+ it("keeps the caps the author left alone", () => {
+ expect(
+ applyRowLimit({ pageSize: 25, maxExportRows: 5000 }, "maxExportRows", "250000"),
+ ).toEqual({ pageSize: 25, maxExportRows: 250000 });
+ });
+
+ it("drops a cleared cap so the profile falls back to the default", () => {
+ expect(
+ applyRowLimit({ pageSize: 25, maxExportRows: 5000 }, "maxExportRows", ""),
+ ).toEqual({ pageSize: 25 });
+ });
+
+ it("starts a limits block for a profile that had none", () => {
+ expect(applyRowLimit(undefined, "pageSize", "200")).toEqual({ pageSize: 200 });
+ });
+
+ it("leaves no block once the last cap is cleared", () => {
+ expect(applyRowLimit({ maxExportRows: 250000 }, "maxExportRows", "")).toBeUndefined();
+ });
+});
+
+describe("QueryRowLimits", () => {
+ const render = (props: Partial[0]> = {}) =>
+ renderToStaticMarkup(
+ {}}
+ defaults={defaults}
+ limits={{ maxExportRows: 250000 }}
+ onLimitsChange={() => {}}
+ {...props}
+ />,
+ );
+
+ it("gives each cap its own labelled field", () => {
+ const html = render();
+ for (const label of ["Limit", "Page size", "Max page", "Max export"]) {
+ expect(html).toContain(`aria-label="${label}"`);
+ }
+ });
+
+ it("holds the query's limit and the caps the profile set", () => {
+ const html = render();
+ expect(html).toContain('value="5000"');
+ expect(html).toContain('value="250000"');
+ });
+
+ it("offers the inherited default as the placeholder of an unset cap", () => {
+ const html = render();
+ expect(html).toContain('placeholder="100"');
+ expect(html).toContain('placeholder="1,000"');
+ });
+
+ it("shows only the query's own limit to a host that edits no profile", () => {
+ const html = render({ onLimitsChange: undefined });
+ expect(html).toContain('aria-label="Limit"');
+ expect(html).not.toContain('aria-label="Max export"');
+ });
+});
diff --git a/packages/ui/src/profiles/queryRowLimits.tsx b/packages/ui/src/profiles/queryRowLimits.tsx
new file mode 100644
index 00000000..15a93a09
--- /dev/null
+++ b/packages/ui/src/profiles/queryRowLimits.tsx
@@ -0,0 +1,113 @@
+/**
+ * The four row caps, edited beside the filters they bound rather than buried in
+ * the generic options form. They answer different questions and so get a field
+ * each: Limit is how many rows the query asks the source for (a provider
+ * option), while the page a caller gets by default, the largest page it may ask
+ * for and where an export stops belong to the profile. A cap the profile leaves
+ * empty shows the inherited default as its placeholder.
+ */
+
+import { InputField } from "../components/InputField";
+import type {
+ BrowserRowLimits,
+ ProfileRowLimits,
+} from "./connectionBrowserModel";
+import { applyRowLimit } from "./queryRowLimitsModel";
+
+const profileCaps: {
+ key: keyof ProfileRowLimits;
+ label: string;
+ title: string;
+ fallback: keyof BrowserRowLimits;
+}[] = [
+ {
+ key: "pageSize",
+ label: "Page size",
+ title: "Rows one page returns when the caller asks for no size.",
+ fallback: "pageSize",
+ },
+ {
+ key: "maxPageSize",
+ label: "Max page",
+ title: "Largest single page a caller may ask this profile for.",
+ fallback: "maxPageSize",
+ },
+ {
+ key: "maxExportRows",
+ label: "Max export",
+ title: "Where an all-row export of this profile stops.",
+ fallback: "maxExportRows",
+ },
+];
+
+export function QueryRowLimits({
+ value,
+ onChange,
+ defaults,
+ limits,
+ onLimitsChange,
+}: {
+ value: string;
+ onChange: (limit: string) => void;
+ defaults?: BrowserRowLimits;
+ limits?: ProfileRowLimits;
+ onLimitsChange?: (limits: ProfileRowLimits | undefined) => void;
+}) {
+ return (
+
+
+ {onLimitsChange
+ ? profileCaps.map((cap) => (
+
+ onLimitsChange(applyRowLimit(limits, cap.key, text))
+ }
+ />
+ ))
+ : null}
+
+ );
+}
+
+function RowLimitField({
+ label,
+ title,
+ value,
+ placeholder,
+ onChange,
+}: {
+ label: string;
+ title: string;
+ value: string;
+ placeholder: string;
+ onChange: (text: string) => void;
+}) {
+ return (
+
+ {label}
+
+
+ );
+}
diff --git a/packages/ui/src/profiles/queryRowLimitsModel.ts b/packages/ui/src/profiles/queryRowLimitsModel.ts
new file mode 100644
index 00000000..8705833b
--- /dev/null
+++ b/packages/ui/src/profiles/queryRowLimitsModel.ts
@@ -0,0 +1,29 @@
+/**
+ * The four row caps, edited beside the filters they bound rather than buried in
+ * the generic options form. They answer different questions and so get a field
+ * each: Limit is how many rows the query asks the source for (a provider
+ * option), while the page a caller gets by default, the largest page it may ask
+ * for and where an export stops belong to the profile. A cap the profile leaves
+ * empty shows the inherited default as its placeholder.
+ */
+
+import type {
+ ProfileRowLimits
+} from "./connectionBrowserModel";
+
+/**
+ * applyRowLimit writes one cap. An emptied field removes the key rather than
+ * storing a zero, so the profile visibly falls back to the default instead of
+ * declaring a cap that returns nothing — and clearing the last one leaves no
+ * block at all, which is what "this profile caps nothing" looks like.
+ */
+export function applyRowLimit(
+ limits: ProfileRowLimits | undefined,
+ key: keyof ProfileRowLimits,
+ text: string,
+): ProfileRowLimits | undefined {
+ const next = { ...limits };
+ if (text.trim() === "") delete next[key];
+ else next[key] = Number(text);
+ return Object.keys(next).length > 0 ? next : undefined;
+}
diff --git a/packages/ui/src/profiles/queryTargetPicker.test.tsx b/packages/ui/src/profiles/queryTargetPicker.test.tsx
new file mode 100644
index 00000000..eed5d7ff
--- /dev/null
+++ b/packages/ui/src/profiles/queryTargetPicker.test.tsx
@@ -0,0 +1,55 @@
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it } from "vitest";
+import type { Inspection } from "./connectionBrowserModel";
+import { QueryTargetPicker } from "./queryTargetPicker";
+
+const inspection = (overrides: Partial = {}): Inspection => ({
+ data: {
+ kind: "opensearch",
+ targets: [
+ { name: "jaeger-span-*", kind: "pattern", count: 53 },
+ { name: "logs-current", kind: "alias" },
+ ],
+ },
+ nodes: [],
+ databases: [],
+ activeDatabase: "",
+ sqlDatabase: "",
+ targetKind: "",
+ loading: false,
+ error: undefined,
+ ...overrides,
+});
+
+const render = (props: Partial[0]> = {}) =>
+ renderToStaticMarkup(
+ {}}
+ {...props}
+ />,
+ );
+
+describe("QueryTargetPicker", () => {
+ it("labels itself with the target the server named", () => {
+ expect(render()).toContain("Index");
+ });
+
+ it("shows the selected target rather than its rotation count", () => {
+ expect(render({ value: "jaeger-span-*" })).toContain("jaeger-span-*");
+ });
+
+ it("surfaces an inspection failure instead of an empty picker", () => {
+ const html = render({
+ inspection: inspection({
+ data: undefined,
+ error: new Error("connection refused"),
+ }),
+ });
+
+ expect(html).toContain("connection refused");
+ expect(html).toContain("Unavailable");
+ });
+});
diff --git a/packages/ui/src/profiles/queryTargetPicker.tsx b/packages/ui/src/profiles/queryTargetPicker.tsx
new file mode 100644
index 00000000..83dc9f43
--- /dev/null
+++ b/packages/ui/src/profiles/queryTargetPicker.tsx
@@ -0,0 +1,62 @@
+/**
+ * The flat target a query runs against — an OpenSearch index, alias, data
+ * stream, or the wildcard a daily rotation rolls up into. A hundred timestamped
+ * index names is a searchable list, not a hierarchy, so this replaces the
+ * catalog tree wherever the server declared a `targetLabel`.
+ */
+
+import { Combobox } from "../components/Combobox";
+import { useMemo } from "react";
+import {
+ openSearchIndexOptions,
+ openSearchTargetKind,
+ type Inspection,
+} from "./connectionBrowserModel";
+
+export function QueryTargetPicker({
+ label,
+ inspection,
+ value,
+ onChange,
+}: {
+ label: string;
+ inspection: Inspection;
+ value: string;
+ onChange: (target: string, kind: string) => void;
+}) {
+ const options = useMemo(
+ () => openSearchIndexOptions(inspection.data),
+ [inspection.data],
+ );
+ return (
+
+
onChange(next, openSearchTargetKind(inspection.data, next))}
+ options={options}
+ placeholder={
+ inspection.error
+ ? "Unavailable — check the connection"
+ : `Select ${label.toLowerCase()} or type a wildcard…`
+ }
+ loading={inspection.loading}
+ invalid={Boolean(inspection.error)}
+ allowCustomValue
+ className="min-w-0"
+ />
+ {inspection.error ? (
+
+ {targetErrorMessage(inspection.error)}
+
+ ) : null}
+
+ );
+}
+
+function targetErrorMessage(error: unknown): string {
+ if (error instanceof Error && error.message.trim()) return error.message.trim();
+ if (typeof error === "string" && error.trim()) return error.trim();
+ return "The inspection request failed. Check the connection settings and try again.";
+}
diff --git a/packages/ui/src/profiles/testSchema.ts b/packages/ui/src/profiles/testSchema.ts
new file mode 100644
index 00000000..13054435
--- /dev/null
+++ b/packages/ui/src/profiles/testSchema.ts
@@ -0,0 +1,51 @@
+/**
+ * A stand-in profile schema for tests.
+ *
+ * The real document is generated from commons-db's Go types and injected by the
+ * host, so it is not available here — and asserting its contents from this
+ * package would test commons-db's generator, not this code. What these tests
+ * own is the mechanics on top of a schema: which properties a projection picks,
+ * which of them stay required, and what a provider's options resolve to. This
+ * fixture is deliberately small enough to read, and its expected projections are
+ * obvious by inspection rather than copied from the code under test.
+ */
+
+import type { ProfileSchema } from "./profileApi";
+
+export const testProfileSchema: ProfileSchema = {
+ type: "object",
+ required: ["profile", "provider"],
+ properties: {
+ profile: { type: "string" },
+ namespace: { type: "string" },
+ query: { type: "string" },
+ params: { type: "array", items: { type: "object" } },
+ processors: {
+ type: "array",
+ items: {
+ type: "object",
+ properties: {
+ use: { type: "string", enum: ["example.processor"] },
+ },
+ },
+ },
+ provider: {
+ type: "object",
+ properties: { type: { type: "string", enum: ["opensearch", "sql"] } },
+ },
+ },
+ $defs: {
+ opensearch: {
+ type: "object",
+ properties: {
+ options: {
+ type: "object",
+ properties: { index: { type: "string" } },
+ },
+ },
+ },
+ // A provider whose options are not an object at all, so the resolver's
+ // fallback has something real to resolve against.
+ sql: { type: "object", properties: { options: { type: "string" } } },
+ },
+};
diff --git a/packages/ui/vite.config.ts b/packages/ui/vite.config.ts
index 79447c0a..64c945e9 100644
--- a/packages/ui/vite.config.ts
+++ b/packages/ui/vite.config.ts
@@ -22,6 +22,7 @@ const entry = {
rpc: resolve(__dirname, "src/rpc.ts"),
monaco: resolve(__dirname, "src/monaco.ts"),
"monaco-schema": resolve(__dirname, "src/monaco-schema.ts"),
+ profiles: resolve(__dirname, "src/profiles.ts"),
chat: resolve(__dirname, "src/chat.ts"),
ai: resolve(__dirname, "src/ai.ts"),
"tailwind-preset": resolve(__dirname, "src/tailwind-preset.ts"),