From 118ea38533848f21f47432480bafdc66172d4b9f Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Tue, 11 Aug 2026 07:58:25 +0300 Subject: [PATCH 01/17] feat(profiles): Add schema-driven profile authoring and query browser workspaces Add reusable query browsing, sampling, and OpenSearch query-building flows for profile creation and editing. Introduce a schema-configured profile editor route with column management, previews, JSONPath selection, YAML editing, parameter validation, and row-limit controls. Cover the new models, components, integrations, and authoring workflows with focused tests and Storybook coverage. --- packages/ui/src/profiles/.widen.py | 62 +++ .../ui/src/profiles/ProfileEditor.stories.tsx | 145 ++++++ packages/ui/src/profiles/catalogTree.tsx | 153 ++++++ .../profiles/connectionBrowserModel.test.ts | 84 ++++ .../ui/src/profiles/connectionBrowserModel.ts | 387 ++++++++++++++ .../connectionQueryWorkspace.test.tsx | 224 +++++++++ .../src/profiles/connectionQueryWorkspace.tsx | 306 ++++++++++++ .../profiles/connectionQueryWorkspaceModel.ts | 66 +++ .../ui/src/profiles/esFieldValues.test.ts | 170 +++++++ packages/ui/src/profiles/esFieldValues.ts | 132 +++++ .../src/profiles/esParamMappingModel.test.ts | 307 ++++++++++++ .../ui/src/profiles/esParamMappingModel.ts | 410 +++++++++++++++ .../ui/src/profiles/esParamMappingPill.tsx | 32 ++ .../src/profiles/esParamOperandExtension.tsx | 83 +++ .../ui/src/profiles/esQueryBuilder.test.tsx | 349 +++++++++++++ packages/ui/src/profiles/esQueryBuilder.tsx | 260 ++++++++++ .../src/profiles/esQueryBuilderExtension.tsx | 30 ++ .../ui/src/profiles/esQueryBuilderForm.ts | 52 ++ .../src/profiles/esQueryBuilderModel.test.ts | 180 +++++++ .../ui/src/profiles/esQueryBuilderModel.ts | 251 ++++++++++ .../src/profiles/esQueryClauseGroup.test.tsx | 159 ++++++ .../ui/src/profiles/esQueryClauseGroup.tsx | 167 +++++++ packages/ui/src/profiles/esQueryCompile.ts | 87 ++++ .../src/profiles/esQueryConditionRow.test.tsx | 207 ++++++++ .../ui/src/profiles/esQueryConditionRow.tsx | 245 +++++++++ packages/ui/src/profiles/esQueryGroupModel.ts | 24 + packages/ui/src/profiles/esQueryOccur.ts | 23 + .../ui/src/profiles/esQueryOperandEditors.tsx | 338 +++++++++++++ .../src/profiles/esQueryOperandModel.test.ts | 99 ++++ .../ui/src/profiles/esQueryOperandModel.ts | 39 ++ .../ui/src/profiles/esQueryOperators.test.ts | 348 +++++++++++++ packages/ui/src/profiles/esQueryOperators.ts | 284 +++++++++++ .../ui/src/profiles/esQueryOutputEditor.tsx | 154 ++++++ .../ui/src/profiles/esQueryOutputModel.ts | 25 + packages/ui/src/profiles/esQueryPreview.tsx | 45 ++ .../ui/src/profiles/esQuerySortEditor.tsx | 136 +++++ packages/ui/src/profiles/esQuerySortModel.ts | 21 + .../ui/src/profiles/esValueCombobox.test.tsx | 52 ++ packages/ui/src/profiles/esValueCombobox.tsx | 110 ++++ packages/ui/src/profiles/jsonPathSample.ts | 114 +++++ .../ui/src/profiles/jsonPathSampleRow.test.ts | 69 +++ .../ui/src/profiles/jsonPathSampleRow.tsx | 18 + packages/ui/src/profiles/profileApi.ts | 79 +++ packages/ui/src/profiles/profileBuilder.css | 27 + .../ui/src/profiles/profileBuilder.test.ts | 38 ++ packages/ui/src/profiles/profileBuilder.tsx | 88 ++++ .../src/profiles/profileBuilderExtension.tsx | 32 ++ .../src/profiles/profileBuilderWorkspace.tsx | 423 ++++++++++++++++ .../ui/src/profiles/profileColumnModel.ts | 34 ++ .../ui/src/profiles/profileColumnPicker.tsx | 82 +++ packages/ui/src/profiles/profileEditor.tsx | 472 ++++++++++++++++++ .../src/profiles/profileEditorModel.test.ts | 208 ++++++++ .../ui/src/profiles/profileEditorModel.ts | 222 ++++++++ .../ui/src/profiles/profileEditorPreview.tsx | 71 +++ .../ui/src/profiles/profileEditorRail.tsx | 58 +++ .../ui/src/profiles/profileEditorRaw.test.tsx | 64 +++ packages/ui/src/profiles/profileEditorRaw.tsx | 115 +++++ .../ui/src/profiles/profileEditorRoutes.ts | 4 + .../ui/src/profiles/profileEditorSections.tsx | 252 ++++++++++ .../ui/src/profiles/profileFieldEditor.tsx | 315 ++++++++++++ packages/ui/src/profiles/profileFieldGrid.tsx | 312 ++++++++++++ .../ui/src/profiles/profileFieldManager.tsx | 102 ++++ .../src/profiles/profileFieldState.test.tsx | 209 ++++++++ packages/ui/src/profiles/profileFieldState.ts | 187 +++++++ packages/ui/src/profiles/profileFieldTypes.ts | 20 + .../ui/src/profiles/profileParamModel.test.ts | 139 ++++++ packages/ui/src/profiles/profileParamModel.ts | 108 ++++ .../ui/src/profiles/profileWizard.test.tsx | 185 +++++++ packages/ui/src/profiles/profileWizard.tsx | 308 ++++++++++++ packages/ui/src/profiles/profileWizardHelp.ts | 13 + .../ui/src/profiles/profileWizardModel.ts | 394 +++++++++++++++ .../profiles/profileWizardQueryStep.test.tsx | 100 ++++ .../src/profiles/profileWizardQueryStep.tsx | 280 +++++++++++ .../ui/src/profiles/profileWizardSteps.tsx | 285 +++++++++++ packages/ui/src/profiles/profileYaml.ts | 17 + .../ui/src/profiles/prometheusResults.tsx | 70 +++ .../ui/src/profiles/queryRowLimits.test.tsx | 67 +++ packages/ui/src/profiles/queryRowLimits.tsx | 113 +++++ .../ui/src/profiles/queryRowLimitsModel.ts | 29 ++ .../src/profiles/queryTargetPicker.test.tsx | 55 ++ .../ui/src/profiles/queryTargetPicker.tsx | 62 +++ packages/ui/src/profiles/testSchema.ts | 51 ++ 82 files changed, 12137 insertions(+) create mode 100644 packages/ui/src/profiles/.widen.py create mode 100644 packages/ui/src/profiles/ProfileEditor.stories.tsx create mode 100644 packages/ui/src/profiles/catalogTree.tsx create mode 100644 packages/ui/src/profiles/connectionBrowserModel.test.ts create mode 100644 packages/ui/src/profiles/connectionBrowserModel.ts create mode 100644 packages/ui/src/profiles/connectionQueryWorkspace.test.tsx create mode 100644 packages/ui/src/profiles/connectionQueryWorkspace.tsx create mode 100644 packages/ui/src/profiles/connectionQueryWorkspaceModel.ts create mode 100644 packages/ui/src/profiles/esFieldValues.test.ts create mode 100644 packages/ui/src/profiles/esFieldValues.ts create mode 100644 packages/ui/src/profiles/esParamMappingModel.test.ts create mode 100644 packages/ui/src/profiles/esParamMappingModel.ts create mode 100644 packages/ui/src/profiles/esParamMappingPill.tsx create mode 100644 packages/ui/src/profiles/esParamOperandExtension.tsx create mode 100644 packages/ui/src/profiles/esQueryBuilder.test.tsx create mode 100644 packages/ui/src/profiles/esQueryBuilder.tsx create mode 100644 packages/ui/src/profiles/esQueryBuilderExtension.tsx create mode 100644 packages/ui/src/profiles/esQueryBuilderForm.ts create mode 100644 packages/ui/src/profiles/esQueryBuilderModel.test.ts create mode 100644 packages/ui/src/profiles/esQueryBuilderModel.ts create mode 100644 packages/ui/src/profiles/esQueryClauseGroup.test.tsx create mode 100644 packages/ui/src/profiles/esQueryClauseGroup.tsx create mode 100644 packages/ui/src/profiles/esQueryCompile.ts create mode 100644 packages/ui/src/profiles/esQueryConditionRow.test.tsx create mode 100644 packages/ui/src/profiles/esQueryConditionRow.tsx create mode 100644 packages/ui/src/profiles/esQueryGroupModel.ts create mode 100644 packages/ui/src/profiles/esQueryOccur.ts create mode 100644 packages/ui/src/profiles/esQueryOperandEditors.tsx create mode 100644 packages/ui/src/profiles/esQueryOperandModel.test.ts create mode 100644 packages/ui/src/profiles/esQueryOperandModel.ts create mode 100644 packages/ui/src/profiles/esQueryOperators.test.ts create mode 100644 packages/ui/src/profiles/esQueryOperators.ts create mode 100644 packages/ui/src/profiles/esQueryOutputEditor.tsx create mode 100644 packages/ui/src/profiles/esQueryOutputModel.ts create mode 100644 packages/ui/src/profiles/esQueryPreview.tsx create mode 100644 packages/ui/src/profiles/esQuerySortEditor.tsx create mode 100644 packages/ui/src/profiles/esQuerySortModel.ts create mode 100644 packages/ui/src/profiles/esValueCombobox.test.tsx create mode 100644 packages/ui/src/profiles/esValueCombobox.tsx create mode 100644 packages/ui/src/profiles/jsonPathSample.ts create mode 100644 packages/ui/src/profiles/jsonPathSampleRow.test.ts create mode 100644 packages/ui/src/profiles/jsonPathSampleRow.tsx create mode 100644 packages/ui/src/profiles/profileApi.ts create mode 100644 packages/ui/src/profiles/profileBuilder.css create mode 100644 packages/ui/src/profiles/profileBuilder.test.ts create mode 100644 packages/ui/src/profiles/profileBuilder.tsx create mode 100644 packages/ui/src/profiles/profileBuilderExtension.tsx create mode 100644 packages/ui/src/profiles/profileBuilderWorkspace.tsx create mode 100644 packages/ui/src/profiles/profileColumnModel.ts create mode 100644 packages/ui/src/profiles/profileColumnPicker.tsx create mode 100644 packages/ui/src/profiles/profileEditor.tsx create mode 100644 packages/ui/src/profiles/profileEditorModel.test.ts create mode 100644 packages/ui/src/profiles/profileEditorModel.ts create mode 100644 packages/ui/src/profiles/profileEditorPreview.tsx create mode 100644 packages/ui/src/profiles/profileEditorRail.tsx create mode 100644 packages/ui/src/profiles/profileEditorRaw.test.tsx create mode 100644 packages/ui/src/profiles/profileEditorRaw.tsx create mode 100644 packages/ui/src/profiles/profileEditorRoutes.ts create mode 100644 packages/ui/src/profiles/profileEditorSections.tsx create mode 100644 packages/ui/src/profiles/profileFieldEditor.tsx create mode 100644 packages/ui/src/profiles/profileFieldGrid.tsx create mode 100644 packages/ui/src/profiles/profileFieldManager.tsx create mode 100644 packages/ui/src/profiles/profileFieldState.test.tsx create mode 100644 packages/ui/src/profiles/profileFieldState.ts create mode 100644 packages/ui/src/profiles/profileFieldTypes.ts create mode 100644 packages/ui/src/profiles/profileParamModel.test.ts create mode 100644 packages/ui/src/profiles/profileParamModel.ts create mode 100644 packages/ui/src/profiles/profileWizard.test.tsx create mode 100644 packages/ui/src/profiles/profileWizard.tsx create mode 100644 packages/ui/src/profiles/profileWizardHelp.ts create mode 100644 packages/ui/src/profiles/profileWizardModel.ts create mode 100644 packages/ui/src/profiles/profileWizardQueryStep.test.tsx create mode 100644 packages/ui/src/profiles/profileWizardQueryStep.tsx create mode 100644 packages/ui/src/profiles/profileWizardSteps.tsx create mode 100644 packages/ui/src/profiles/profileYaml.ts create mode 100644 packages/ui/src/profiles/prometheusResults.tsx create mode 100644 packages/ui/src/profiles/queryRowLimits.test.tsx create mode 100644 packages/ui/src/profiles/queryRowLimits.tsx create mode 100644 packages/ui/src/profiles/queryRowLimitsModel.ts create mode 100644 packages/ui/src/profiles/queryTargetPicker.test.tsx create mode 100644 packages/ui/src/profiles/queryTargetPicker.tsx create mode 100644 packages/ui/src/profiles/testSchema.ts 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 ? ( + + ) : 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..c721f4bc --- /dev/null +++ b/packages/ui/src/profiles/connectionBrowserModel.test.ts @@ -0,0 +1,84 @@ +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", + 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", + ); + } +}); + +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..06e6420b --- /dev/null +++ b/packages/ui/src/profiles/connectionBrowserModel.ts @@ -0,0 +1,387 @@ +/** + * 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 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") { + throw new QueryBrowserExecutionError(parsed.error, parsed.diagnostics); + } + } 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 ? ( + + set({ occur: event.target.value as never })} + /> + + + , 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 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} + + + + 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)} + /> + ))} + +
+
+ ) : 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)} + + + ))} + { + 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 ( + + ); + } + 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 ( + + + setSource({ enabled: event.target.checked ? undefined : false }) + } + /> + Return _source + + + {total.enabled ? ( + setTotal({ threshold: parseCount(next) })} + /> + ) : null} + + {source.enabled === false ? null : ( +
+ + setSource({ includes: includes.length ? includes : undefined }) + } + /> + + setSource({ excludes: excludes.length ? excludes : undefined }) + } + /> +
+ )} + + ); +} + +/** + * 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} + + + ))} + { + if (event.key !== "Enter" && event.key !== ",") return; + event.preventDefault(); + commit(); + }} + onBlur={commit} + /> +
+ ); +} 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

+ +
+ {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 + /> + + ; + }, +})); + +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..0dd243f9 --- /dev/null +++ b/packages/ui/src/profiles/profileApi.ts @@ -0,0 +1,79 @@ +/** + * 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"; + +/** 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 = options.basePath.trim().replace(/\/+$/, ""); + 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.css b/packages/ui/src/profiles/profileBuilder.css new file mode 100644 index 00000000..a9686dd8 --- /dev/null +++ b/packages/ui/src/profiles/profileBuilder.css @@ -0,0 +1,27 @@ +.profile-builder-workspace-dialog > [data-slot="modal-body"] { + display: flex; + min-height: 0; + overflow: hidden; +} + +.profile-builder-workspace-dialog > [data-slot="modal-body"] > * { + flex: 1 1 0; + height: auto; + min-height: 0; +} + +.profile-editor-dialog { + height: calc(100dvh - 2rem); + max-height: calc(100dvh - 2rem); +} + +.profile-editor-dialog > [data-slot="modal-body"] { + display: flex; + min-height: 0; + overflow: hidden; +} + +.profile-editor-dialog > [data-slot="modal-body"] > * { + flex: 1 1 0; + min-height: 0; +} diff --git a/packages/ui/src/profiles/profileBuilder.test.ts b/packages/ui/src/profiles/profileBuilder.test.ts new file mode 100644 index 00000000..a5a870a3 --- /dev/null +++ b/packages/ui/src/profiles/profileBuilder.test.ts @@ -0,0 +1,38 @@ +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)]"); + expect(profileBuilderModalClassName).toContain( + "profile-builder-workspace-dialog", + ); + }); +}); + +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..0d184b98 --- /dev/null +++ b/packages/ui/src/profiles/profileBuilder.tsx @@ -0,0 +1,88 @@ +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"; +import "./profileBuilder.css"; + +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} +
+ + {!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..2f250d44 --- /dev/null +++ b/packages/ui/src/profiles/profileBuilderWorkspace.tsx @@ -0,0 +1,423 @@ +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. +export const profileBuilderModalClassName = + "profile-builder-workspace-dialog h-[calc(100dvh-2rem)]"; + +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 = ( +
+ + + + +
+ ); + + 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/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 +
+
+ {columns.map((column) => ( +
+ + + {existing.has(column.name) ? ( + + configured + + ) : null} +
+ ))} +
+
+ ); +} + 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 ? ( + + ) : null} + + + ), + }, + 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 ( + +
+
+ + {error ? {error} : null} + + {dirty ? "Unsaved changes" : "No changes"} + + + +
+ + +
+ + {confirmDiscard ? ( + setConfirmDiscard(false)} + title="Discard profile changes?" + size="sm" + footer={ +
+ + +
+ } + > +

+ Your unsaved profile changes will be lost. +

+
+ ) : null} + + {confirmResetColumns ? ( + setConfirmResetColumns(false)} + title="Reset columns from latest sample?" + size="sm" + footer={ +
+ + +
+ } + > +

+ 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={ +
+ + +
+ } + > +

+ 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..082d9ac9 --- /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 oracle 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..09419446 --- /dev/null +++ b/packages/ui/src/profiles/profileEditorModel.ts @@ -0,0 +1,222 @@ +import type { JsonSchemaObject } from "../components/json-schema-form-types"; +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 = Array.from(name.trim().toLowerCase()) + .map((character) => + /[a-z0-9]/.test(character) + ? character + : /[ ._/-]/.test(character) + ? "-" + : "", + ) + .join("") + .replace(/^-+|-+$/g, ""); + 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) => ( + + ))} + + + + {rows.map((row, index) => ( + + {shown.map((column) => ( + + ))} + + ))} + +
+ {column.label ?? column.name} +
+ {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 ( + + ); +} 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)} + /> + + +
+
+ {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 ( + +
+ + onChange({ ...draft, profile: event.target.value })} + /> + + + onChange({ ...draft, namespace: event.target.value })} + /> + + + + + +
+ onChange({ ...draft, icon: event.target.value || undefined })} + /> + {typeof draft.icon === "string" && draft.icon !== "" ? ( + // Shows what the name actually resolves to — an unresolvable name + // renders nothing anywhere, which is invisible without a preview. + + ) : null} +
+
+
+
+ ); +} + +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, 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 ? ( + + ) : ( + + +