From 67f19cf1bad1dacd2895677497b466948242bf23 Mon Sep 17 00:00:00 2001 From: "cemre.mengu" Date: Fri, 28 Aug 2026 19:50:17 +0300 Subject: [PATCH 1/4] feat(ui): create index --- .../quickwit-ui/src/services/client.test.ts | 58 +++++++++ quickwit/quickwit-ui/src/services/client.ts | 40 +++++- .../src/views/IndexesView.test.jsx | 115 ++++++++++++++---- .../quickwit-ui/src/views/IndexesView.tsx | 43 ++++++- 4 files changed, 225 insertions(+), 31 deletions(-) diff --git a/quickwit/quickwit-ui/src/services/client.test.ts b/quickwit/quickwit-ui/src/services/client.test.ts index eb2552fc755..e987b0db1dd 100644 --- a/quickwit/quickwit-ui/src/services/client.test.ts +++ b/quickwit/quickwit-ui/src/services/client.test.ts @@ -52,4 +52,62 @@ describe("Client unit test", () => { expect(mockFetch).toHaveBeenCalledTimes(1); expect(mockFetch).toHaveBeenCalledWith(expectedUrl, expect.any(Object)); }); + + it("Should post the index config as YAML when creating an index", async () => { + const mockFetch = jest.fn((_url: string, _options?: unknown) => + Promise.resolve({ ok: true, json: () => Promise.resolve({}) }), + ); + (global as any).fetch = mockFetch; + + const indexConfigYaml = "version: 0.9\nindex_id: my-index\n"; + const client = new Client(); + await client.createIndex(indexConfigYaml); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, params] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`${client.apiRoot()}indexes`); + expect(params.method).toBe("POST"); + // The config must be sent verbatim, not JSON-encoded. + expect(params.body).toBe(indexConfigYaml); + expect((params.headers as Record)["content-type"]).toBe( + "application/yaml", + ); + }); + + it("Should unwrap the message of a JSON error envelope", async () => { + const mockFetch = jest.fn((_url: string, _options?: unknown) => + Promise.resolve({ + ok: false, + status: 400, + text: () => + Promise.resolve( + '{"message":"field `timestamp` has an unknown type"}', + ), + }), + ); + (global as any).fetch = mockFetch; + + const client = new Client(); + await expect(client.createIndex("version: 0.9\n")).rejects.toEqual({ + message: "field `timestamp` has an unknown type", + status: 400, + }); + }); + + it("Should surface a non-JSON error body verbatim", async () => { + const mockFetch = jest.fn((_url: string, _options?: unknown) => + Promise.resolve({ + ok: false, + status: 502, + text: () => Promise.resolve("Bad Gateway"), + }), + ); + (global as any).fetch = mockFetch; + + const client = new Client(); + await expect(client.createIndex("version: 0.9\n")).rejects.toEqual({ + message: "Bad Gateway", + status: 502, + }); + }); }); diff --git a/quickwit/quickwit-ui/src/services/client.ts b/quickwit/quickwit-ui/src/services/client.ts index 95baaceed99..ef5dec8e6cc 100644 --- a/quickwit/quickwit-ui/src/services/client.ts +++ b/quickwit/quickwit-ui/src/services/client.ts @@ -106,6 +106,23 @@ export class Client { return this.fetch(`${this.apiRoot()}indexes`, {}); } + // Creates an index from a raw index config expressed in YAML. The config is + // sent as-is: the server selects its parser from the `content-type` subtype + // and reports validation errors, so no client-side YAML parsing is done here. + async createIndex(indexConfigYaml: string): Promise { + return this.fetch( + `${this.apiRoot()}indexes`, + { + headers: { + "content-type": "application/yaml", + Accept: "application/json", + }, + mode: "cors", + }, + indexConfigYaml, + ); + } + async fetch( url: string, params: RequestInit, @@ -114,22 +131,39 @@ export class Client { if (body !== null) { params.method = "POST"; params.body = body; + // The caller's `content-type` wins: callers that do not set one default + // to JSON. params.headers = { - ...params.headers, "content-type": "application/json", + ...params.headers, }; } const response = await fetch(url, params); if (response.ok) { return response.json() as Promise; } - const message = await response.text(); return await Promise.reject({ - message: message, + message: await this.extractErrorMessage(response), status: response.status, }); } + // Quickwit reports errors as a `{"message": "..."}` JSON envelope. Anything + // else (a proxy error page, for instance) is surfaced verbatim so failures + // are never hidden. + private async extractErrorMessage(response: Response): Promise { + const rawBody = await response.text(); + try { + const parsedBody = JSON.parse(rawBody); + if (typeof parsedBody?.message === "string") { + return parsedBody.message; + } + } catch { + // Not a JSON error envelope. + } + return rawBody; + } + private defaultGetRequestParams(): RequestInit { return { method: "GET", diff --git a/quickwit/quickwit-ui/src/views/IndexesView.test.jsx b/quickwit/quickwit-ui/src/views/IndexesView.test.jsx index d64abebedb0..c82712f18b9 100644 --- a/quickwit/quickwit-ui/src/views/IndexesView.test.jsx +++ b/quickwit/quickwit-ui/src/views/IndexesView.test.jsx @@ -12,8 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { act } from "react"; +import { DEFAULT_INDEX_CONFIG_YAML } from "../components/CreateIndexDialog"; import { Client } from "../services/client"; import IndexesView from "./IndexesView"; @@ -37,29 +38,30 @@ afterEach(() => { container = null; }); -test("renders IndexesView", async () => { - const indexes = [ - { - index_config: { - index_id: "my-new-fresh-index", - index_uri: "my-uri", - indexing_settings: { - timestamp_field: "timestamp", - }, - search_settings: {}, - doc_mapping: { - store: false, - field_mappings: [], - tag_fields: [], - dynamic_mapping: false, - }, +const indexes = [ + { + index_config: { + index_id: "my-new-fresh-index", + index_uri: "my-uri", + indexing_settings: { + timestamp_field: "timestamp", + }, + search_settings: {}, + doc_mapping: { + store: false, + field_mappings: [], + tag_fields: [], + dynamic_mapping: false, }, - sources: [], - create_timestamp: 1000, - update_timestamp: 1000, }, - ]; - Client.prototype.listIndexes.mockResolvedValueOnce(() => indexes); + sources: [], + create_timestamp: 1000, + update_timestamp: 1000, + }, +]; + +test("renders IndexesView", async () => { + Client.prototype.listIndexes.mockResolvedValue(indexes); await act(async () => { render(, container); @@ -69,3 +71,72 @@ test("renders IndexesView", async () => { screen.getByText(indexes[0].index_config.index_id), ).toBeInTheDocument(); }); + +test("opens the create index dialog with the default config", async () => { + Client.prototype.listIndexes.mockResolvedValue(indexes); + + await act(async () => { + render(, container); + }); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /create index/i })); + }); + + // The Monaco editor is mocked and renders its value as plain text. Compare + // raw `textContent`: `toHaveTextContent` collapses the YAML indentation. + expect(screen.getByRole("dialog").textContent).toContain( + DEFAULT_INDEX_CONFIG_YAML, + ); +}); + +test("creates an index and refetches the index list", async () => { + Client.prototype.listIndexes.mockResolvedValue(indexes); + Client.prototype.createIndex.mockResolvedValue(indexes[0]); + + await act(async () => { + render(, container); + }); + expect(Client.prototype.listIndexes).toHaveBeenCalledTimes(1); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /create index/i })); + }); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Create" })); + }); + + expect(Client.prototype.createIndex).toHaveBeenCalledWith( + DEFAULT_INDEX_CONFIG_YAML, + ); + expect(Client.prototype.listIndexes).toHaveBeenCalledTimes(2); + // The dialog fades out, so it only leaves the DOM once the transition ends. + await waitFor(() => + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(), + ); +}); + +test("keeps the dialog open and displays the error when creation fails", async () => { + Client.prototype.listIndexes.mockResolvedValue(indexes); + Client.prototype.createIndex.mockRejectedValue({ + status: 400, + message: "index `my-index` already exists", + }); + + await act(async () => { + render(, container); + }); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /create index/i })); + }); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Create" })); + }); + + expect( + screen.getByText("index `my-index` already exists"), + ).toBeInTheDocument(); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(Client.prototype.listIndexes).toHaveBeenCalledTimes(1); +}); diff --git a/quickwit/quickwit-ui/src/views/IndexesView.tsx b/quickwit/quickwit-ui/src/views/IndexesView.tsx index 507f5b6291d..770406a7fd1 100644 --- a/quickwit/quickwit-ui/src/views/IndexesView.tsx +++ b/quickwit/quickwit-ui/src/views/IndexesView.tsx @@ -12,9 +12,11 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Box, Typography } from "@mui/material"; -import { useEffect, useMemo, useState } from "react"; +import AddIcon from "@mui/icons-material/Add"; +import { Box, Button, Typography } from "@mui/material"; +import { useCallback, useEffect, useMemo, useState } from "react"; import ApiUrlFooter from "../components/ApiUrlFooter"; +import CreateIndexDialog from "../components/CreateIndexDialog"; import IndexesTable from "../components/IndexesTable"; import { FullBoxContainer, @@ -32,6 +34,7 @@ function IndexesView() { null, ); const [indexesMetadata, setIndexesMetadata] = useState(); + const [createDialogOpen, setCreateDialogOpen] = useState(false); const quickwitClient = useMemo(() => new Client(), []); const renderFetchIndexesResult = () => { @@ -51,7 +54,7 @@ function IndexesView() { return You have no index registered in your metastore.; }; - useEffect(() => { + const fetchIndexes = useCallback(() => { setLoading(true); quickwitClient.listIndexes().then( (indexesMetadata) => { @@ -66,14 +69,42 @@ function IndexesView() { ); }, [quickwitClient]); + useEffect(() => { + fetchIndexes(); + }, [fetchIndexes]); + return ( - - Indexes - + + + Indexes + + + {renderFetchIndexesResult()} + setCreateDialogOpen(false)} + onIndexCreated={() => { + setCreateDialogOpen(false); + fetchIndexes(); + }} + /> {ApiUrlFooter("api/v1/indexes")} ); From 537259e8fcb207428fdf40ab4fe206a50334067c Mon Sep 17 00:00:00 2001 From: "cemre.mengu" Date: Fri, 28 Aug 2026 20:14:20 +0300 Subject: [PATCH 2/4] cleanup --- .../src/components/CreateIndexDialog.tsx | 146 ++++++++++++++++++ .../quickwit-ui/src/components/YamlEditor.tsx | 58 +++++++ quickwit/quickwit-ui/src/services/client.ts | 6 - 3 files changed, 204 insertions(+), 6 deletions(-) create mode 100644 quickwit/quickwit-ui/src/components/CreateIndexDialog.tsx create mode 100644 quickwit/quickwit-ui/src/components/YamlEditor.tsx diff --git a/quickwit/quickwit-ui/src/components/CreateIndexDialog.tsx b/quickwit/quickwit-ui/src/components/CreateIndexDialog.tsx new file mode 100644 index 00000000000..4d67e6a78cf --- /dev/null +++ b/quickwit/quickwit-ui/src/components/CreateIndexDialog.tsx @@ -0,0 +1,146 @@ +// Copyright 2021-Present Datadog, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + Alert, + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, +} from "@mui/material"; +import { useMemo, useState } from "react"; +import { Client } from "../services/client"; +import { ResponseError } from "../utils/models"; +import { YamlEditor } from "./YamlEditor"; + +// Starter config offered when the dialog opens. It mirrors the index config +// files accepted by `quickwit index create --index-config`. +export const DEFAULT_INDEX_CONFIG_YAML = `version: 0.9 + +index_id: my-index + +doc_mapping: + field_mappings: + - name: timestamp + type: datetime + fast: true + input_formats: + - rfc3339 + fast_precision: seconds + - name: body + type: text + tokenizer: default + record: position + stored: true + timestamp_field: timestamp + +search_settings: + default_search_fields: + - body + +indexing_settings: + commit_timeout_secs: 30 +`; + +const EDITOR_HEIGHT_PX = 420; + +export default function CreateIndexDialog({ + open, + onClose, + onIndexCreated, +}: Readonly<{ + open: boolean; + onClose: () => void; + onIndexCreated: () => void; +}>) { + const [indexConfig, setIndexConfig] = useState(DEFAULT_INDEX_CONFIG_YAML); + const [submitting, setSubmitting] = useState(false); + const [responseError, setResponseError] = useState( + null, + ); + const quickwitClient = useMemo(() => new Client(), []); + + const handleClose = () => { + // Closing mid-flight would leave the dialog unable to report the outcome. + if (submitting) { + return; + } + setResponseError(null); + onClose(); + }; + + const handleCreate = () => { + setSubmitting(true); + setResponseError(null); + quickwitClient.createIndex(indexConfig).then( + () => { + setSubmitting(false); + setResponseError(null); + setIndexConfig(DEFAULT_INDEX_CONFIG_YAML); + onIndexCreated(); + }, + (error) => { + // Keep the dialog open so the config can be fixed and resubmitted. + setSubmitting(false); + setResponseError(error); + }, + ); + }; + + return ( + + Create index + + + Paste an index config in YAML. + + + + + {responseError !== null && ( + + {responseError.message} + + )} + + + + + + + ); +} diff --git a/quickwit/quickwit-ui/src/components/YamlEditor.tsx b/quickwit/quickwit-ui/src/components/YamlEditor.tsx new file mode 100644 index 00000000000..9c8361c4b59 --- /dev/null +++ b/quickwit/quickwit-ui/src/components/YamlEditor.tsx @@ -0,0 +1,58 @@ +// Copyright 2021-Present Datadog, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { BeforeMount, Editor } from "@monaco-editor/react"; +import { EDITOR_THEME } from "../utils/theme"; + +// Editable YAML editor. Unlike `JsonEditor`, it works on a raw string and lets +// the caller own the value, and it does not size itself: the caller must give +// it a container with a definite height. +export function YamlEditor({ + value, + onChange, +}: { + value: string; + onChange: (value: string) => void; +}) { + const beforeMount: BeforeMount = (monaco) => { + monaco.editor.defineTheme("quickwit-light", EDITOR_THEME); + }; + + return ( + onChange(newValue ?? "")} + beforeMount={beforeMount} + options={{ + fontFamily: "monospace", + overviewRulerBorder: false, + overviewRulerLanes: 0, + minimap: { + enabled: false, + }, + scrollbar: { + alwaysConsumeMouseWheel: false, + }, + renderLineHighlight: "gutter", + fontSize: 12, + fixedOverflowWidgets: true, + scrollBeyondLastLine: false, + automaticLayout: true, + tabSize: 2, + }} + theme="quickwit-light" + /> + ); +} diff --git a/quickwit/quickwit-ui/src/services/client.ts b/quickwit/quickwit-ui/src/services/client.ts index ef5dec8e6cc..14392684a19 100644 --- a/quickwit/quickwit-ui/src/services/client.ts +++ b/quickwit/quickwit-ui/src/services/client.ts @@ -106,9 +106,6 @@ export class Client { return this.fetch(`${this.apiRoot()}indexes`, {}); } - // Creates an index from a raw index config expressed in YAML. The config is - // sent as-is: the server selects its parser from the `content-type` subtype - // and reports validation errors, so no client-side YAML parsing is done here. async createIndex(indexConfigYaml: string): Promise { return this.fetch( `${this.apiRoot()}indexes`, @@ -148,9 +145,6 @@ export class Client { }); } - // Quickwit reports errors as a `{"message": "..."}` JSON envelope. Anything - // else (a proxy error page, for instance) is surfaced verbatim so failures - // are never hidden. private async extractErrorMessage(response: Response): Promise { const rawBody = await response.text(); try { From c047b0ea3a13bbf89c3939d2e532cb01cb2943f0 Mon Sep 17 00:00:00 2001 From: "cemre.mengu" Date: Fri, 28 Aug 2026 20:16:32 +0300 Subject: [PATCH 3/4] cleanup comments --- quickwit/quickwit-ui/src/components/YamlEditor.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/quickwit/quickwit-ui/src/components/YamlEditor.tsx b/quickwit/quickwit-ui/src/components/YamlEditor.tsx index 9c8361c4b59..74142d02977 100644 --- a/quickwit/quickwit-ui/src/components/YamlEditor.tsx +++ b/quickwit/quickwit-ui/src/components/YamlEditor.tsx @@ -15,9 +15,6 @@ import { BeforeMount, Editor } from "@monaco-editor/react"; import { EDITOR_THEME } from "../utils/theme"; -// Editable YAML editor. Unlike `JsonEditor`, it works on a raw string and lets -// the caller own the value, and it does not size itself: the caller must give -// it a container with a definite height. export function YamlEditor({ value, onChange, From 63ebfe493f525a513a822df29a2b121000b9442e Mon Sep 17 00:00:00 2001 From: "cemre.mengu" Date: Fri, 28 Aug 2026 20:27:36 +0300 Subject: [PATCH 4/4] disable button while loading indexes --- quickwit/quickwit-ui/src/views/IndexesView.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/quickwit/quickwit-ui/src/views/IndexesView.tsx b/quickwit/quickwit-ui/src/views/IndexesView.tsx index 770406a7fd1..409a30a613f 100644 --- a/quickwit/quickwit-ui/src/views/IndexesView.tsx +++ b/quickwit/quickwit-ui/src/views/IndexesView.tsx @@ -89,6 +89,7 @@ function IndexesView() {