diff --git a/AGENTS.md b/AGENTS.md index 29442a0db..016456627 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -204,6 +204,13 @@ without having run them. ### Sign off locally before updating the pull request — never push and wait on CI +**Run sign-off only at the end, after all requested work, reviews, and focused +verification are complete. Sign-off is expensive: do not start or queue +`make ci-signoff`, full CI, or deploy-image builds while implementation or UI +iteration is still in progress. Use the existing watchers and focused tests +during development. If more work is requested after sign-off starts, stop that +run and finish the work before starting the final sign-off again.** + **A push to `master` IS a deploy, and remote CI is far too slow to be a feedback loop: an image build alone runs ~85 minutes.** So `make ci-signoff` must be green *before* you push the branch. It runs the checks locally and publishes diff --git a/e2e/tests/container-identity.spec.ts b/e2e/tests/container-identity.spec.ts index 809fd5414..55a34eed5 100644 --- a/e2e/tests/container-identity.spec.ts +++ b/e2e/tests/container-identity.spec.ts @@ -61,94 +61,7 @@ test("navbar refresh menu applies Off and interval options", async ({ page }) => } }); -test("changing the container time preset refreshes inventory and drawer windows", async ({ page }) => { - test.setTimeout(90_000); // Includes a real 15s live tick, repeated presets and held initial arrival. - test.skip(!process.env.E2E_BASE_URL, "Requires a disposable fixture database"); - const namespace = "e2e-container-preset"; - const cleanup = `DELETE FROM otel_metrics WHERE project_id='${DEMO_PROJECT}' AND resource->'k8s'->'namespace'->>'name'='${namespace}';`; - sql(cleanup + [30, 600].map(age => `INSERT INTO otel_metrics (project_id,id,series_id,timestamp,metric_name,metric_type,value,resource___k8s___container___name,resource___k8s___pod___name,resource___k8s___namespace___name,resource) - VALUES ('${DEMO_PROJECT}',gen_random_uuid(),'preset-${age}','${new Date(Date.now() - age * 1000).toISOString()}','container.cpu.usage','GAUGE',1,'preset-${age}','preset-pod-${age}','${namespace}', - '${JSON.stringify({ k8s: { container: { name: `preset-${age}` }, pod: { name: `preset-pod-${age}` }, namespace: { name: namespace }, cluster: { uid: CLUSTER }, node: { name: "preset-node" } } })}');`).join("")); - try { - await page.setViewportSize({ width: 390, height: 900 }); - await page.goto(`/p/${DEMO_PROJECT}/infrastructure/containers?namespace=${namespace}&cluster=${CLUSTER}&since=5M`, { waitUntil: "domcontentloaded" }); - const rows = page.locator('#containersContainer tr[role="button"]'); - await expect(rows).toHaveCount(1); - await expect(page.getByText(/containers reporting in the final 5m of this range/)).toBeVisible(); - const refresh = page.waitForRequest(request => new URL(request.url()).searchParams.get("deferred") === "1" && new URL(request.url()).searchParams.get("since") === "1H"); - await page.locator('[popovertarget="n-timepicker-popover"]').click(); - await page.locator('#n-timepicker-popover button[data-value="1H"]').click(); - const requested = new URL((await refresh).url()).searchParams; - expect(requested.getAll("since")).toEqual(["1H"]); - expect(requested.get("namespace")).toBe(namespace); - expect(requested.get("cluster")).toBe(CLUSTER); - await expect(page).toHaveURL(/since=1H/); - await expect(rows).toHaveCount(2); - await expect(page.getByText(/containers reporting in the final 15m of this range/)).toBeVisible(); - expect(await rows.evaluateAll(rows => rows.every(row => { - const params = new URL(row.getAttribute("data-hx-get")!, location.origin).searchParams; - return params.get("since") === "1H" && params.get("namespace") === "e2e-container-preset" && params.get("cluster") === "8b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d"; - }))).toBe(true); - const search = page.getByRole("searchbox", { name: "Search containers", exact: true }); - await search.fill("preset-600"); - await expect(page.locator('#containersContainer tr[role="button"]:visible')).toHaveCount(1); - for (const [preset, count] of [["5M", 1], ["1H", 2]] as const) { - await page.locator('[popovertarget="n-timepicker-popover"]').click(); - await page.locator(`#n-timepicker-popover button[data-value="${preset}"]`).click(); - await expect(rows).toHaveCount(count); - await expect(search).toHaveValue("preset-600"); - await expect(page.locator('#containersContainer tr[role="button"]:visible')).toHaveCount(preset === "1H" ? 1 : 0); - expect(new URL((await rows.first().getAttribute("data-hx-get"))!, page.url()).searchParams.get("since")).toBe(preset); - } - await search.focus(); - await page.evaluate(() => new Promise(resolve => document.getElementById("containersContainer")!.addEventListener("htmx:afterSwap", () => resolve(), { once: true }))); - await expect(search).toBeFocused(); - await expect(search).toHaveValue("preset-600"); - await expect(page.locator('#containersContainer tr[role="button"]:visible')).toHaveCount(1); - await search.fill(""); - await page.locator('[popovertarget="n-timepicker-popover"]').click(); - await page.getByRole("button", { name: "Previous time window", exact: true }).click(); - await expect.poll(() => new URL(page.url()).searchParams.get("from")).toBeTruthy(); - await expect(rows).toHaveCount(0); - await page.locator('#n-timepicker-popover button[data-value="1H"]').click(); - await expect(rows).toHaveCount(2); - const restored = new URL((await rows.first().getAttribute("data-hx-get"))!, page.url()).searchParams; - expect(restored.get("since")).toBe("1H"); - expect(restored.get("from") || restored.get("to")).toBeFalsy(); - await page.reload({ waitUntil: "domcontentloaded" }); - await expect(rows).toHaveCount(2); - await rows.filter({ hasText: "preset-600" }).focus(); - await page.keyboard.press("Enter"); - const drawer = page.locator("#global-data-drawer-content"); - await expect(drawer.getByText(/^Pod:\s*preset-pod-600$/)).toBeVisible(); - expect(new URL((await drawer.getByRole("link", { name: "View logs", exact: true }).getAttribute("href"))!, page.url()).searchParams.get("since")).toBe("1H"); - let release = () => {}; - const held = new Promise(resolve => { release = resolve; }); - await page.route("**/infrastructure/containers?**", async route => { - const params = new URL(route.request().url()).searchParams; - if (params.get("deferred") === "1" && params.get("since") === "5M") await held; - await route.continue(); - }); - try { - const initial = page.waitForRequest(request => new URL(request.url()).searchParams.get("deferred") === "1"); - await page.goto(`/p/${DEMO_PROJECT}/infrastructure/containers?namespace=${namespace}&cluster=${CLUSTER}&since=5M`, { waitUntil: "domcontentloaded" }); - await initial; - await page.locator('[popovertarget="n-timepicker-popover"]').click(); - await page.locator("[data-mobile-live-toggle]").click(); - await expect(page.locator("[data-time-transport]")).toHaveAttribute("data-interval", "0"); - await page.locator('#n-timepicker-popover button[data-value="1H"]').click(); - release(); - await expect(rows).toHaveCount(2); - expect(new URL((await rows.first().getAttribute("data-hx-get"))!, page.url()).searchParams.get("since")).toBe("1H"); - } finally { - release(); - } - } finally { - sql(cleanup); - } -}); - -for (const width of [390, 1280]) { +for (const width of [390, 1280, 1920]) { test(`duplicate containers expose their identity before opening a drawer at ${width}px`, async ({ page }) => { await page.setViewportSize({ width, height: 900 }); await page.goto(`/p/${DEMO_PROJECT}/infrastructure/containers?from=2024-12-31T23:00:00Z&to=2025-01-01T00:00:00Z`, { waitUntil: "domcontentloaded" }); @@ -163,7 +76,7 @@ for (const width of [390, 1280]) { return value ? value.getClientRects().length > 0 : null; }); })); - expect(visible).toEqual(Array(identities.length * 4).fill(width < 1024)); + expect(visible).toEqual(Array(identities.length * 4).fill(width < 1536)); await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); }); } @@ -213,38 +126,30 @@ for (const width of [390, 1280]) { expect(data.error ?? null).toBeNull(); expect(data.dataset.map((row: number[]) => row.slice(1))).toEqual([[2]]); await expect(drawer.locator("#container-detail-cpu canvas")).toBeVisible(); - }); -} - -for (const theme of ["dark", "light"]) { - test(`a lone container metric observation has a visible point in ${theme} appearance`, async ({ page }) => { - await page.setViewportSize({ width: 1280, height: 900 }); - await page.goto(`/p/${DEMO_PROJECT}/infrastructure/containers?namespace=identity-b&from=2024-12-31T23:00:00Z&to=2025-01-01T00:00:00Z`, { waitUntil: "domcontentloaded" }); - await expect(page.locator("[data-deferred-shell]")).toHaveCount(0); - if (await page.locator("body").getAttribute("data-theme") !== theme) await page.evaluate(() => (window as any).toggleDarkMode()); - await page.locator(`tr[role="button"][data-hx-get*="container=${CONTAINER}"] td`).first().click(); - await expect(page.locator("#global-data-drawer-content #container-detail-cpu canvas")).toBeVisible(); - await expect(page.locator("#container-detail-cpu")).toHaveAttribute("aria-busy", "false"); - const rendered = await page.evaluate(() => { - const chart = (window as any).echarts.getInstanceByDom(document.getElementById("container-detail-cpu")); - const data = chart.getModel().getSeriesByIndex(0)?.getData(); - if (!data) return { values: [], symbols: 0 }; - const dimension = data.mapDimension("y"); - const values = Array.from({ length: data.count() }, (_, index) => data.get(dimension, index)).filter(Number.isFinite); - const symbols = Array.from({ length: data.count() }, (_, index) => data.getItemGraphicEl(index)).filter(element => element && !element.ignore && element.getBoundingRect().width > 0 && element.getBoundingRect().height > 0).length; - return { values, symbols }; - }); - expect(rendered.values).toEqual([2]); - expect(rendered.symbols).toBe(1); - const point = await page.evaluate(() => { - const element = document.getElementById("container-detail-cpu")!; - const chart = (window as any).echarts.getInstanceByDom(element); - const pixel = chart.convertToPixel({ seriesIndex: 0 }, chart.getOption().dataset[0].source[1]); - const bounds = element.getBoundingClientRect(); - return { x: bounds.left + pixel[0], y: bounds.top + pixel[1] }; - }); - await page.mouse.move(point.x, point.y + 1); - await expect(page.getByText("2 cores", { exact: true })).toBeVisible(); + for (const theme of width === 1280 ? ["dark", "light"] : []) { + if (await page.locator("body").getAttribute("data-theme") !== theme) await page.evaluate(() => (window as any).toggleDarkMode()); + await expect(page.locator("#container-detail-cpu")).toHaveAttribute("aria-busy", "false"); + const rendered = await page.evaluate(() => { + const chart = (window as any).echarts.getInstanceByDom(document.getElementById("container-detail-cpu")); + const data = chart.getModel().getSeriesByIndex(0)?.getData(); + if (!data) return { values: [], symbols: 0 }; + const dimension = data.mapDimension("y"); + const values = Array.from({ length: data.count() }, (_, index) => data.get(dimension, index)).filter(Number.isFinite); + const symbols = Array.from({ length: data.count() }, (_, index) => data.getItemGraphicEl(index)).filter(element => element && !element.ignore && element.getBoundingRect().width > 0 && element.getBoundingRect().height > 0).length; + return { values, symbols }; + }); + expect(rendered.values).toEqual([2]); + expect(rendered.symbols).toBe(1); + const point = await page.evaluate(() => { + const element = document.getElementById("container-detail-cpu")!; + const chart = (window as any).echarts.getInstanceByDom(element); + const pixel = chart.convertToPixel({ seriesIndex: 0 }, chart.getOption().dataset[0].source[1]); + const bounds = element.getBoundingClientRect(); + return { x: bounds.left + pixel[0], y: bounds.top + pixel[1] }; + }); + await page.mouse.move(point.x, point.y + 1); + await expect(page.getByText("2 cores", { exact: true })).toBeVisible(); + } }); } @@ -255,13 +160,12 @@ test("mobile namespace facet preserves the selected cluster", async ({ page }) = const rows = page.locator(`tr[role="button"][data-hx-get*="container=${CONTAINER}"]`); await expect(rows).toHaveCount(4); expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); - await page.locator("#containersContainer summary").filter({ hasText: /^Filters$/ }).focus(); - await page.keyboard.press("Enter"); - await page.locator('[data-component="facet-section"] summary').filter({ hasText: /^Namespace$/ }).focus(); + await page.locator("#containersContainer summary").filter({ hasText: /^Filters$/ }).press("Enter"); + await page.locator('[data-component="facet-section"] summary').filter({ hasText: /^Namespace$/ }).press("Enter"); + await expect(page.getByRole("radio", { name: "identity-a", exact: true })).toBeHidden(); await page.keyboard.press("Enter"); const changed = page.waitForResponse(response => new URL(response.url()).searchParams.get("namespace") === "identity-a"); - await page.getByRole("radio", { name: "identity-a", exact: true }).focus(); - await page.keyboard.press("Space"); + await page.getByRole("radio", { name: "identity-a", exact: true }).press("Space"); const url = new URL((await changed).url()); await expect(rows).toHaveCount(3); expect(url.searchParams.getAll("cluster")).toEqual([CLUSTER]); @@ -269,24 +173,19 @@ test("mobile namespace facet preserves the selected cluster", async ({ page }) = expect(url.searchParams.get("from")).toBe("2024-12-31T23:00:00Z"); expect(url.searchParams.get("to")).toBe("2025-01-01T00:00:00Z"); expect(await rows.evaluateAll((rows, cluster) => rows.every(row => new URL(row.getAttribute("data-hx-get")!, "http://localhost").searchParams.get("cluster") === cluster), CLUSTER)).toBe(true); - await page.locator("#containersContainer summary").filter({ hasText: /^Filters$/ }).focus(); - await page.keyboard.press("Enter"); + await page.locator("#containersContainer summary").filter({ hasText: /^Filters$/ }).press("Enter"); const replaced = page.waitForResponse(response => new URL(response.url()).searchParams.get("namespace") === "identity-b"); - await page.getByRole("radio", { name: "identity-b", exact: true }).focus(); - await page.keyboard.press("Space"); + await page.getByRole("radio", { name: "identity-b", exact: true }).press("Space"); const replacedUrl = new URL((await replaced).url()); await expect(rows).toHaveCount(1); expect(replacedUrl.searchParams.getAll("namespace")).toEqual(["identity-b"]); expect(replacedUrl.searchParams.getAll("cluster")).toEqual([CLUSTER]); - await page.getByRole("button", { name: "Remove filter Namespace: identity-b", exact: true }).focus(); - await page.keyboard.press("Enter"); + await page.getByRole("button", { name: "Remove filter Namespace: identity-b", exact: true }).press("Enter"); await expect(rows).toHaveCount(4); await expect(page.getByRole("button", { name: `Remove filter Cluster: ${CLUSTER}`, exact: true })).toBeVisible(); - await page.locator("#containersContainer summary").filter({ hasText: /^Filters$/ }).focus(); - await page.keyboard.press("Enter"); + await page.locator("#containersContainer summary").filter({ hasText: /^Filters$/ }).press("Enter"); const cleared = page.waitForResponse(response => new URL(response.url()).pathname.endsWith("/infrastructure/containers")); - await page.getByRole("button", { name: "Clear all", exact: true }).focus(); - await page.keyboard.press("Enter"); + await page.getByRole("button", { name: "Clear all", exact: true }).press("Enter"); const clearUrl = new URL((await cleared).url()); await expect(rows).toHaveCount(5); for (const key of ["runtime", "cluster", "namespace", "node", "image"]) expect(clearUrl.searchParams.has(key)).toBe(false); @@ -301,6 +200,9 @@ test("mobile container search remains reachable and preserves drawer identity", await expect(page.locator("[data-deferred-shell]")).toHaveCount(0); const rows = page.locator(`tr[role="button"][data-hx-get*="container=${CONTAINER}"]:visible`); await expect(rows).toHaveCount(4); + const cpu = rows.filter({ hasText: "Node / host: identity-node-b" }).locator("td").nth(4).locator("span"); + await expect(cpu).toHaveText("4.000"); + await expect(cpu).toBeInViewport({ ratio: 0.95 }); const search = page.getByPlaceholder("Search containers", { exact: true }); await expect(search).toBeVisible(); expect(await search.evaluate(input => parseFloat(getComputedStyle(input).fontSize))).toBeGreaterThanOrEqual(16); @@ -319,27 +221,13 @@ test("mobile container search remains reachable and preserves drawer identity", expect(url.searchParams.get("from")).toBe("2024-12-31T23:00:00Z"); expect(url.searchParams.get("to")).toBe("2025-01-01T00:00:00Z"); expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); - await rows.focus(); - await page.keyboard.press("Enter"); + await rows.press("Enter"); const drawer = page.locator("#global-data-drawer-content"); await expect(drawer.getByText(/^Node \/ host:\s*identity-node-b$/)).toBeVisible(); await expect(drawer.getByText(/^Namespace:\s*identity-a$/)).toBeVisible(); }); -test("mobile container CPU fits initially with an active cluster filter", async ({ page }) => { - await page.setViewportSize({ width: 390, height: 900 }); - await page.goto(`/p/${DEMO_PROJECT}/infrastructure/containers?cluster=${CLUSTER}&from=2024-12-31T23:00:00Z&to=2025-01-01T00:00:00Z`, { waitUntil: "domcontentloaded" }); - await expect(page.locator("[data-deferred-shell]")).toHaveCount(0); - const rows = page.locator(`tr[role="button"][data-hx-get*="container=${CONTAINER}"]`); - await expect(rows).toHaveCount(4); - const cpu = rows.filter({ hasText: "Node / host: identity-node-b" }).locator("td").nth(4).locator("span"); - await expect(cpu).toHaveText("4.000"); - await expect(cpu).toBeInViewport({ ratio: 0.95 }); - expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); -}); - - for (const width of [390, 1280]) { test(`container CPU fits initially with collector image and workload metadata at ${width}px`, async ({ page }) => { test.skip(!process.env.E2E_BASE_URL, "Requires a disposable fixture server"); diff --git a/e2e/tests/dashboard-add-widget.spec.ts b/e2e/tests/dashboard-add-widget.spec.ts index 8f0ec2572..3cc3f53d5 100644 --- a/e2e/tests/dashboard-add-widget.spec.ts +++ b/e2e/tests/dashboard-add-widget.spec.ts @@ -43,6 +43,7 @@ async function openFixture(page: Page): Promise { async function openWidgetDrawer(page: Page) { await page.locator('[aria-label="Add a new widget"]').first().click(); await expect(page.locator("#visualizationTabs")).toBeVisible({ timeout: 20000 }); + await expect(page.getByRole("textbox", { name: "Query", exact: true })).toBeVisible(); } test("a slow avatar does not block dashboard initialization", async ({ page }) => { diff --git a/e2e/tests/infrastructure-time-window.spec.ts b/e2e/tests/infrastructure-time-window.spec.ts index bf07abd29..acf2816f0 100644 --- a/e2e/tests/infrastructure-time-window.spec.ts +++ b/e2e/tests/infrastructure-time-window.spec.ts @@ -24,8 +24,92 @@ const views = [ ["hosts", "hostsContainer", { provider, group: "region" }, "Search hosts"], ["images", "imagesContainer", { registry: "registry.example", runtime: "kubernetes" }, "Search images"], ["kubernetes", "kubernetesContainer", { resource: "pods", namespace, cluster }, "Search pods"], + ["containers", "containersContainer", { namespace, cluster }, "Search containers"], ["host-map", "hostMapContainer", { provider, group: "region", fill: "memory" }, ""], ] as const; + +for (const width of [390, 1440]) { + test(`shared host controls preserve filters, columns and grouped exports at ${width}px`, async ({ page }) => { + test.skip(!process.env.E2E_BASE_URL, "Requires a disposable fixture database"); + await page.setViewportSize({ width, height: 900 }); + await page.goto(`/p/${DEMO_PROJECT}/infrastructure/hosts?${new URLSearchParams({ provider, since: "1H" })}`); + const container = page.locator('#hostsContainer'); + await expect(container.locator('tr[role="button"]')).toHaveCount(2); + if (width === 1440) { + await container.evaluate(async (target, url) => { await (window as any).htmx.ajax('GET', url, { target, swap: 'beforeend', select: '#imagesContainer-facets' }); }, `/p/${DEMO_PROJECT}/infrastructure/images?since=1H&deferred=1`); + const other = page.locator('#imagesContainer-facets'); + await expect(other.getByRole('radio', { name: 'kubernetes', exact: true })).toBeVisible(); + const search = container.locator('#hostsContainer-facets').getByRole('searchbox'); + await search.fill(provider); + await expect(other.getByRole('radio', { name: 'kubernetes', exact: true })).toBeVisible(); + await search.fill(''); + await other.evaluate(rail => rail.remove()); + } + await expect(container.getByRole('button', { name: 'Customize', exact: true })).toBeInViewport(); + await expect(container.getByRole('button', { name: 'Export', exact: true })).toBeInViewport(); + await container.getByRole('button', { name: 'Customize', exact: true }).click(); + await container.locator('[data-column-toggle="CPU"]').uncheck(); + await container.getByRole('button', { name: 'Customize', exact: true }).click(); + await container.getByRole('combobox', { name: 'Group by', exact: true }).selectOption('region'); + await expect(page).toHaveURL(/group=region/); + expect(new URL(page.url()).searchParams.get('provider')).toBe(provider); + expect(new URL(page.url()).searchParams.get('since')).toBe('1H'); + expect(new URL(page.url()).searchParams.has('deferred')).toBe(false); + await expect(container.locator('[data-table-group]')).toHaveCount(1); + await expect(container.locator('td[data-column-key="CPU"]').first()).toBeHidden(); + const search = container.getByRole('searchbox', { name: 'Search hosts', exact: true }); + await search.fill('no-such-host'); + await expect(container.locator('[data-table-group]')).toBeHidden(); + await search.fill(''); + await expect(container.locator('[data-table-group]')).toBeVisible(); + const download = page.waitForEvent('download'); + await container.getByRole('button', { name: 'Export', exact: true }).click(); + const stream = await (await download).createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream!) chunks.push(Buffer.from(chunk)); + const csv = Buffer.concat(chunks).toString(); + expect(csv.split('\n')).toHaveLength(3); + expect(csv).not.toContain('"CPU"'); + expect(csv).toContain('infra-preset-host-30'); + expect(csv).toContain('infra-preset-host-600'); + await page.reload(); + await expect(container.locator('tr[role="button"]')).toHaveCount(2); + await expect(container.locator('td[data-column-key="CPU"]').first()).toBeHidden(); + await container.getByRole('button', { name: 'Customize', exact: true }).click(); + await container.getByRole('button', { name: 'Reset columns', exact: true }).click(); + await expect(container.locator('td[data-column-key="CPU"]').first()).toBeVisible(); + if (width === 390) { + const system = container.locator('[data-column-toggle="System"]'); + await expect(system).not.toBeChecked(); + await system.check(); + await expect(container.locator('td[data-column-key="System"]').first()).toBeVisible(); + await container.getByRole('button', { name: 'Reset columns', exact: true }).click(); + await expect(container.locator('td[data-column-key="System"]').first()).toBeHidden(); + await page.setViewportSize({ width: 1440, height: 900 }); + await expect(container.locator('td[data-column-key="System"]').first()).toBeVisible(); + await expect(system).toBeChecked(); + } + }); +} + +test('shared Kubernetes tabs preserve scoped filters and column choices', async ({ page }) => { + test.skip(!process.env.E2E_BASE_URL, "Requires a disposable fixture database"); + await page.goto(`/p/${DEMO_PROJECT}/infrastructure/kubernetes?${new URLSearchParams({ resource: 'pods', namespace, cluster, since: '1H' })}`); + const container = page.locator('#kubernetesContainer'); + await expect(container.locator('tr[role="button"]')).toHaveCount(2); + await container.getByRole('button', { name: 'Customize', exact: true }).click(); + await container.locator('[data-column-toggle="Memory"]').uncheck(); + await container.getByRole('button', { name: 'Customize', exact: true }).click(); + await container.getByRole('tab', { name: 'Nodes', exact: true }).click(); + await expect(page).toHaveURL(/resource=nodes/); + const params = new URL(page.url()).searchParams; + expect(params.get('namespace')).toBe(namespace); + expect(params.get('cluster')).toBe(cluster); + expect(params.get('since')).toBe('1H'); + await expect(container.locator('tr[role="button"]')).toHaveCount(2); + await expect(container.locator('td[data-column-key="Memory"]').first()).toBeHidden(); +}); + for (const [route, container, filters, searchName] of views) { test(`${route} time presets refresh scoped inventory`, async ({ page }) => { test.skip(!process.env.E2E_BASE_URL, "Requires a disposable fixture database"); @@ -33,8 +117,9 @@ for (const [route, container, filters, searchName] of views) { const url = `/p/${DEMO_PROJECT}/infrastructure/${route}?${new URLSearchParams({ ...filters, from: "", to: "", since: "5m" })}`; await page.goto(url, { waitUntil: "domcontentloaded" }); await expect(page.locator("[data-deferred-shell]")).toHaveCount(0, { timeout: 5_000 }); - const entries = page.locator(`#${container} ${route === "host-map" ? "button[data-hx-get]" : 'tr[role="button"]'}`); + const entries = page.locator(`#${container} ${route === "host-map" ? 'button[data-hx-get*="/infrastructure/hosts/detail"]' : 'tr[role="button"]'}`); await expect(entries).toHaveCount(1); + if (route === "containers") await expect(page.getByText(/containers reporting in the final 5m of this range/)).toBeVisible(); await page.locator('[popovertarget="n-timepicker-popover"]').click(); await page.locator("[data-mobile-live-toggle]").click(); await expect(page.locator("[data-time-transport]")).toHaveAttribute("data-interval", "0"); @@ -43,6 +128,7 @@ for (const [route, container, filters, searchName] of views) { await page.locator('#n-timepicker-popover button[data-value="1H"]').click(); await expect(page).toHaveURL(/since=1H/); await expect(entries).toHaveCount(2); + if (route === "containers") await expect(page.getByText(/containers reporting in the final 15m of this range/)).toBeVisible(); const params = new URL((await refresh).url()).searchParams; expect(params.getAll("since")).toEqual(["1H"]); expect(params.getAll("from")).toEqual([""]); @@ -86,6 +172,14 @@ for (const [route, container, filters, searchName] of views) { const restored = new URL((await entries.first().getAttribute("data-hx-get"))!, page.url()).searchParams; expect(restored.get("since")).toBe("1H"); expect(restored.get("from") || restored.get("to")).toBeFalsy(); + if (route === "containers") { + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(entries).toHaveCount(2); + await entries.filter({ hasText: "600" }).press("Enter"); + const drawer = page.locator("#global-data-drawer-content"); + await expect(drawer.getByText(/^Pod:\s*infra-preset-pod-600$/)).toBeVisible(); + expect(new URL((await drawer.getByRole("link", { name: "View logs", exact: true }).getAttribute("href"))!, page.url()).searchParams.get("since")).toBe("1H"); + } }); test(`${route} time presets replace held initial inventory`, async ({ page }) => { test.skip(!process.env.E2E_BASE_URL, "Requires a disposable fixture database"); @@ -107,14 +201,14 @@ for (const [route, container, filters, searchName] of views) { await page.locator('#n-timepicker-popover button[data-value="1H"]').click(); release(); await expect(page.locator("[data-deferred-shell]")).toHaveCount(0, { timeout: 5_000 }); - const entries = page.locator(`#${container} ${route === "host-map" ? "button[data-hx-get]" : 'tr[role="button"]'}`); + const entries = page.locator(`#${container} ${route === "host-map" ? 'button[data-hx-get*="/infrastructure/hosts/detail"]' : 'tr[role="button"]'}`); await expect(entries).toHaveCount(2); expect(new URL((await entries.first().getAttribute("data-hx-get"))!, page.url()).searchParams.get("since")).toBe("1H"); } finally { release(); } }); } -for (const [route, container, filters] of [...views, ["containers", "containersContainer", { namespace, cluster }]] as const) { +for (const [route, container, filters] of views) { test(`${route} initial inventory does not wait for application script arrival`, async ({ page }) => { test.skip(!process.env.E2E_BASE_URL, "Requires a disposable fixture database"); let release = () => {}; @@ -122,7 +216,7 @@ for (const [route, container, filters] of [...views, ["containers", "containersC await page.route(/\/public\/assets\/(?:js\/main\.|web-components\/dist\/js\/index\.).*\.js$/, async route => { await held; await route.continue(); }); try { await page.goto(`/p/${DEMO_PROJECT}/infrastructure/${route}?${new URLSearchParams({ ...filters, from: "", to: "", since: "5M" })}&since=5m`, { waitUntil: "commit" }); - const entries = page.locator(`#${container} ${route === "host-map" ? "button[data-hx-get]" : 'tr[role="button"]'}`); + const entries = page.locator(`#${container} ${route === "host-map" ? 'button[data-hx-get*="/infrastructure/hosts/detail"]' : 'tr[role="button"]'}`); await page.waitForFunction(() => Boolean((window as any).htmx)); await expect.poll(() => page.evaluate(() => typeof (window as any).params)).toBe("undefined"); // The shell may have loaded automatically before this test reaches it. diff --git a/e2e/tests/query-editor.spec.ts b/e2e/tests/query-editor.spec.ts index 4b892ebc5..eb5205e3e 100644 --- a/e2e/tests/query-editor.spec.ts +++ b/e2e/tests/query-editor.spec.ts @@ -145,6 +145,21 @@ test.describe("Query editor", () => { for (const tab of ["Popular", "Saved", "Recent"]) await expect(library.getByRole("tab", { name: tab })).toBeVisible(); }); + + test("facet Clear all removes facet clauses and keeps typed ones", async ({ page }) => { + const facets = page.locator(".facet-rail .facet-item input[data-field]"); + const clear = page.locator(".facet-rail button", { hasText: "Clear all" }); + await facets.first().waitFor({ state: "attached", timeout: 10_000 }).catch(() => {}); + test.skip((await facets.count()) < 2, "no facet values in this database"); + await page.locator("#filterElement").evaluate((el: any) => el.setValue("duration > 1000")); + await expect(clear).toBeHidden(); + await facets.nth(0).check(); + await facets.nth(1).check(); + await expect(clear).toBeVisible(); + await clear.click(); + await expect.poll(() => page.locator("#filterElement").evaluate((el: any) => el.getValue())).toBe("duration > 1000"); + await expect(clear).toBeHidden(); + }); }); // Regression: the explorer ships without Tagify, so the monitor panel's teams picker threw diff --git a/e2e/tests/repositories.spec.ts b/e2e/tests/repositories.spec.ts index a6e19a8ba..a030fbc5c 100644 --- a/e2e/tests/repositories.spec.ts +++ b/e2e/tests/repositories.spec.ts @@ -197,7 +197,7 @@ test('one repository list owns service links and independent capabilities in bot expect(sql(`SELECT count(*) FROM projects.code_mappings WHERE project_id = '${pid}'`).toString().trim()).toBe('2'); await page.getByRole('button', { name: 'Save review settings', exact: true }).press('Enter'); await expect(page.locator('#repository-detail')).toHaveCount(1); - expect(sql(`SELECT enabled FROM projects.pr_review_settings WHERE project_id = '${pid}' AND owner = 'team-a'`).toString().trim()).toBe('t'); + await expect.poll(() => sql(`SELECT enabled FROM projects.pr_review_settings WHERE project_id = '${pid}' AND owner = 'team-a'`).toString().trim()).toBe('t'); await page.getByText('Move existing resources into this folder', { exact: true }).click(); await page.getByRole('combobox', { name: /Cron/ }).selectOption(cron); await page.getByRole('combobox', { name: /Cron/ }).locator('..').locator('..').getByRole('button', { name: 'Move into folder', exact: true }).press('Enter'); diff --git a/monoscope.cabal b/monoscope.cabal index 3fabfca8b..8c712899d 100644 --- a/monoscope.cabal +++ b/monoscope.cabal @@ -386,6 +386,7 @@ library Pages.Telemetry Pkg.AI Pkg.AssetManifestFingerprint + Pkg.Components.Facets Pkg.Components.LogQueryBox Pkg.Components.ServiceMap Pkg.Components.Table @@ -1030,6 +1031,7 @@ test-suite test-dev Pages.Telemetry Pkg.AI Pkg.AssetManifestFingerprint + Pkg.Components.Facets Pkg.Components.LogQueryBox Pkg.Components.ServiceMap Pkg.Components.Table diff --git a/src/Pages/Components.hs b/src/Pages/Components.hs index a488f8782..fd3204785 100644 --- a/src/Pages/Components.hs +++ b/src/Pages/Components.hs @@ -1,4 +1,4 @@ -module Pages.Components (drawer_, PanelStyle (..), PanelCfg (..), panel_, entityHeader_, detailSection_, pivotLinks_, disclosure_, settingsSection_, settingsH2_, sectionLabel_, fieldLabel_, headerRow_, navBar, brandLogo_, resizer_, resizerWith_, detailTab_, httpTab_, tabPanel_, segmented_, periodToggle_, Deferred (..), RefreshingDeferred (..), withDeferredBody, deferredShell_, lazyPanel_, timeRefreshListener_, drawerLoadingSkeleton_, tableSkeleton_, chartSkeleton_, EmptyStateCfg (..), EmptyStateSize (..), EmptyStateAction (..), emptyState_, callout_, BadgeColor (..), IconBadgeSize (..), iconBadge_, tonePill_, colorChip_, metadataChip_, metaChip_, connectionBadge_, silenceBadge_, statFigure_, factGrid_, kvList_, sparkline_, eventsCountCell_, lastSeenCell_, dateTime, localTimeFmt_, FieldSize (..), FieldCfg (..), formField_, formSelectField_, formCheckbox_, options_, roleOptions_, tagInput_, toggleRow_, searchInput_, copyField_, copyButton_, facetRail_, facetSection_, facetOption_, ModalCfg (..), modalWith_, formActionsModal_, dangerAction_, floatingDialog_, dialogField_, kbdHints_, durationMenu_, primaryButton_, inlineIconBtn_, RowAction (..), rowActions_, externalLink_, installationSettingsLink_, dirtyFormSaveAttr_, resetFormOnSuccessAttr_, detailsClosedBelowAttr_, keyboardActivateAttr_, listboxNavAttr_, copySourceAttr_, filterInputAttr_, TraceFrame (..), stackTrace_, stackFrames_, codeContextUrl, curlCommand, getTargetPage, abbreviateUnit, agoText, durationQuery, untilLabel, grantsTrial, paymentPlanPicker) where +module Pages.Components (drawer_, PanelStyle (..), PanelCfg (..), panel_, entityHeader_, detailSection_, pivotLinks_, disclosure_, settingsSection_, settingsH2_, sectionLabel_, fieldLabel_, headerRow_, navBar, brandLogo_, resizer_, resizerWith_, detailTab_, httpTab_, tabPanel_, segmented_, periodToggle_, Deferred (..), RefreshingDeferred (..), withDeferredBody, deferredShell_, lazyPanel_, timeRefreshListener_, drawerLoadingSkeleton_, tableSkeleton_, chartSkeleton_, EmptyStateCfg (..), EmptyStateSize (..), EmptyStateAction (..), emptyState_, callout_, BadgeColor (..), IconBadgeSize (..), iconBadge_, tonePill_, colorChip_, metadataChip_, metaChip_, connectionBadge_, silenceBadge_, statFigure_, factGrid_, kvList_, sparkline_, eventsCountCell_, lastSeenCell_, dateTime, localTimeFmt_, FieldSize (..), FieldCfg (..), formField_, formSelectField_, formCheckbox_, options_, roleOptions_, tagInput_, toggleRow_, searchInput_, copyField_, copyButton_, ModalCfg (..), modalWith_, formActionsModal_, dangerAction_, floatingDialog_, dialogField_, kbdHints_, durationMenu_, primaryButton_, inlineIconBtn_, RowAction (..), rowActions_, externalLink_, installationSettingsLink_, dirtyFormSaveAttr_, resetFormOnSuccessAttr_, detailsClosedBelowAttr_, keyboardActivateAttr_, listboxNavAttr_, copySourceAttr_, filterInputAttr_, TraceFrame (..), stackTrace_, stackFrames_, codeContextUrl, curlCommand, getTargetPage, abbreviateUnit, agoText, durationQuery, untilLabel, grantsTrial, paymentPlanPicker) where import Data.Default (Default (..)) import Data.List (elemIndex, findIndex, lookup) @@ -77,55 +77,6 @@ searchInput_ extra ph attrs = label_ [class_ $ "input input-sm flex items-center input_ $ [type_ "search", class_ "min-w-0 grow bg-transparent max-md:text-base", placeholder_ ph, Aria.label_ (T.dropWhileEnd (== '…') ph)] <> attrs --- | Shared shell for searchable facet trees. The content decides how filters change --- (query-editor operations in Explorer, URL parameters in inventories); search, --- accessibility, and disclosure markers stay identical. -facetRail_ :: Maybe Text -> Text -> Text -> Maybe (Html ()) -> Html () -> Html () -facetRail_ elemId extraClass searchLabel actions content = - div_ ([class_ $ "facet-rail flex flex-col gap-2 " <> extraClass, data_ "component" "facet-rail"] <> [id_ x | x <- maybeToList elemId]) do - searchInput_ - "sticky top-0 z-10 h-9 w-[calc(100%-1rem)] mx-2 border-strokeStrong bg-bgBase" - searchLabel - [ name_ "facet-search" - , oninput_ "const q=this.value.toLowerCase();this.closest('[data-component=\"facet-rail\"]').querySelectorAll('[data-component=\"facet-option\"],[data-component=\"facet-section\"]').forEach(el=>el.classList.toggle('hidden',!el.textContent.toLowerCase().includes(q)))" - , onkeydown_ "if(event.key==='Escape'){this.value='';this.dispatchEvent(new Event('input',{bubbles:true}))}" - ] - whenJust actions id - content - - --- | Native details/summary facet disclosure shared by Explorer's lazy hierarchy --- and inventory filter menus. Extra attributes must not include @class@. -facetSection_ :: Bool -> Text -> [Attribute] -> Html () -> Html () -> Html () -facetSection_ startOpen extraClass attrs title content = - details_ - ( [ class_ $ "facet-section block border-t border-strokeWeak [&[open]>summary_.facet-chevron]:rotate-0 " <> extraClass - , data_ "component" "facet-section" - ] - <> attrs - <> [open_ "" | startOpen] - ) - do - summary_ [class_ "flex cursor-pointer list-none items-center gap-2 rounded px-2 py-2 text-xs font-semibold text-textStrong hover:bg-fillWeak [&::-webkit-details-marker]:hidden"] do - faSprite_ "chevron-down" "regular" "facet-chevron h-2.5 w-2.5 shrink-0 -rotate-90 transition-transform" - div_ [class_ "min-w-0 flex-1"] title - content - - --- | Shared option row. Callers provide the control/label body and optional count. -facetOption_ :: Text -> [Attribute] -> Html () -> Html () -> Html () -facetOption_ extraClass attrs body trailing = - label_ - ( [ class_ $ "flex min-h-7 cursor-pointer items-center justify-between gap-2 rounded px-2 py-1 hover:bg-fillWeak " <> extraClass - , data_ "component" "facet-option" - ] - <> attrs - ) - do - div_ [class_ "flex min-w-0 flex-1 items-center gap-2"] body - trailing - - -- | Header of an entity detail drawer: icon and title with inline @badges@, then the -- identity @chips@ row. Its sections go in a @main_@ that owns their rules -- (@[&>*+*]:border-t [&>*+*]:pt-4@), so 'detailSection_' and 'pivotLinks_' carry none. @@ -1116,7 +1067,7 @@ curlCommand method url headers body = T.intercalate " \\\n " $ ("curl -X " <> m -- server-generated ids, never from user input. Pair with @type_ "search"@ so Escape -- clears the box and refilters natively. filterInputAttr_ :: Text -> Attribute -filterInputAttr_ sel = term "_" [text|on input or htmx:after:swap from document set v to my value.toLowerCase() then repeat for el in <${sel}/> if (el.dataset.filter or el.textContent).toLowerCase().includes(v) remove .hidden from el else add .hidden to el end end|] +filterInputAttr_ sel = term "_" [text|on input set v to my value.toLowerCase() then repeat for el in <${sel}/> if (el.dataset.filter or el.textContent).toLowerCase().includes(v) remove .hidden from el else add .hidden to el end end on htmx:after:swap from document if my value is not '' send input to me end on keydown[key is 'Escape'] set my value to '' then send input to me|] -- | Toned icon + label chip. diff --git a/src/Pages/Containers.hs b/src/Pages/Containers.hs index 34f02a95f..5d681cddd 100644 --- a/src/Pages/Containers.hs +++ b/src/Pages/Containers.hs @@ -22,8 +22,8 @@ import Lucid import Models.Projects.Projects qualified as Projects import Models.Telemetry.Containers (ContainerRow (..), Runtime (..), Scope (..), containersInWindowCached, cpuPctOfLimit, freshnessWindow, memPctOfLimit, runtimeOf) import Pages.BodyWrapper (BWConfig (..), PageCtx (..), mkPageCtx) -import Pages.Components (BadgeColor (..), Deferred (..), EmptyStateAction (..), EmptyStateCfg (..), RefreshingDeferred (..), callout_, detailSection_, emptyState_, entityHeader_, factGrid_, metaChip_, pivotLinks_, tableSkeleton_, timeRefreshListener_, withDeferredBody) -import Pkg.Components.Table (Column, Config (..), Features (..), SearchMode (..), Table (..), ZeroState (..), col, facetActions, facetValues, singleSelectFilter, withAttrs, withColHeaderExtra) +import Pages.Components (BadgeColor (..), Deferred (..), EmptyStateAction (..), EmptyStateCfg (..), RefreshingDeferred (..), callout_, detailSection_, emptyState_, entityHeader_, factGrid_, metaChip_, pivotLinks_, tableSkeleton_, withDeferredBody) +import Pkg.Components.Table (Column, Features (..), Table (..), ZeroState (..), col, countedFilter, facetActions, inventoryTable, withAttrs, withColHeaderExtra) import Pkg.Components.TimePicker qualified as TimePicker import Pkg.Components.Widget qualified as Widget import Relude @@ -145,22 +145,16 @@ containersGetH pid runtimeM namespaceM nodeM imageM clusterM fromParam toParam s rows = sortOn (Down . (.cpuCores)) $ V.toList $ applyFilters filters allRows -- Facet values come from the unfiltered result, so choosing a namespace never empties -- the node or image menus — the way a facet bar is expected to behave. - menu label param selected fromRow = singleSelectFilter label param selected $ facetValues fromRow allRows + menu label param selected fromRow = countedFilter label param selected (maybeToList . fromRow) allRows + let table = inventoryTable "containers" deferredUrl pure - Table - { -- bulkActionsInHeader is what puts the header action group — and with it the whole - -- filter dropdown — into a column header. Leaving it unset renders no facet menus - -- at all, however many are configured. - config = def{elemID = "containersForm", containerId = Just "containersContainer", addPadding = True, renderAsTable = True, bulkActionsInHeader = Just 0} - , columns = containerColumns + table + { columns = containerColumns , rows = V.fromList $ map (ContainerVM pid window) rows , features = - def - { header = Just $ timeRefreshListener_ "containersContainer" deferredUrl - , search = Just ClientSide - , searchPlaceholder = Just "Search containers" - , rowAttrs = Just $ drawerRowAttrs_ . detailUrl - , tableHeaderActions = + table.features + { rowAttrs = Just $ drawerRowAttrs_ . detailUrl + , facets = Just $ facetActions (infraUrl pid "/infrastructure/containers" [] window) @@ -173,9 +167,7 @@ containersGetH pid runtimeM namespaceM nodeM imageM clusterM fromParam toParam s , menu "Node" "node" filters.node (.nodeName) , menu "Image" "image" filters.image (.image) ] - , showFilterRail = True , resultSummary = Just $ "Showing " <> show (length rows) <> " of " <> show (V.length allRows) <> " containers reporting in the final " <> freshnessLabel window <> " of this range" - , exportName = Just "containers" , zeroState = Just ZeroState @@ -191,8 +183,8 @@ containersGetH pid runtimeM namespaceM nodeM imageM clusterM fromParam toParam s containerColumns :: [Column ContainerVM] containerColumns = [ col "Container" renderNameCol & withAttrs [class_ "w-80 max-w-80 overflow-hidden max-md:w-auto max-md:max-w-none"] - , col "Pod" (textCell (.podName)) & withAttrs [class_ "w-72 max-w-72 overflow-hidden max-lg:hidden"] - , col "Namespace" (textCell (.namespace)) & withAttrs [class_ "w-32 max-lg:hidden"] + , col "Pod" (textCell (.podName)) & withAttrs [class_ "w-72 max-w-72 overflow-hidden max-2xl:hidden"] + , col "Namespace" (textCell (.namespace)) & withAttrs [class_ "w-32 max-2xl:hidden"] , col "Node" (textCell (.nodeName)) & withAttrs [class_ "w-36 max-md:hidden"] , col "CPU (cores)" (numCell (showFFloat' 3) (.cpuCores)) & withAttrs [class_ "w-24 text-right"] , col "CPU limit used" (pctCell "CPU limit used" cpuPctOfLimit) & withAttrs [class_ "w-36 max-md:hidden"] @@ -248,7 +240,7 @@ containersGetH pid runtimeM namespaceM nodeM imageM clusterM fromParam toParam s in span_ [class_ "min-w-0 flex-1 truncate", data_ "tippy-content" $ "Image: " <> image] $ toHtml image -- The badge is a flex box, so the ellipsis has to live on an inner block or the name clips at its start. whenJust vm.row.workload \workload -> span_ [class_ "badge badge-xs badge-ghost min-w-0 max-w-36 truncate", data_ "tippy-content" $ "Workload: " <> workload] $ span_ [class_ "min-w-0 truncate"] $ toHtml workload - div_ [class_ "hidden max-lg:flex flex-col gap-0.5 text-xs leading-normal text-textWeak"] + div_ [class_ "hidden max-2xl:flex flex-col gap-0.5 text-xs leading-normal text-textWeak"] $ forM_ ([("Pod", vm.row.podName), ("Namespace", vm.row.namespace), ("Cluster", vm.row.cluster), ("Node / host", vm.row.nodeName)] :: [(Text, Maybe Text)]) \(label, value) -> whenJust value \v -> span_ [class_ "whitespace-normal break-all"] $ toHtml $ label <> ": " <> v diff --git a/src/Pages/Infrastructure.hs b/src/Pages/Infrastructure.hs index 591e26be8..bb4082410 100644 --- a/src/Pages/Infrastructure.hs +++ b/src/Pages/Infrastructure.hs @@ -32,14 +32,14 @@ import Pages.Components (BadgeColor (..), Deferred (..), EmptyStateAction (..), import Pages.Containers (deferUrl, infraSnapshot, infraUrl, infrastructureBW, matchesAny, matchesFilter, mkWindow, pctText) import Pages.Containers qualified as Containers import Pages.LogExplorer.Log qualified as Log -import Pkg.Components.Table (Config (..), Features (..), SearchMode (..), Table (..), ZeroState (..), col, facetActions, facetValues, singleSelectFilter, withAttrs) +import Pkg.Components.Facets qualified as Facets +import Pkg.Components.Table (Features (..), SelectControl (..), Table (..), TableTabs (..), ZeroState (..), col, countedFilter, facetActions, facetsConfig, inventoryTable, selectControl_, withAttrs) import Pkg.Components.TimePicker qualified as TimePicker import Pkg.Components.Widget qualified as Widget import Pkg.DeriveUtils (WrappedEnumSC (..)) import Relude -import Relude.Extra.Tuple (dup) import System.Types (ATAuthCtx, RespHeaders, addRespHeaders) -import Utils (TabStrip (..), drawerLoadAttrs_, drawerRowAttrs_, faSprite_, formatBytes, navTabStrip_, showFFloat') +import Utils (drawerLoadAttrs_, drawerRowAttrs_, faSprite_, formatBytes, showFFloat') -- | Group by an optional key, dropping rows without one. Per-key lists come out in reverse @@ -139,19 +139,13 @@ hostGroupOption = \case GroupIntegration -> ("integration", "Integration") --- | Bucket every host falls into. @GroupNone@ collapses to a single bucket, which the table --- skips entirely and the map renders as one section. -groupHosts :: HostGroup -> V.Vector HostRow -> [(Text, [HostRow])] -groupHosts grouping = groupRows \host -> - Just case grouping of - GroupNone -> "All hosts" - GroupProvider -> fromMaybe "Unknown provider" host.provider - GroupRegion -> fromMaybe "Unknown region" host.region - GroupOS -> fromMaybe "Unknown OS" host.osType - GroupIntegration -> maybe "OpenTelemetry" integrationLabel $ listToMaybe $ drop 1 host.integrations - - -data HostListRow = HostGroupRow Text Int | HostItem HostRow +hostGroupLabel :: HostGroup -> HostRow -> Text +hostGroupLabel grouping host = case grouping of + GroupNone -> "All hosts" + GroupProvider -> fromMaybe "Unknown provider" host.provider + GroupRegion -> fromMaybe "Unknown region" host.region + GroupOS -> fromMaybe "Unknown OS" host.osType + GroupIntegration -> maybe "OpenTelemetry" integrationLabel $ listToMaybe $ drop 1 host.integrations hostsGetH :: Projects.ProjectId -> Maybe Text -> Maybe Text -> Maybe Text -> Maybe Text -> Maybe Text -> Maybe Text -> Maybe Text -> Maybe Text -> Maybe Text -> ATAuthCtx (RespHeaders HostsGet) @@ -167,42 +161,30 @@ hostsGetH pid providerM regionM osM integrationM groupM fromParam toParam sinceP addRespHeaders $ HostsPage $ PageCtx (infrastructureBW pid "Hosts" window bw) body -newtype HostsGet = HostsPage (PageCtx (Deferred (Table HostListRow))) - deriving (ToHtml) via (PageCtx (RefreshingDeferred (Table HostListRow))) +newtype HostsGet = HostsPage (PageCtx (Deferred (Table HostRow))) + deriving (ToHtml) via (PageCtx (RefreshingDeferred (Table HostRow))) -hostsTable :: Projects.ProjectId -> TimePicker.TimeWindow -> Text -> HostFilters -> HostGroup -> V.Vector HostRow -> V.Vector HostRow -> Table HostListRow +hostsTable :: Projects.ProjectId -> TimePicker.TimeWindow -> Text -> HostFilters -> HostGroup -> V.Vector HostRow -> V.Vector HostRow -> Table HostRow hostsTable pid window url filters grouping hosts allHosts = - Table - { config = - def - { elemID = "hostsForm" - , containerId = Just "hostsContainer" - , addPadding = True - , renderAsTable = True - , bulkActionsInHeader = Just 0 - , containerClasses = "w-full mx-auto space-y-2 group/columns" - } - , columns = hostColumns - , rows = hostEntries grouping hosts + table + { columns = hostColumns + , rows = V.fromList $ sortOn (\h -> (hostGroupLabel grouping h, h.name)) $ V.toList hosts , features = - def - { search = Just ClientSide - , searchPlaceholder = Just "Search hosts" - , rowAttrs = Just $ \case HostGroupRow _ _ -> []; HostItem host -> drawerRowAttrs_ $ hostDetailUrl pid window host.name - , header = Just $ timeRefreshListener_ "hostsContainer" url >> hostGroupControl - , showFilterRail = True + table.features + { rowAttrs = Just $ drawerRowAttrs_ . hostDetailUrl pid window . (.name) + , rowGroup = if grouping == GroupNone then Nothing else Just $ Just . hostGroupLabel grouping + , grouping = Just $ SelectControl "group" "Group by" (hostGroupParam grouping) (map hostGroupOption [minBound ..]) url "hostsContainer" , resultSummary = Just $ "Showing " <> show (V.length hosts) <> " of " <> show (V.length allHosts) <> " hosts" - , exportName = Just "hosts" - , tableHeaderActions = + , facets = Just $ facetActions (infraUrl pid "/infrastructure/hosts" [("group", hostGroupParam grouping) | grouping /= GroupNone] window) "hostsContainer" - [ singleSelectFilter "Provider" "provider" filters.provider $ facetValues (.provider) allHosts - , singleSelectFilter "Region" "region" filters.region $ facetValues (.region) allHosts - , singleSelectFilter "OS" "os" filters.osType $ facetValues (.osType) allHosts - , singleSelectFilter "Integration" "integration" filters.integration $ map integrationLabel $ ordNub $ concatMap (.integrations) $ V.toList allHosts + [ countedFilter "Provider" "provider" filters.provider (maybeToList . (.provider)) allHosts + , countedFilter "Region" "region" filters.region (maybeToList . (.region)) allHosts + , countedFilter "OS" "os" filters.osType (maybeToList . (.osType)) allHosts + , countedFilter "Integration" "integration" filters.integration (map integrationLabel . (.integrations)) allHosts ] , zeroState = Just @@ -215,17 +197,7 @@ hostsTable pid window url filters grouping hosts allHosts = } } where - hostEntries GroupNone = V.map HostItem - hostEntries g = V.fromList . concatMap (\(label, hs) -> HostGroupRow label (length hs) : map HostItem (sortOn (.name) hs)) . groupHosts g - hostGroupControl = - form_ [method_ "get", action_ $ "/p/" <> pid.toText <> "/infrastructure/hosts", class_ "ml-auto mr-3 flex w-fit items-center gap-2 whitespace-nowrap"] do - TimePicker.timeHiddenInputs_ window.fromQuery window.toQuery window.sinceQuery - forM_ [(field, value) | (field, Just value) <- [("provider", filters.provider), ("region", filters.region), ("os", filters.osType), ("integration", filters.integration)]] \(field, value) -> - input_ [type_ "hidden", name_ field, value_ value] - label_ [Lucid.for_ "hosts-group", class_ "shrink-0 text-xs text-textWeak"] "Group by" - select_ [id_ "hosts-group", name_ "group", class_ "select select-xs w-auto cursor-pointer border-strokeWeak bg-bgBase", onchange_ "this.form.requestSubmit()"] - $ forM_ (map hostGroupOption [minBound ..]) \(value, label) -> - option_ ([value_ value] <> [selected_ "" | value == hostGroupParam grouping]) $ toHtml label + table = inventoryTable "hosts" url -- Configuration only earns its width when some host reports a provider or region; it is -- decided over the unfiltered inventory so a facet never toggles a column. hostColumns = @@ -235,29 +207,26 @@ hostsTable pid window url filters grouping hosts allHosts = , col "CPU" (metricCell (.cpuPct)) & withAttrs [class_ "w-32"] , col "Memory" (metricCell (.memoryPct)) & withAttrs [class_ "w-32"] , col "Storage" (metricCell (.storagePct)) & withAttrs [class_ "host-col-storage w-32 max-lg:hidden"] - , col "Load (1m)" (itemOnly $ plainCell . fmap (showFFloat' 2) . (.load1)) & withAttrs [class_ "host-col-load w-24 text-right max-xl:hidden"] + , col "Load (1m)" (plainCell . fmap (showFFloat' 2) . (.load1)) & withAttrs [class_ "host-col-load w-24 text-right max-xl:hidden"] , col "Uptime" uptimeCell & withAttrs [class_ "w-24 max-xl:hidden"] , col "Containers" containersCell & withAttrs [class_ "w-24 text-right"] , col "Integrations" integrationsCell & withAttrs [class_ "w-56 max-md:hidden"] ] where -- The row itself opens the drawer; a button in the name would be a second tab stop for the same action. - nameCell = \case - HostGroupRow label count -> div_ [class_ "flex items-center gap-2 py-1 font-semibold text-textStrong"] $ toHtml label >> span_ [class_ "badge badge-xs badge-ghost"] (toHtml $ show count) - HostItem host -> div_ [class_ "flex min-w-0 items-center gap-2 font-medium text-textStrong"] do - faSprite_ "server" "solid" "h-3.5 w-3.5 shrink-0 text-iconNeutral" - span_ [class_ "truncate"] $ toHtml host.name - configCell = itemOnly \host -> div_ [class_ "flex flex-wrap gap-1"] do + nameCell host = div_ [class_ "flex min-w-0 items-center gap-2 font-medium text-textStrong"] do + faSprite_ "server" "solid" "h-3.5 w-3.5 shrink-0 text-iconNeutral" + span_ [class_ "truncate"] $ toHtml host.name + configCell host = div_ [class_ "flex flex-wrap gap-1"] do whenJust host.provider $ metadataChip "cloud" . T.toTitle whenJust host.region $ metadataChip "location-dot" - systemCell = itemOnly \host -> span_ [class_ "text-xs text-textWeak"] $ toHtml $ T.intercalate " · " $ catMaybes [host.osType, host.architecture] - metricCell getter = itemOnly $ utilizationCell . getter - uptimeCell = itemOnly $ plainCell . fmap formatUptime . (.uptime) - containersCell = itemOnly $ span_ [class_ "tabular-nums text-textStrong"] . toHtml . show . (.containers) - integrationsCell = itemOnly \host -> div_ [class_ "flex flex-nowrap gap-1"] $ forM_ host.integrations \integration -> span_ [class_ "inline-flex items-center gap-1 whitespace-nowrap rounded-md border border-strokeWeak bg-fillWeak px-1.5 py-0.5 text-xs text-textWeak"] do + systemCell host = span_ [class_ "text-xs text-textWeak"] $ toHtml $ T.intercalate " · " $ catMaybes [host.osType, host.architecture] + metricCell getter = utilizationCell . getter + uptimeCell = plainCell . fmap formatUptime . (.uptime) + containersCell = span_ [class_ "tabular-nums text-textStrong"] . toHtml . show . (.containers) + integrationsCell host = div_ [class_ "flex flex-nowrap gap-1"] $ forM_ host.integrations \integration -> span_ [class_ "inline-flex items-center gap-1 whitespace-nowrap rounded-md border border-strokeWeak bg-fillWeak px-1.5 py-0.5 text-xs text-textWeak"] do faSprite_ (case integration of OpenTelemetryIntegration -> "arrows-turn-right"; KubernetesIntegration -> "cube"; DockerIntegration -> "layer-group") "regular" "h-3 w-3" toHtml $ integrationLabel integration - itemOnly render = \case HostGroupRow _ _ -> mempty; HostItem host -> render host metadataChip icon value = span_ [class_ "inline-flex items-center gap-1 rounded bg-fillWeak px-1.5 py-0.5 text-xs text-textWeak"] $ faSprite_ icon "regular" "h-3 w-3" >> toHtml value @@ -410,9 +379,8 @@ newtype ImagesGet = ImagesPage (PageCtx (Deferred (Table ImageRow))) imagesTable :: Projects.ProjectId -> TimePicker.TimeWindow -> Text -> Maybe Text -> Maybe Text -> V.Vector ImageRow -> V.Vector ImageRow -> Table ImageRow imagesTable pid window url runtimeM registryM images allImages = - Table - { config = def{elemID = "imagesForm", containerId = Just "imagesContainer", addPadding = True, renderAsTable = True, bulkActionsInHeader = Just 0} - , columns = + table + { columns = [ col "Image" (\image -> div_ [class_ "flex items-center gap-2 min-w-0"] $ faSprite_ "layer-group" "solid" "h-3.5 w-3.5 shrink-0 text-iconNeutral" >> span_ [class_ "truncate font-medium text-textStrong"] (toHtml image.image)) & withAttrs [class_ "min-w-72 w-full"] , col "Running" (\image -> span_ [class_ "tabular-nums text-textStrong"] $ toHtml $ show image.running) & withAttrs [class_ "w-24 text-right"] , col "Source" (\image -> span_ [class_ "whitespace-nowrap text-textWeak"] $ toHtml image.registry) & withAttrs [class_ "w-36"] @@ -424,26 +392,23 @@ imagesTable pid window url runtimeM registryM images allImages = ] , rows = images , features = - def - { header = Just $ timeRefreshListener_ "imagesContainer" url - , search = Just ClientSide - , searchPlaceholder = Just "Search images" - , rowAttrs = Just \image -> drawerRowAttrs_ $ infraUrl pid "/infrastructure/images/detail" [("image", image.image)] window - , tableHeaderActions = + table.features + { rowAttrs = Just \image -> drawerRowAttrs_ $ infraUrl pid "/infrastructure/images/detail" [("image", image.image)] window + , facets = Just $ facetActions (infraUrl pid "/infrastructure/images" [] window) "imagesContainer" - [ singleSelectFilter "Runtime" "runtime" runtimeM $ sortNub $ map Containers.runtimeLabel $ concatMap (.runtimes) $ V.toList allImages - , singleSelectFilter "Registry" "registry" registryM $ facetValues (Just . (.registry)) allImages + [ countedFilter "Runtime" "runtime" runtimeM (map Containers.runtimeLabel . (.runtimes)) allImages + , countedFilter "Registry" "registry" registryM (one . (.registry)) allImages ] , zeroState = Just $ ZeroState "layer-group" "No container images reporting" "Images appear when container telemetry includes container.image.name." (ESLink "https://monoscope.tech/docs/sdks/infrastructure/kubernetes" "Container setup guide") - , showFilterRail = True , resultSummary = Just $ "Showing " <> show (V.length images) <> " of " <> show (V.length allImages) <> " images" , exportName = Just "container-images" } } where + table = inventoryTable "images" url -- Two tags and a "+N" pill wrap rather than clip: a long tag used to cut the pill to "(". renderTags image = div_ [class_ "flex max-w-72 flex-wrap gap-1"] do forM_ (take 2 image.tags) $ \tag -> span_ [class_ "badge badge-xs badge-ghost shrink-0"] $ toHtml tag @@ -568,8 +533,9 @@ kubernetesGetH pid resourceM clusterM namespaceM statusM fromParam toParam since let resource = parseParam KubePods display resourceM url = deferUrl pid "/infrastructure/kubernetes" [("resource", resourceM), ("cluster", clusterM), ("namespace", namespaceM), ("status", statusM)] window body <- withDeferredBody deferredM "kubernetesContainer" url (tableSkeleton_ 8) do - allRows <- kubeRowsFromRows resource <$> infraSnapshot pid window - let rows = V.filter (\row -> matchesFilter clusterM row.cluster && matchesFilter namespaceM row.namespace && matchesFilter statusM (Just $ kubeStatusLabel row.status)) allRows + snapshot <- infraSnapshot pid window + let allRows = kubeRowsFromRows resource snapshot + rows = V.filter (\row -> matchesFilter clusterM row.cluster && matchesFilter statusM (Just $ kubeStatusLabel row.status)) $ kubeRowsFromRows resource $ V.filter (matchesFilter namespaceM . (.namespace)) snapshot pure $ kubernetesTable pid window url resource clusterM namespaceM statusM rows allRows addRespHeaders $ KubernetesPage $ PageCtx (infrastructureBW pid "Kubernetes" window bw) body @@ -580,9 +546,8 @@ newtype KubernetesGet = KubernetesPage (PageCtx (Deferred (Table KubeRow))) kubernetesTable :: Projects.ProjectId -> TimePicker.TimeWindow -> Text -> KubeResource -> Maybe Text -> Maybe Text -> Maybe Text -> V.Vector KubeRow -> V.Vector KubeRow -> Table KubeRow kubernetesTable pid window url resource clusterM namespaceM statusM rows allRows = - Table - { config = def{elemID = "kubernetesForm", containerId = Just "kubernetesContainer", addPadding = True, renderAsTable = True, bulkActionsInHeader = Just 0} - , columns = + table + { columns = [ col (resourceLabel resource) (\row -> div_ [class_ "flex min-w-0 items-center gap-2"] $ faSprite_ (kubeIcon resource) "solid" "h-3.5 w-3.5 shrink-0 text-iconNeutral" >> span_ [class_ "min-w-0 truncate font-medium text-textStrong", term "data-tippy-content" row.name] (toHtml $ bool id clusterLabel (resource == KubeClusters) row.name)) & withAttrs [class_ "min-w-56 w-full"] , col "Status" (statusBadge . (.status)) & withAttrs [class_ "w-28"] , col "Cluster" (\row -> maybe Containers.emDash_ (\value -> span_ [class_ "block truncate whitespace-nowrap text-textStrong", term "data-tippy-content" value] $ toHtml $ clusterLabel value) row.cluster) & withAttrs [class_ "w-36 max-lg:hidden"] @@ -597,27 +562,26 @@ kubernetesTable pid window url resource clusterM namespaceM statusM rows allRows ] , rows , features = - def - { search = Just ClientSide - , searchPlaceholder = Just $ "Search " <> display resource + table.features + { searchPlaceholder = Just $ "Search " <> display resource , rowAttrs = Just $ drawerRowAttrs_ . kubeDetailUrl - , header = Just $ timeRefreshListener_ "kubernetesContainer" url >> navTabStrip_ ViewTabs "Kubernetes resource" "tabs-sm mx-3 w-fit" [(toHtml $ resourceLabel r <> "s", infraUrl pid "/infrastructure/kubernetes" [("resource", display r)] window, r == resource) | r <- [minBound ..]] - , showFilterRail = True + , tabs = Just $ TableTabs "Kubernetes resource" [(resourceLabel r <> "s", infraUrl pid "/infrastructure/kubernetes" ([("resource", display r)] <> mapMaybe sequenceA [("cluster", clusterM), ("namespace", namespaceM), ("status", statusM)]) window, r == resource) | r <- [minBound ..]] , resultSummary = Just $ "Showing " <> show (V.length rows) <> " resources" , exportName = Just $ "kubernetes-" <> display resource - , tableHeaderActions = + , facets = Just $ facetActions (infraUrl pid "/infrastructure/kubernetes" [("resource", display resource)] window) "kubernetesContainer" - [ singleSelectFilter "Cluster" "cluster" clusterM $ facetValues (.cluster) allRows - , singleSelectFilter "Namespace" "namespace" namespaceM $ facetValues (.namespace) allRows - , singleSelectFilter "Status" "status" statusM $ facetValues (Just . kubeStatusLabel . (.status)) allRows + [ countedFilter "Cluster" "cluster" clusterM (maybeToList . (.cluster)) allRows + , countedFilter "Namespace" "namespace" namespaceM (maybeToList . (.namespace)) allRows + , countedFilter "Status" "status" statusM (one . kubeStatusLabel . (.status)) allRows ] , zeroState = Just $ ZeroState "cube" "No Kubernetes resources reporting" "Enable kubeletstats and k8s_cluster receivers to populate pods, workloads, and nodes." (ESLink "https://monoscope.tech/docs/sdks/infrastructure/kubernetes" "Kubernetes setup guide") } } where + table = inventoryTable "kubernetes" url kubeDetailUrl row = infraUrl pid "/infrastructure/kubernetes/detail" ([("resource", display resource), ("name", row.name)] <> mapMaybe sequenceA [("cluster", row.cluster), ("namespace", row.namespace)]) window @@ -702,7 +666,7 @@ hostMapGetH pid fillM groupM providerM regionM osM fromParam toParam sinceParam url = deferUrl pid "/infrastructure/host-map" [("fill", fillM), ("group", groupM), ("provider", providerM), ("region", regionM), ("os", osM)] window body <- withDeferredBody deferredM "hostMapContainer" url hostMapSkeleton_ do allHosts <- hostsFromRows <$> infraSnapshot pid window - let groups = groupHosts grouping $ applyHostFilters filters allHosts + let groups = groupRows (Just . hostGroupLabel grouping) $ applyHostFilters filters allHosts pure HostMapData{pid, window, fill = parseParam FillCPU display fillM, grouping, filters, allHosts, groups} addRespHeaders $ HostMapPage $ PageCtx (infrastructureBW pid "Host Map" window bw) body where @@ -714,36 +678,40 @@ hostMapGetH pid fillM groupM providerM regionM osM fromParam toParam sinceParam hostMap_ :: HostMapData -> Html () hostMap_ page = div_ [id_ "hostMapContainer", class_ "flex min-h-full flex-col bg-bgBase"] do timeRefreshListener_ "hostMapContainer" (deferUrl page.pid "/infrastructure/host-map" [("fill", Just $ display page.fill), ("group", Just $ hostGroupParam page.grouping), ("provider", page.filters.provider), ("region", page.filters.region), ("os", page.filters.osType)] page.window) - form_ [method_ "get", action_ $ "/p/" <> page.pid.toText <> "/infrastructure/host-map", class_ "flex flex-wrap items-end gap-3 border-b border-strokeWeak bg-bgRaised px-4 py-3 max-sm:grid max-sm:grid-cols-2"] do - TimePicker.timeHiddenInputs_ page.window.fromQuery page.window.toQuery page.window.sinceQuery - mapSelect "fill" "Fill by" (display page.fill) [(display f, case f of FillCPU -> "CPU usage"; FillMemory -> "Memory usage"; FillStorage -> "Storage usage") | f <- [minBound ..]] - mapSelect "group" "Group by" (hostGroupParam page.grouping) (map hostGroupOption [minBound ..]) - mapSelect "provider" "Provider" (fromMaybe "" page.filters.provider) $ ("", "All") : map dup (facetValues (.provider) page.allHosts) - mapSelect "region" "Region" (fromMaybe "" page.filters.region) $ ("", "All") : map dup (facetValues (.region) page.allHosts) - mapSelect "os" "Operating system" (fromMaybe "" page.filters.osType) $ ("", "All") : map dup (facetValues (.osType) page.allHosts) + div_ [class_ "flex flex-wrap items-center gap-3 border-b border-strokeWeak bg-bgRaised px-4 py-3 max-sm:grid max-sm:grid-cols-2"] do + selectControl_ $ SelectControl "fill" "Fill by" (display page.fill) [(display f, case f of { FillCPU -> "CPU usage"; FillMemory -> "Memory usage"; FillStorage -> "Storage usage" }) | f <- [minBound ..]] mapUrl "hostMapContainer" + selectControl_ $ SelectControl "group" "Group by" (hostGroupParam page.grouping) (map hostGroupOption [minBound ..]) mapUrl "hostMapContainer" div_ [class_ "ml-auto flex flex-wrap items-center gap-3 text-xs text-textWeak max-sm:col-span-2 max-sm:ml-0", Aria.label_ "Utilization legend"] do legend "bg-fillSuccess-strong" "Below 60%" legend "bg-fillWarning-strong" "60–85%" legend "bg-fillError-strong" "Above 85%" legend "bg-fillNeutral-strong" "No data" - if null page.groups - then - div_ [class_ "m-auto"] - $ emptyState_ - def{icon = Just "server", action = ESLink "https://monoscope.tech/docs/sdks/infrastructure/" "Set up host monitoring"} - "No hosts reporting" - "Enable host metrics or Kubernetes node telemetry to populate the map." - else div_ [class_ "flex flex-wrap items-start gap-4 p-4"] $ forM_ page.groups \(label, hosts) -> - let enlarged = length hosts <= 12 - in section_ [class_ $ "rounded-lg border border-strokeWeak bg-bgRaised p-3 shadow-sm " <> bool "min-w-80 flex-1" "w-fit min-w-72" enlarged] do - h2_ [class_ "mb-3 flex items-center gap-2 text-sm font-semibold text-textStrong"] $ toHtml label >> span_ [class_ "badge badge-xs badge-ghost"] (toHtml $ show (length hosts) <> " hosts") - div_ [class_ $ "flex flex-wrap " <> bool "gap-1" "gap-2" enlarged] $ forM_ (sortOn (.name) hosts) $ hostHex enlarged + div_ [class_ "flex items-start gap-4 p-4 max-lg:flex-col"] do + Facets.sidebar_ + . facetsConfig + $ facetActions + mapUrl + "hostMapContainer" + [ countedFilter "Provider" "provider" page.filters.provider (maybeToList . (.provider)) page.allHosts + , countedFilter "Region" "region" page.filters.region (maybeToList . (.region)) page.allHosts + , countedFilter "OS" "os" page.filters.osType (maybeToList . (.osType)) page.allHosts + ] + div_ [class_ "min-w-0 w-full flex-1"] do + if null page.groups + then + div_ [class_ "m-auto"] + $ emptyState_ + def{icon = Just "server", action = ESLink "https://monoscope.tech/docs/sdks/infrastructure/" "Set up host monitoring"} + "No hosts reporting" + "Enable host metrics or Kubernetes node telemetry to populate the map." + else div_ [class_ "flex flex-wrap items-start gap-4"] $ forM_ page.groups \(label, hosts) -> + let enlarged = length hosts <= 12 + in section_ [class_ $ "rounded-lg border border-strokeWeak bg-bgRaised p-3 shadow-sm " <> bool "min-w-80 flex-1" "w-fit min-w-72" enlarged] do + h2_ [class_ "mb-3 flex items-center gap-2 text-sm font-semibold text-textStrong"] $ toHtml label >> span_ [class_ "badge badge-xs badge-ghost"] (toHtml $ show (length hosts) <> " hosts") + div_ [class_ $ "flex flex-wrap " <> bool "gap-1" "gap-2" enlarged] $ forM_ (sortOn (.name) hosts) $ hostHex enlarged where + mapUrl = infraUrl page.pid "/infrastructure/host-map" ([("fill", display page.fill), ("group", hostGroupParam page.grouping)] <> mapMaybe sequenceA [("provider", page.filters.provider), ("region", page.filters.region), ("os", page.filters.osType)]) page.window legend colour label = span_ [class_ "inline-flex items-center gap-1.5"] $ span_ [class_ $ "h-3 w-3 " <> colour, style_ hexClipPath] mempty >> toHtml label - mapSelect :: Text -> Text -> Text -> [(Text, Text)] -> Html () - mapSelect field label current options = label_ [class_ "flex flex-col gap-1 text-xs text-textWeak"] do - toHtml label - select_ [name_ field, class_ "select select-sm min-w-44 border-strokeWeak bg-bgBase text-sm text-textStrong max-sm:h-11 max-sm:min-w-0 max-sm:w-full", onchange_ "this.form.requestSubmit()"] $ forM_ options \(value, title) -> option_ ([value_ value] <> [selected_ "" | value == current]) $ toHtml title hostHex enlarged host = div_ [class_ $ "flex flex-col items-center gap-1 " <> bool "" "w-24" enlarged] do let value = case page.fill of FillCPU -> host.cpuPct; FillMemory -> host.memoryPct; FillStorage -> host.storagePct button_ diff --git a/src/Pages/LogExplorer/Log.hs b/src/Pages/LogExplorer/Log.hs index 654716a09..53be42433 100644 --- a/src/Pages/LogExplorer/Log.hs +++ b/src/Pages/LogExplorer/Log.hs @@ -39,7 +39,6 @@ import Data.Aeson.Types qualified as AET import Data.Default (Default, def) import Data.Effectful.Hasql (Hasql) import Data.Effectful.Hasql qualified as Hasql -import Data.Foldable.WithIndex (iforM_) import Data.HashMap.Strict qualified as HM import Data.List qualified as L import Data.Map.Strict qualified as Map @@ -75,10 +74,12 @@ import Models.Telemetry.Schema qualified as Schema import Models.Telemetry.Telemetry qualified as Telemetry import NeatInterpolation (text) import Pages.BodyWrapper (BWConfig (..), PageCtx (..), checkFreeTierStatus, mkPageCtx, pageActions, pageTitle) -import Pages.Components (FieldCfg (..), FieldSize (..), facetOption_, facetRail_, facetSection_, formField_, keyboardActivateAttr_, lazyPanel_, localTimeFmt_, resetFormOnSuccessAttr_, resizer_) +import Pages.Components (FieldCfg (..), FieldSize (..), formField_, keyboardActivateAttr_, lazyPanel_, localTimeFmt_, resetFormOnSuccessAttr_, resizer_) import Pages.LogExplorer.LogItem qualified as LogItem import Pages.Monitors qualified as AlertUI import Pkg.AI qualified as AI +import Pkg.Components.Facets (FacetChoice (..), FacetContent (..), FacetNode (..), FacetOverflow (..), facetHeadCount, facetNode_, facetTail_) +import Pkg.Components.Facets qualified as Facets import Pkg.Components.LogQueryBox (LogQueryBoxConfig (..), VizSurface (..), VizType (..), enrichSchemaWithFacets, logQueryBox_, visTypes) import Pkg.Components.TimePicker qualified as Components import Pkg.Components.Widget (WidgetAxis (..), WidgetType (WTTimeseries, WTTimeseriesLine)) @@ -99,7 +100,7 @@ import System.Types import Text.Casing (fromAny, toKebab) import Text.Megaparsec (parseMaybe) import Text.Slugify (slugify) -import Utils (FieldAction (..), FieldMenuCtx (..), LoadingSize (..), LoadingType (..), encodeText, explorerNavTabs_, faSprite_, fieldContextMenuItems_, fieldMenuPanel_, getDurationNSMS, getServiceColors, levelFillColor, listToIndexHashMap, loadingIndicator_, lookupVecBy, lookupVecNonEmptyText, lookupVecTextByKey, methodFillColor, nonEmptyT, popoverTrigger_, prettyPrintCount, sanitizeBackendError, serviceFillColor, showFFloat', statusFillColorText, toUriStr) +import Utils (FieldAction (..), FieldMenuCtx (..), encodeText, explorerNavTabs_, faSprite_, fieldContextMenuItems_, fieldMenuPanel_, getDurationNSMS, getServiceColors, levelFillColor, listToIndexHashMap, lookupVecBy, lookupVecNonEmptyText, lookupVecTextByKey, methodFillColor, nonEmptyT, popoverTrigger_, prettyPrintCount, sanitizeBackendError, serviceFillColor, showFFloat', statusFillColorText, toUriStr) import Web.FormUrlEncoded (FromForm) import Web.HttpApiData (parseUrlPiece) @@ -513,105 +514,72 @@ facetGroupPaths g = map (.path) $ Map.findWithDefault [] g facetsByGroup -- | Render facet data for Log Explorer sidebar in a compact format. -- The facet counts are scaled in the upstream summary based on the selected time range. renderFacets :: FacetSummary -> Html () -renderFacets facetSummary = - forM_ (universe :: [FacetGroup]) \facetGroup -> renderFacetGroup (facetGroup == FGCommon) facetGroup facetSummary - - --- | A group shell is cheap enough for the initial sidebar. Only Common Filters --- includes its fields immediately; every closed group fetches and replaces --- its shell with the server-rendered Lucid fragment on first open. -renderFacetGroup :: Bool -> FacetGroup -> FacetSummary -> Html () -renderFacetGroup loaded facetGroup facetSummary = - facetSection_ loaded "facet-section-group" attrs (span_ [class_ "font-medium text-sm"] $ toHtml $ facetGroupLabel facetGroup) body - where - attrs = - if loaded - then [] - else - [ hxGet_ url - , hxTrigger_ "toggle[target.open] once" - , hxTarget_ "this" - , hxSwap_ "outerHTML" - , hxIndicator_ "find .facet-group-loader" - ] - body - | loaded = div_ [class_ "facets-container"] $ renderFacetFields facetGroup facetSummary - | otherwise = div_ [class_ "facet-group-loader htmx-indicator flex h-8 items-center justify-center"] $ loadingIndicator_ LdXS LdSpinner - url = "/p/" <> facetSummary.projectId <> "/log_explorer/facets?group=" <> facetGroupParam facetGroup - - --- | Facet values shown before the "+ More" fold. 'renderFacetFields' takes this --- many and 'renderFacetTail' drops exactly the same many — two different numbers --- here would make the tail repeat or skip values. -facetHeadCount :: Int -facetHeadCount = 5 - - -renderFacetFields :: FacetGroup -> FacetSummary -> Html () -renderFacetFields facetGroup facetSummary = do - let (FacetData facetMap) = facetSummary.facetJson - facets = Map.findWithDefault [] facetGroup facetsByGroup - -- Checkbox↔query sync lives in web-components/src/main.ts (syncFacetCheckboxes), - -- wired to `update-query` + `htmx:after:swap` so swapped-in facets re-sync. - iforM_ facets \idx facet -> do - let values = HM.lookupDefault [] facet.path facetMap - open = facetGroup == FGCommon && idx < 5 && not (null values) - (visibleValues, hiddenValues) = splitAt facetHeadCount values - hiddenCount = length hiddenValues - facetSection_ - open - "" - [] - ( div_ [class_ "flex items-center justify-between gap-2"] do - span_ [class_ "truncate text-sm font-normal", term "data-tippy-content" facet.path] $ toHtml facet.label - -- Bubble-halt so the ⋮ popover (and its menu items) don't toggle the
. - div_ [class_ "inline-block", [__|on click halt the event's bubbling|]] do - button_ ([type_ "button", class_ "cursor-pointer rounded p-1 hover:bg-fillWeak", Aria.label_ "Facet options"] <> popoverTrigger_ (slugify facet.path)) - $ faSprite_ "ellipsis-vertical" "regular" "w-3 h-3" - ul_ ([class_ "dropdown menu p-2 shadow-sm bg-bgRaised rounded-box w-96 border border-strokeWeak z-50", term "data-field-path" facet.path] <> fieldMenuPanel_ (slugify facet.path)) - $ fieldContextMenuItems_ (StaticField facet.path Nothing) [FCopyField, FDivider, FGroupBy, FViewPatterns, FDivider, FAddColumn] - ) - $ div_ [class_ "facet-values pl-5 pr-1 mb-1 space-y-1"] do - if null values - then div_ [class_ "facet-empty px-1 py-1 text-xs italic text-textWeak"] "no values in window" - else forM_ visibleValues (renderFacetValue facet) - when (hiddenCount > 0) - $ div_ [class_ "facet-tail"] - $ button_ - [ type_ "button" - , class_ "facet-more text-textBrand text-xs px-1 py-0.5 cursor-pointer hover:underline" - , hxGet_ $ "/p/" <> facetSummary.projectId <> "/log_explorer/facets?field=" <> toUriStr facet.path - , hxTarget_ "closest .facet-tail" +renderFacets = mapM_ facetNode_ . facetGroups + + +facetGroups :: FacetSummary -> [FacetNode] +facetGroups summary = [facetGroupNode (g == FGCommon) g summary | g <- universe] + + +facetGroupNode :: Bool -> FacetGroup -> FacetSummary -> FacetNode +facetGroupNode loaded facetGroup summary = + FacetNode + { label = facetGroupLabel facetGroup + , path = Nothing + , expanded = loaded + , attrs = + if loaded + then [] + else + [ hxGet_ $ "/p/" <> summary.projectId <> "/log_explorer/facets?group=" <> facetGroupParam facetGroup + , hxTrigger_ "toggle[target.open] once" + , hxTarget_ "this" , hxSwap_ "outerHTML" + , hxIndicator_ "find .facet-group-loader" ] - $ toHtml - $ "+ More (" - <> prettyPrintCount hiddenCount - <> ")" - - -renderFacetValue :: Facet -> FacetValue -> Html () -renderFacetValue f (FacetValue val count) = - facetOption_ - "facet-item max-md:min-h-9" - [] - ( do - input_ - [ type_ "checkbox" - , class_ "checkbox checkbox-xs max-md:checkbox-sm" - , -- Via queryEditorCall, not the element directly: Monaco is loaded lazily, so on a fresh - -- page load is still un-upgraded and `.toggleSubQuery` doesn't exist yet. - term "hx-on:click" "window.queryEditorCall('toggleSubQuery', this.dataset.field, this.dataset.value)" - , Aria.label_ (f.path <> " equals " <> val) - , term "data-tippy-content" (f.path <> " == \"" <> val <> "\"") - , term "data-field" f.path - , term "data-value" val - ] - let colorClass = f.color val - unless (T.null colorClass) $ span_ [class_ $ colorClass <> " shrink-0 w-0.5 h-3 rounded-sm"] "" - span_ [class_ "facet-value truncate text-xs", term "data-tippy-content" val] $ toHtml val - ) - (span_ [class_ "facet-count shrink-0 text-xs tabular-nums text-textWeak"] $ toHtml $ prettyPrintCount count) + , actions = Nothing + , content = if loaded then FacetChildren fields else FacetLoading + } + where + (FacetData valuesByPath) = summary.facetJson + fields = + [ FacetNode + { label = facet.label + , path = Just facet.path + , expanded = facetGroup == FGCommon && index < 5 && not (null values) + , attrs = [] + , actions = Just do + button_ ([type_ "button", class_ "cursor-pointer rounded p-1 hover:bg-fillWeak", Aria.label_ "Facet options"] <> popoverTrigger_ (slugify facet.path)) $ faSprite_ "ellipsis-vertical" "regular" "w-3 h-3" + ul_ ([class_ "dropdown menu p-2 shadow-sm bg-bgRaised rounded-box w-96 border border-strokeWeak z-50", term "data-field-path" facet.path] <> fieldMenuPanel_ (slugify facet.path)) + $ fieldContextMenuItems_ (StaticField facet.path Nothing) [FCopyField, FDivider, FGroupBy, FViewPatterns, FDivider, FAddColumn] + , content = + FacetValues (map (facetChoice facet) values) + $ FetchValues + [ hxGet_ $ "/p/" <> summary.projectId <> "/log_explorer/facets?field=" <> toUriStr facet.path + , hxTarget_ "closest .facet-tail" + , hxSwap_ "outerHTML" + ] + } + | (index, facet) <- zip [0 :: Int ..] $ Map.findWithDefault [] facetGroup facetsByGroup + , let values = HM.lookupDefault [] facet.path valuesByPath + ] + + +facetChoice :: Facet -> FacetValue -> FacetChoice +facetChoice f (FacetValue val count) = + FacetChoice + { label = val + , multiSelect = True + , inputAttrs = + [ term "hx-on:click" "window.queryEditorCall('toggleSubQuery', this.dataset.field, this.dataset.value)" + , Aria.label_ (f.path <> " equals " <> val) + , term "data-tippy-content" (f.path <> " == \"" <> val <> "\"") + , term "data-field" f.path + , term "data-value" val + ] + , marker = if T.null (f.color val) then Nothing else Just $ span_ [class_ $ f.color val <> " shrink-0 w-0.5 h-3 rounded-sm"] "" + , count = Just $ prettyPrintCount count + } -- | Descendants of a page of rows, for the trace tree: seed from each row's own @@ -1022,7 +990,7 @@ logExplorerFacetsH pid fieldM groupM = do (div_ [class_ "px-1 py-4 text-xs italic text-textWeak"] "Filters are still being built for this project.") ( \summary -> case (fieldM, facetGroupM) of (Just field, _) -> renderFacetTail field summary - (_, Just facetGroup) -> renderFacetGroup True facetGroup summary + (_, Just facetGroup) -> facetNode_ $ facetGroupNode True facetGroup summary _ -> renderFacets summary ) facetSummary @@ -1032,12 +1000,7 @@ logExplorerFacetsH pid fieldM groupM = do whenJust (find ((== field) . (.path)) facetDefs) \facet -> do let (FacetData facetMap) = facetSummary.facetJson values = drop facetHeadCount $ HM.lookupDefault [] field facetMap - count = prettyPrintCount $ length values - details_ [class_ "facet-tail group", open_ ""] do - summary_ [class_ "list-none cursor-pointer text-textBrand text-xs px-1 py-0.5 hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-strokeBrand-strong"] do - span_ [class_ "group-open:hidden"] $ toHtml $ "+ More (" <> count <> ")" - span_ [class_ "hidden group-open:inline"] $ toHtml $ "− Less (" <> count <> ")" - div_ [class_ "space-y-1"] $ forM_ values (renderFacetValue facet) + facetTail_ True $ map (facetChoice facet) values -- | Structured verdict for the query editor. It underlines @column@..@width@ and @@ -1689,16 +1652,14 @@ apiLogsPage page = do -- No `contain:layout` here: it makes this a containing block for fixed/anchored -- descendants, which clips the facet action popover (top layer) to the sidebar. facetsPanel = - facetRail_ - (Just "facets-container") - "w-68 will-change-[width] text-sm text-textWeak shrink-0 h-full overflow-y-scroll max-md:w-full max-md:shrink max-md:max-h-[55vh] max-md:border-b max-md:border-strokeWeak group-has-[.toggle-filters:checked]/pg:max-w-0 group-has-[.toggle-filters:checked]/pg:overflow-hidden max-md:group-has-[.toggle-filters:checked]/pg:max-h-0" - "Search filters" - Nothing - $ div_ [id_ "facets-list"] - $ maybe - (div_ [class_ "px-1 py-4 text-xs italic text-textWeak"] "Filters are still being built for this project.") - renderFacets - page.facetSummary + Facets.sidebar_ + Facets.Config + { elemId = "facets-container" + , extraClass = "will-change-[width] h-full overflow-y-scroll max-md:shrink max-md:max-h-[55vh] max-md:border-b max-md:border-strokeWeak group-has-[.toggle-filters:checked]/pg:max-w-0 group-has-[.toggle-filters:checked]/pg:overflow-hidden max-md:group-has-[.toggle-filters:checked]/pg:max-h-0" + , clear = Just [[__|on click repeat for cb in in closest .facet-rail call cb.click() end|]] + , content = facetGroups <$> page.facetSummary + , layout = Facets.Plain + } logsListPanel = div_ [class_ "@container grow will-change-[width] contain-[layout_style] relative flex flex-col shrink-1 min-w-0 w-full h-full ", id_ "logs_list_container"] do rowCountHeader diff --git a/src/Pkg/Components/Facets.hs b/src/Pkg/Components/Facets.hs new file mode 100644 index 000000000..bb4628dce --- /dev/null +++ b/src/Pkg/Components/Facets.hs @@ -0,0 +1,112 @@ +module Pkg.Components.Facets (Config (..), Layout (..), FacetNode (..), FacetContent (..), FacetOverflow (..), FacetChoice (..), sidebar_, facetNode_, facetChoice_, facetTail_, facetHeadCount) where + +import Lucid +import Lucid.Hyperscript (__) +import Pages.Components (filterInputAttr_, searchInput_) +import Relude +import Utils (LoadingSize (..), LoadingType (..), faSprite_, loadingIndicator_, prettyPrintCount) + + +data Layout = Plain | Collapsible + + +data Config = Config + { elemId :: Text + , extraClass :: Text + , clear :: Maybe [Attribute] + , content :: Maybe [FacetNode] + , layout :: Layout + } + + +-- | Explorer's facet sidebar, also embedded by inventory tables and the host map. +sidebar_ :: Config -> Html () +sidebar_ cfg = case cfg.layout of + Plain -> rail "" + Collapsible -> details_ [class_ "w-68 shrink-0 max-lg:w-full lg:[&::details-content]:block lg:[&::details-content]:[content-visibility:visible]"] do + summary_ [class_ "flex cursor-pointer list-none items-center gap-2 rounded px-2 py-2 text-xs font-semibold text-textStrong hover:bg-fillWeak lg:hidden [&::-webkit-details-marker]:hidden"] "Filters" + rail " max-lg:w-full" + where + rail extra = div_ [class_ $ "facet-rail group/rail flex w-68 shrink-0 flex-col gap-2 text-sm text-textWeak max-md:w-full " <> cfg.extraClass <> extra, data_ "component" "facet-rail", id_ cfg.elemId] do + searchInput_ + "sticky top-0 z-10 h-9 w-[calc(100%-1rem)] mx-2 border-strokeStrong bg-bgBase" + "Search filters" + [name_ "facet-search", filterInputAttr_ $ "#" <> cfg.elemId <> " :is(.facet-item,.facet-section)"] + forM_ cfg.clear $ \attrs -> button_ ([type_ "button", class_ "hidden items-center justify-between rounded px-2 py-1.5 text-xs text-textBrand hover:bg-fillWeak group-has-[.facet-item_input:checked]/rail:flex"] <> attrs) $ "Clear all" >> faSprite_ "xmark" "regular" "h-3 w-3" + div_ [class_ "facets-container"] $ maybe (div_ [class_ "px-1 py-4 text-xs italic text-textWeak"] "Filters are still being built for this project.") (mapM_ facetNode_) cfg.content + + +data FacetNode = FacetNode + { label :: Text + , path :: Maybe Text + , expanded :: Bool + , attrs :: [Attribute] + , actions :: Maybe (Html ()) + , content :: FacetContent + } + + +data FacetContent + = FacetValues [FacetChoice] FacetOverflow + | FacetChildren [FacetNode] + | FacetLoading + + +facetNode_ :: FacetNode -> Html () +facetNode_ node = do + let grouped = case node.content of FacetValues{} -> False; FacetChildren{} -> True; FacetLoading -> True + details_ ([class_ $ "facet-section block border-t border-strokeWeak [&[open]>summary_.facet-chevron]:rotate-0 " <> bool "" "facet-section-group" grouped, data_ "component" "facet-section"] <> node.attrs <> [open_ "" | node.expanded]) do + summary_ [class_ "flex cursor-pointer list-none items-center gap-2 rounded px-2 py-2 text-textStrong hover:bg-fillWeak [&::-webkit-details-marker]:hidden"] do + faSprite_ "chevron-down" "regular" "facet-chevron h-2.5 w-2.5 shrink-0 -rotate-90 transition-transform" + div_ [class_ "flex min-w-0 flex-1 items-center justify-between gap-2"] do + span_ ([class_ $ "truncate text-sm " <> bool "font-normal" "font-medium" grouped] <> [term "data-tippy-content" p | p <- maybeToList node.path]) $ toHtml node.label + forM_ node.actions $ \actions -> div_ [class_ "inline-block", [__|on click halt the event's bubbling|]] actions + case node.content of + FacetChildren children -> div_ [class_ "facets-container"] $ mapM_ facetNode_ children + FacetLoading -> div_ [class_ "facet-group-loader htmx-indicator flex h-8 items-center justify-center"] $ loadingIndicator_ LdXS LdSpinner + FacetValues choices overflow -> div_ [class_ "facet-values pl-5 pr-1 mb-1 space-y-1"] do + let (visible, hidden) = splitAt facetHeadCount choices + moreLabel = "+ More (" <> prettyPrintCount (length hidden) <> ")" + if null choices + then div_ [class_ "facet-empty px-1 py-1 text-xs italic text-textWeak"] "no values in window" + else forM_ visible facetChoice_ + unless (null hidden) $ case overflow of + ExpandValues -> facetTail_ False hidden + FetchValues attrs -> div_ [class_ "facet-tail"] $ button_ ([type_ "button", class_ "facet-more text-textBrand text-xs px-1 py-0.5 cursor-pointer hover:underline"] <> attrs) $ toHtml moreLabel + + +facetTail_ :: Bool -> [FacetChoice] -> Html () +facetTail_ expanded choices = details_ ([class_ "facet-tail group/tail"] <> [open_ "" | expanded]) do + summary_ [class_ "list-none cursor-pointer text-textBrand text-xs px-1 py-0.5 hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-strokeBrand-strong"] do + span_ [class_ "group-open/tail:hidden"] $ toHtml $ "+ More (" <> count <> ")" + span_ [class_ "hidden group-open/tail:inline"] $ toHtml $ "− Less (" <> count <> ")" + div_ [class_ "space-y-1"] $ mapM_ facetChoice_ choices + where + count = prettyPrintCount $ length choices + + +-- | Local inventories expand in place; Explorer fetches the tail lazily. +data FacetOverflow = ExpandValues | FetchValues [Attribute] + + +-- | The initial field and its lazy tail must use the same cutoff. +facetHeadCount :: Int +facetHeadCount = 5 + + +data FacetChoice = FacetChoice + { label :: Text + , multiSelect :: Bool + , inputAttrs :: [Attribute] + , marker :: Maybe (Html ()) + , count :: Maybe Text + } + + +facetChoice_ :: FacetChoice -> Html () +facetChoice_ choice = label_ [class_ "facet-item flex min-h-7 cursor-pointer items-center justify-between gap-2 rounded px-2 py-1 hover:bg-fillWeak max-md:min-h-9", data_ "component" "facet-option"] do + div_ [class_ "flex min-w-0 flex-1 items-center gap-2"] do + input_ $ [type_ $ bool "radio" "checkbox" choice.multiSelect, class_ $ bool "radio radio-xs max-md:radio-sm" "checkbox checkbox-xs max-md:checkbox-sm" choice.multiSelect] <> choice.inputAttrs + sequence_ choice.marker + span_ [class_ "facet-value truncate text-xs", term "data-tippy-content" choice.label] $ toHtml choice.label + forM_ choice.count $ \n -> span_ [class_ "facet-count shrink-0 text-xs tabular-nums text-textWeak"] $ toHtml n diff --git a/src/Pkg/Components/Table.hs b/src/Pkg/Components/Table.hs index b76bc4f1d..55f1ff97c 100644 --- a/src/Pkg/Components/Table.hs +++ b/src/Pkg/Components/Table.hs @@ -1,11 +1,16 @@ module Pkg.Components.Table ( Table (..), + inventoryTable, TableRows (..), TableResponse (..), Column (..), Config (..), Features (..), - defaultFeaturesAreInert, + SelectControl (..), + TableTabs (..), + selectControl_, + facetsConfig, + countedFilter, SearchMode (..), BulkAction (..), TabFilter (..), @@ -21,8 +26,6 @@ module Pkg.Components.Table ( TableHeaderActions (..), FilterMenu (..), FilterOption (..), - facetValues, - singleSelectFilter, multiSelectFilter, facetActions, -- Sorting types and utilities @@ -37,8 +40,10 @@ module Pkg.Components.Table ( ) where import Data.Default (Default (..)) +import Data.Foldable.WithIndex (iforM_) import Data.List (lookup) import Data.List.NonEmpty qualified as NE +import Data.Map.Strict qualified as Map import Data.Text qualified as T import Data.Text.Lazy qualified as TL import Data.Vector qualified as V @@ -48,9 +53,11 @@ import Lucid.Aria qualified as Aria import Lucid.Htmx import Lucid.Hyperscript (__) import Pages.BodyWrapper (PageCtx) -import Pages.Components (EmptyStateAction (..), EmptyStateCfg (..), detailsClosedBelowAttr_, emptyState_, facetOption_, facetRail_, facetSection_, filterInputAttr_, keyboardActivateAttr_, searchInput_) +import Pages.Components (EmptyStateAction (..), EmptyStateCfg (..), emptyState_, filterInputAttr_, keyboardActivateAttr_, searchInput_, timeRefreshListener_) +import Pkg.Components.Facets (FacetChoice (..), FacetContent (..), FacetNode (..), FacetOverflow (..), facetChoice_) +import Pkg.Components.Facets qualified as Facets import Relude -import Utils (TabStrip (..), deleteParam, faSprite_, navTabAttrs, navTabStrip_, popoverPanel_, popoverTrigger_, toUriStr) +import Utils (TabStrip (..), deleteParam, faSprite_, navTabAttrs, navTabStrip_, popoverPanel_, popoverTrigger_, prettyPrintCount, toUriStr) -- Core Types @@ -77,6 +84,18 @@ data Table a = Table } +inventoryTable :: Text -> Text -> Table a +inventoryTable name url = + Table + { config = def{elemID = name <> "Form", containerId = Just target, addPadding = True, renderAsTable = True} + , columns = [] + , rows = V.empty + , features = def{header = Just $ timeRefreshListener_ target url, search = Just ClientSide, searchPlaceholder = Just $ "Search " <> name, customizeColumns = True, exportName = Just name} + } + where + target = name <> "Container" + + -- TableRows for pagination - renders rows + pagination footer type role TableRows nominal data TableRows a = TableRows @@ -104,6 +123,11 @@ data Pagination = Pagination data SearchMode = ClientSide | ServerSide Text +-- | Features default to no optional behavior. +-- +-- >>> let html = renderText $ toHtml (Table def [] V.empty def :: Table ()) +-- >>> any @[] (`TL.isInfixOf` html) ["facet-rail", " Text) , rowId :: Maybe (a -> Text) @@ -121,42 +145,23 @@ data Features a = Features , zeroState :: Maybe ZeroState , header :: Maybe (Html ()) , treeConfig :: Maybe (TreeConfig a) - , showFilterRail :: Bool + , facets :: Maybe TableHeaderActions , resultSummary :: Maybe Text , exportName :: Maybe Text + , customizeColumns :: Bool + , grouping :: Maybe SelectControl + , tabs :: Maybe TableTabs } - -- Every field's default is the generic one (Nothing / [] / False). The - -- hand-written instance was 21 lines that had to be edited in lockstep with the - -- record; derived, a newly added field cannot be forgotten. The doctest on - -- 'defaultFeaturesAreInert' pins that the derived defaults match what the - -- hand-written instance produced — every table's behaviour depends on it. deriving stock (Generic) deriving anyclass (Default) -- $setup --- >>> :set -XTypeApplications +-- >>> :set -XTypeApplications -XOverloadedRecordDot -XOverloadedStrings -- >>> import Data.Default (def) - - --- | The derived 'Default' for 'Features' must stay inert: no row link, no bulk --- actions, no filter rail. This is what the hand-written instance guaranteed, and --- what every @def{...}@ table construction in the codebase assumes. --- --- >>> defaultFeaturesAreInert --- True -defaultFeaturesAreInert :: Bool -defaultFeaturesAreInert = - and - [ isNothing d.rowLink - , isNothing d.search - , isNothing d.pagination - , isNothing d.rowGroup - , null d.bulkActions - , not d.showFilterRail - ] - where - d = def @(Features ()) +-- >>> import Data.Vector qualified as V +-- >>> import Data.Text.Lazy qualified as TL +-- >>> import Lucid (renderText, toHtml) data Config = Config @@ -183,6 +188,22 @@ data Config = Config } +data SelectControl = SelectControl + { name :: Text + , label :: Text + , current :: Text + , options :: [(Text, Text)] -- (value, label) + , baseUrl :: Text + , targetId :: Text + } + + +data TableTabs = TableTabs + { label :: Text + , options :: [(Text, Text, Bool)] -- (label, url, active) + } + + -- Supporting Types -- | A toolbar action over the checked rows. With @choices@ it opens a menu and each @@ -280,38 +301,30 @@ data FilterOption = FilterOption { label :: Text , value :: Text , isActive :: Bool + , count :: Maybe Int } deriving stock (Eq, Show) --- | Sorted distinct values from the unfiltered result, so choosing one facet does --- not empty the menus of the others. --- --- >>> facetValues snd (V.fromList [("a", Just "b"), ("c", Just "a"), ("d", Nothing)]) --- ["a","b"] -facetValues :: (a -> Maybe Text) -> V.Vector a -> [Text] -facetValues getter = sortNub . filter (not . T.null) . mapMaybe getter . V.toList - - --- | A facet menu over @values@, marking those in @selected@ active. Single-select is the --- same menu with at most one selection, so both go through here rather than each call site --- assembling 'FilterMenu'/'FilterOption' by hand. -filterMenu :: Bool -> Text -> Text -> [Text] -> [Text] -> FilterMenu -filterMenu multiSelect label paramName selected values = +multiSelectFilter :: Text -> Text -> [Text] -> [Text] -> FilterMenu +multiSelectFilter label paramName selected values = FilterMenu { label , paramName - , options = [FilterOption value value (value `elem` selected) | value <- values] - , multiSelect + , options = [FilterOption value value (value `elem` selected) Nothing | value <- values] + , multiSelect = True } -singleSelectFilter :: Text -> Text -> Maybe Text -> [Text] -> FilterMenu -singleSelectFilter label paramName selected = filterMenu False label paramName (maybeToList selected) - - -multiSelectFilter :: Text -> Text -> [Text] -> [Text] -> FilterMenu -multiSelectFilter = filterMenu True +-- | Count each inventory row once per value, before applying any filters. +-- +-- >>> map (\o -> (o.value, o.count)) (countedFilter "Runtime" "runtime" Nothing id (V.fromList [["a", "a"], ["a", "b"], [""]])).options +-- [("a",Just 2),("b",Just 1)] +countedFilter :: Text -> Text -> Maybe Text -> (a -> [Text]) -> V.Vector a -> FilterMenu +countedFilter label paramName selected valuesOf rows = FilterMenu label paramName options False + where + counts = Map.fromListWith (+) [(value, 1) | row <- V.toList rows, value <- ordNub (valuesOf row), not (T.null value)] + options = [FilterOption value value (Just value == selected) (Just count) | (value, count) <- Map.toAscList counts] -- | Header actions for a filter-only inventory table. The active-filter chips are read back @@ -465,7 +478,8 @@ renderTable tbl = let isEmpty = V.null tbl.rows && isJust tbl.features.zeroState tableMain = do sequence_ tbl.features.header - when (isJust tbl.features.resultSummary || isJust tbl.features.exportName) $ renderResultToolbar tbl + forM_ tbl.features.tabs $ \tabs -> navTabStrip_ ViewTabs tabs.label "tabs-sm mx-3 w-fit" [(toHtml label, url, active) | (label, url, active) <- tabs.options] + when (isJust tbl.features.resultSummary || isJust tbl.features.exportName || tbl.features.customizeColumns || isJust tbl.features.grouping || isJust tbl.features.facets) $ renderResultToolbar tbl -- Scrolls horizontally rather than clipping. `overflow-hidden` silently cut off whatever -- did not fit: Containers, Kubernetes and Images each need 1460-1820px and get ~1250 -- with the facet rail open, so 200-570px of columns were unreachable — no scrollbar, no @@ -485,13 +499,13 @@ renderTable tbl = whenJust tbl.features.search \_ -> span_ [id_ "searchIndicator", class_ "htmx-indicator loading loading-sm loading-dots mx-auto"] "" div_ [id_ "rowsContainer", class_ divCls] do renderRows tbl - tableContent = div_ [class_ $ tbl.config.containerClasses <> bool " pb-24" "" isEmpty, id_ $ tbl.config.elemID <> "_page"] do + tableContent = div_ ([class_ $ tbl.config.containerClasses <> bool " pb-24" "" isEmpty, id_ $ tbl.config.elemID <> "_page"] <> [data_ "table-columns" tbl.config.elemID | tbl.features.customizeColumns]) do unless isEmpty $ whenJust tbl.features.search $ renderSearch tbl.config.elemID (fromMaybe "Search" tbl.features.searchPlaceholder) - if tbl.features.showFilterRail - then div_ [class_ "flex items-start gap-4 max-lg:flex-col"] do - whenJust tbl.features.tableHeaderActions renderFilterRail - div_ [class_ "min-w-0 flex-1 space-y-3"] tableMain - else tableMain + case tbl.features.facets of + Just facets -> div_ [class_ "flex items-start gap-4 max-lg:flex-col"] do + Facets.sidebar_ $ facetsConfig facets + div_ [class_ "min-w-0 w-full flex-1 space-y-3"] tableMain + Nothing -> tableMain -- Pagination footer outside the raised surface whenJust tbl.features.pagination renderPaginationFooter when (isJust tbl.features.treeConfig) treeScript @@ -527,7 +541,7 @@ renderRows tbl thAttrs = [class_ $ " " <> tbl.config.thClasses <> " " <> fromMaybe "" c.align <> bool "" " cursor-pointer hover:bg-fillWeak" (isJust sortable)] sortAttrs = foldMap (\(field, cfg) -> swapTarget_ cfg.targetId (toggleSortUrl cfg field)) sortable sortOrder = sortable >>= \(field, cfg) -> lookup cfg.currentSort [("-" <> field, Desc), ("+" <> field, Asc)] - th_ (c.attrs <> thAttrs <> sortAttrs <> [data_ "column-index" $ show idx]) do + th_ (c.attrs <> thAttrs <> sortAttrs <> [data_ "column-key" c.name]) do span_ [class_ $ "flex items-center gap-2 min-w-0" <> bool "" " flex-wrap" (tbl.config.bulkActionsInHeader == Just idx)] do span_ [class_ $ bool "max-md:hidden" "" (idx > 0)] $ toHtml c.name sequence_ c.headerExtra @@ -550,24 +564,32 @@ renderRows tbl -- so htmx-swapped pages stay markup-identical to page 1. renderBody :: [Attribute] -> Table a -> Html () renderBody extra tbl - | tbl.config.renderAsTable = tbody_ (extra <> [class_ "stagger-fade"]) $ maybe (V.mapM_ (renderTableRow tbl) tbl.rows) groupedRows tbl.features.rowGroup - | otherwise = div_ [class_ "stagger-fade"] $ V.mapM_ (renderListRow tbl) tbl.rows + | not tbl.config.renderAsTable = div_ [class_ "stagger-fade"] $ V.mapM_ (renderListRow tbl) tbl.rows + | Just groupOf <- tbl.features.rowGroup = + if isJust tbl.features.grouping then groupedRows groupOf else body $ groupedRows groupOf + | otherwise = body $ V.mapM_ (renderTableRow tbl) tbl.rows where - groupedRows groupOf = - forM_ (sortWith (fst . head) $ NE.groupAllWith (\(idx, row) -> maybeToRight idx $ groupOf row) $ zip [0 :: Int ..] (V.toList tbl.rows)) \((_, row) :| siblings) -> - case (groupOf row, siblings) of - (Just label, _ : _) -> tr_ [class_ "not-has-[.itemsListItem:not(.hidden)]:hidden"] - $ td_ [colspan_ $ show $ length tbl.columns + bool 0 1 (isJust tbl.features.rowId)] - $ details_ [class_ "group/disclosure", [__|on input from closest <[id$='_page']/> if event.target.type is "search" set my.open to true end|]] do - summary_ [class_ "cursor-pointer px-4 py-3 text-sm font-medium text-textStrong focus-visible:outline-offset-[-2px]"] do - toHtml label - span_ [class_ "ms-2 text-xs font-normal text-textWeak tabular-nums"] $ toHtml $ show (1 + length siblings) <> " items on this page" - table_ [class_ tbl.config.tableClasses] do - thead_ [class_ "sr-only"] $ tr_ do - when (isJust tbl.features.rowId) $ th_ [scope_ "col"] "Select issue" - forM_ tbl.columns $ \c -> th_ [scope_ "col"] $ toHtml c.name - tbody_ $ mapM_ (renderTableRow tbl) (row : map snd siblings) - _ -> renderTableRow tbl row + body = tbody_ (extra <> [class_ "stagger-fade"]) + columnCount = length tbl.columns + bool 0 1 (isJust tbl.features.rowId) + groups groupOf = sortWith (fst . head) $ NE.groupAllWith (\(idx, row) -> maybeToRight idx $ groupOf row) $ zip [0 :: Int ..] (V.toList tbl.rows) + groupedRows groupOf = iforM_ (groups groupOf) $ \idx ((_, row) :| siblings) -> + let renderedRows = mapM_ (renderTableRow tbl) (row : map snd siblings) + groupLabel label suffix = toHtml label >> span_ [class_ "ms-2 text-xs font-normal text-textWeak tabular-nums"] (toHtml $ show (1 + length siblings) <> suffix) + in if isJust tbl.features.grouping + then tbody_ ([class_ "stagger-fade not-has-[.itemsListItem:not(.hidden)]:hidden"] <> if idx == 0 then extra else []) do + forM_ (groupOf row) $ \label -> tr_ [data_ "table-group" label] $ td_ [colspan_ $ show columnCount, class_ "px-4 py-3 text-sm font-medium text-textStrong"] $ groupLabel label " items" + renderedRows + else case (groupOf row, siblings) of + (Just label, _ : _) -> tr_ [class_ "not-has-[.itemsListItem:not(.hidden)]:hidden"] + $ td_ [colspan_ $ show columnCount] + $ details_ [class_ "group/disclosure", [__|on input from closest <[id$='_page']/> if event.target.type is "search" set my.open to true end|]] do + summary_ [class_ "cursor-pointer px-4 py-3 text-sm font-medium text-textStrong focus-visible:outline-offset-[-2px]"] $ groupLabel label " items on this page" + table_ [class_ tbl.config.tableClasses] do + thead_ [class_ "sr-only"] $ tr_ do + when (isJust tbl.features.rowId) $ th_ [scope_ "col"] "Select issue" + forM_ tbl.columns $ \c -> th_ [scope_ "col", data_ "column-key" c.name] $ toHtml c.name + tbody_ renderedRows + _ -> renderedRows treeRowAttrs :: a -> TreeConfig a -> [Attribute] @@ -608,7 +630,7 @@ renderTableRow tbl row = $ input_ $ [term "aria-label" $ "Select " <> maybe "Item" ($ row) tbl.features.rowLabel, class_ "bulkactionItemCheckbox checkbox checkbox-md checked:checkbox-primary", type_ "checkbox", name_ "itemId", value_ $ getId row] <> [checked_ | maybe False ($ row) tbl.features.selectRow] - forM_ (zip [0 :: Int ..] tbl.columns) \(idx, c) -> td_ (c.attrs <> (class_ <$> maybeToList c.align) <> [data_ "column-index" $ show idx]) $ c.render row + forM_ tbl.columns $ \c -> td_ (c.attrs <> (class_ <$> maybeToList c.align) <> [data_ "column-key" c.name]) $ c.render row where rowAttrs = maybe [] ($ row) tbl.features.rowAttrs treeAttrs = maybe [] (treeRowAttrs row) tbl.features.treeConfig @@ -638,32 +660,38 @@ renderHeaderTableActions :: TableHeaderActions -> Html () renderHeaderTableActions actions = span_ [class_ "inline-flex flex-wrap items-center gap-2 ml-2"] do unless (null actions.sortOptions) $ renderSortDropdown actions unless (null actions.filterMenus) $ renderFilterDropdown actions - -- Each active filter as a removable chip, Sentry-search style: "Type: runtime_exception ×". - forM_ actions.filterMenus \menu -> forM_ (filter (.isActive) menu.options) \opt -> - button_ ([type_ "button", class_ "inline-flex items-center gap-1 rounded-md border border-strokeWeak bg-fillWeak px-2 py-0.5 text-xs text-textStrong hover:bg-fillWeaker max-md:whitespace-normal", Aria.label_ $ "Remove filter " <> menu.label <> ": " <> opt.label] <> swapTarget_ actions.targetId (deleteParamValue menu.paramName opt.value actions.baseUrl)) do - span_ [class_ "text-textWeak"] $ toHtml $ menu.label <> ":" - toHtml opt.label - faSprite_ "xmark" "regular" "w-2.5 h-2.5 text-textWeak" + renderFilterChips actions + + +renderFilterChips :: TableHeaderActions -> Html () +renderFilterChips actions = forM_ actions.filterMenus \menu -> forM_ (filter (.isActive) menu.options) \opt -> + button_ ([type_ "button", class_ "inline-flex items-center gap-1 rounded-md border border-strokeWeak bg-fillWeak px-2 py-0.5 text-xs text-textStrong hover:bg-fillWeaker max-md:whitespace-normal", Aria.label_ $ "Remove filter " <> menu.label <> ": " <> opt.label] <> swapTarget_ actions.targetId (deleteParamValue menu.paramName opt.value actions.baseUrl)) do + span_ [class_ "text-textWeak"] $ toHtml $ menu.label <> ":" + toHtml opt.label + faSprite_ "xmark" "regular" "w-2.5 h-2.5 text-textWeak" clearFiltersUrl :: TableHeaderActions -> Text clearFiltersUrl actions = foldl' (\url menu -> deleteParam menu.paramName url) actions.baseUrl actions.filterMenus -renderFilterRail :: TableHeaderActions -> Html () -renderFilterRail actions = - details_ [open_ "", class_ "w-60 shrink-0 max-lg:w-full", detailsClosedBelowAttr_ 1024] do - summary_ [class_ "flex cursor-pointer list-none items-center gap-2 rounded px-2 py-2 text-xs font-semibold text-textStrong hover:bg-fillWeak [&::-webkit-details-marker]:hidden"] "Filters" - facetRail_ Nothing "p-2" "Search filters" (Just clearAll) $ forM_ (zip [0 :: Int ..] actions.filterMenus) \(index, menu) -> - facetSection_ (index == 0 || any (.isActive) menu.options) "" [] (toHtml menu.label) - $ div_ [class_ "max-h-48 overflow-y-auto"] - $ forM_ menu.options (renderFilterOption actions menu) +-- | Adapt URL filters to the same sidebar data Explorer supplies. +facetsConfig :: TableHeaderActions -> Facets.Config +facetsConfig actions = Facets.Config (actions.targetId <> "-facets") "" (Just $ swapTarget_ actions.targetId $ clearFiltersUrl actions) (Just [common]) Facets.Collapsible where - clearAll = - button_ - ([type_ "button", class_ "flex items-center justify-between rounded px-2 py-1.5 text-xs text-textBrand hover:bg-fillWeak"] <> swapTarget_ actions.targetId (clearFiltersUrl actions)) - $ "Clear all" - >> faSprite_ "xmark" "regular" "h-3 w-3" + common = + FacetNode "Common Filters" Nothing True [] Nothing + $ FacetChildren + [ FacetNode + { label = menu.label + , path = Just menu.paramName + , expanded = (index < 5 && not (null menu.options)) || any (.isActive) menu.options + , attrs = [] + , actions = Nothing + , content = FacetValues (map (filterChoice actions menu) $ sortOn (not . (.isActive)) menu.options) ExpandValues + } + | (index, menu) <- zip [0 :: Int ..] actions.filterMenus + ] renderSortDropdown :: TableHeaderActions -> Html () @@ -706,29 +734,26 @@ renderFilterMenuItem actions menu = div_ [class_ "relative"] do ] do div_ [class_ "px-3 py-2 text-sm font-semibold text-textStrong border-b border-strokeWeak"] $ toHtml menu.label - div_ [class_ "p-1"] $ forM_ menu.options (renderFilterOption actions menu) + div_ [class_ "p-1"] $ forM_ menu.options (facetChoice_ . filterChoice actions menu) -renderFilterOption :: TableHeaderActions -> FilterMenu -> FilterOption -> Html () -renderFilterOption actions menu opt = facetOption_ "" [] optionBody pass +filterChoice :: TableHeaderActions -> FilterMenu -> FilterOption -> FacetChoice +filterChoice actions menu opt = FacetChoice{label = opt.label, multiSelect = menu.multiSelect, inputAttrs, marker = Nothing, count = prettyPrintCount <$> opt.count} where paramVal = menu.paramName <> "=" <> toUriStr opt.value -- For multi-select: toggle this value; for single-select: replace all with this value url - | menu.multiSelect && opt.isActive = deleteParamValue menu.paramName opt.value actions.baseUrl + | opt.isActive = deleteParamValue menu.paramName opt.value actions.baseUrl | menu.multiSelect = withQuery actions.baseUrl paramVal | otherwise = withQuery (deleteParam menu.paramName actions.baseUrl) paramVal - optionBody = do - input_ - $ [ type_ $ bool "radio" "checkbox" menu.multiSelect - , class_ $ bool "radio radio-xs" "checkbox checkbox-xs" menu.multiSelect - , value_ opt.value - , name_ $ "filter_" <> menu.paramName -- group radios by param name - , hxTrigger_ "change" - ] + inputAttrs = + [ Aria.label_ opt.label + , value_ opt.value + , name_ $ actions.targetId <> "_filter_" <> menu.paramName + , hxTrigger_ "change" + ] <> swapTarget_ actions.targetId url <> [checked_ | opt.isActive] - span_ [class_ "truncate text-sm"] $ toHtml opt.label -- | Remove a specific param=value pair from a URL (for multi-select filter toggle) @@ -772,23 +797,64 @@ renderSortMenu sortCfg = do renderResultToolbar :: Table a -> Html () renderResultToolbar tbl = div_ [class_ "flex min-h-9 flex-wrap items-center justify-between gap-2"] do whenJust tbl.features.resultSummary $ \summary -> span_ [class_ "text-sm text-textWeak", role_ "status", Aria.live_ "polite"] $ toHtml summary - whenJust tbl.features.exportName $ \name -> div_ [class_ "flex items-center gap-2"] do - button_ - [ type_ "button" - , class_ "btn btn-xs border border-strokeWeak bg-transparent font-normal text-textWeak shadow-none" - , onclick_ $ "window.exportTableCsv('#" <> tbl.config.elemID <> "_grid table','" <> name <> ".csv')" - ] - do - faSprite_ "download" "solid" "h-3 w-3" - "Export" - button_ ([type_ "button", class_ "btn btn-xs border border-strokeWeak bg-transparent font-normal text-textWeak shadow-none"] <> popoverTrigger_ (tbl.config.elemID <> "-columns")) do - faSprite_ "table-columns" "regular" "h-3 w-3" - "Customize" - div_ (popoverPanel_ (tbl.config.elemID <> "-columns") <> [class_ "dropdown dropdown-end z-50 mt-1 w-56 rounded-md border border-strokeWeak bg-bgRaised p-2 text-sm shadow-lg"]) do - span_ [class_ "block px-2 pb-1 text-xs font-semibold text-textStrong"] "Visible columns" - forM_ (zip [0 :: Int ..] tbl.columns) \(idx, column) -> label_ [class_ "flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-fillWeak"] do - input_ [type_ "checkbox", checked_, class_ "checkbox checkbox-xs", onchange_ $ "document.querySelectorAll('#" <> tbl.config.elemID <> "_grid [data-column-index=\"" <> show idx <> "\"]').forEach((element)=>element.classList.toggle('hidden',!this.checked))"] - toHtml column.name + forM_ tbl.features.facets $ \facets -> div_ [class_ "flex flex-wrap gap-2"] $ renderFilterChips facets + div_ [class_ "ml-auto flex flex-wrap items-center gap-2"] do + forM_ tbl.features.grouping selectControl_ + forM_ tbl.features.exportName $ \name -> + button_ + [ type_ "button" + , class_ "btn btn-xs border border-strokeWeak bg-transparent font-normal text-textWeak shadow-none" + , onclick_ $ "window.exportTableCsv('#" <> tbl.config.elemID <> "_grid table','" <> name <> ".csv')" + ] + $ faSprite_ "download" "solid" "h-3 w-3" + >> "Export" + when tbl.features.customizeColumns do + button_ ([type_ "button", class_ "btn btn-xs border border-strokeWeak bg-transparent font-normal text-textWeak shadow-none"] <> popoverTrigger_ (tbl.config.elemID <> "-columns")) do + faSprite_ "table-columns" "regular" "h-3 w-3" + "Customize" + -- CSS owns visibility; the checkbox only restores a stored choice. @data-forced@ marks a + -- user choice, so ticking a column that a breakpoint hides shows it. + style_ $ flip foldMap tbl.columns \c -> + let rule toggle display = "#" <> tbl.config.elemID <> "_page:has([data-column-toggle=\"" <> c.name <> "\"]" <> toggle <> ") [data-column-key=\"" <> c.name <> "\"]{display:" <> display <> "}" + in rule "[data-forced]:not(:checked)" "none" <> rule "[data-forced]:checked" "table-cell" + div_ + ( popoverPanel_ (tbl.config.elemID <> "-columns") + <> [ class_ "dropdown dropdown-end z-50 mt-1 w-56 rounded-md border border-strokeWeak bg-bgRaised p-2 text-sm shadow-lg" + , [__|on beforetoggle if event.newState is 'open' send syncColumn to <[data-column-toggle]/> in me end + on resize from window if me.matches(':popover-open') send syncColumn to <[data-column-toggle]/> in me end|] + ] + ) + do + span_ [class_ "block px-2 pb-1 text-xs font-semibold text-textStrong"] "Visible columns" + forM_ tbl.columns $ \column -> label_ [class_ "flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-fillWeak"] do + input_ + [ type_ "checkbox" + , checked_ + , class_ "checkbox checkbox-xs" + , data_ "column-toggle" column.name + , data_ "column-storage" $ "table-columns:" <> tbl.config.elemID <> ":" <> column.name + , [__|init set saved to localStorage.getItem(@data-column-storage) if saved is not null set @data-forced to 'true' then set my.checked to saved is 'true' end + on syncColumn if not @data-forced + set cell to (closest <[data-table-columns]/>).querySelector('[data-column-key="' + @data-column-toggle + '"]') + if cell set my.checked to getComputedStyle(cell).display is not 'none' end + end + on change set @data-forced to 'true' then call localStorage.setItem(@data-column-storage, my.checked)|] + ] + toHtml column.name + button_ + [ type_ "button" + , class_ "btn btn-xs btn-ghost mt-1 w-full font-normal" + , [__|on click repeat for t in <[data-column-toggle]/> in closest <[data-table-columns]/> call localStorage.removeItem(t.getAttribute('data-column-storage')) then remove @data-forced from t then send syncColumn to t end|] + ] + "Reset columns" + + +selectControl_ :: SelectControl -> Html () +selectControl_ cfg = label_ [class_ "flex items-center gap-2 whitespace-nowrap text-xs text-textWeak"] do + toHtml cfg.label + select_ ([name_ cfg.name, class_ "select select-xs w-auto cursor-pointer border-strokeWeak bg-bgBase max-md:min-h-11 max-md:text-base", hxTrigger_ "change"] <> swapTarget_ cfg.targetId (deleteParam "deferred" $ deleteParam cfg.name cfg.baseUrl)) + $ forM_ cfg.options + $ \(value, label) -> option_ ([value_ value] <> [selected_ "" | value == cfg.current]) $ toHtml label renderPaginationFooter :: Pagination -> Html () diff --git a/src/Pkg/Components/TimePicker.hs b/src/Pkg/Components/TimePicker.hs index ef4c27653..bcf875aae 100644 --- a/src/Pkg/Components/TimePicker.hs +++ b/src/Pkg/Components/TimePicker.hs @@ -3,7 +3,6 @@ module Pkg.Components.TimePicker ( parseTimeRange, LiveDataMode (..), liveDataControls_, - timeHiddenInputs_, TimePicker (..), rangePairs, rangeJson, @@ -489,11 +488,6 @@ windowUrl :: Text -> [(Text, Text)] -> TimeWindow -> Text windowUrl base extras window = timeScopedUrl base extras window.fromQuery window.toQuery window.sinceQuery -timeHiddenInputs_ :: Maybe Text -> Maybe Text -> Maybe Text -> Html () -timeHiddenInputs_ fromM toM sinceM = forM_ ([("from", fromM), ("to", toM), ("since", sinceM)] :: [(Text, Maybe Text)]) \(name, valueM) -> - input_ [type_ "hidden", name_ name, value_ $ fromMaybe "" valueM, term "_" "on 'update-query' from window set my.value to window.params()[my.name] or ''"] - - refreshOptions :: [(Text, Text, Text)] refreshOptions = [ ("Off", "Turn off automatic refresh", "0") diff --git a/test/integration/Pages/ContainersSpec.hs b/test/integration/Pages/ContainersSpec.hs index c43ca7b91..cc68f0e01 100644 --- a/test/integration/Pages/ContainersSpec.hs +++ b/test/integration/Pages/ContainersSpec.hs @@ -7,7 +7,6 @@ import Data.Cache qualified as Cache import Data.Effectful.Hasql qualified as Hasql import Data.List (lookup) import Data.Text qualified as T -import Data.Text.Lazy qualified as LT import Data.Time (NominalDiffTime, addUTCTime) import Data.Vector qualified as V import Lucid qualified @@ -16,7 +15,7 @@ import Pages.BodyWrapper (BWConfig (..), PageCtx (..)) import Pages.Charts.Charts qualified as Charts import Pages.Containers qualified as Containers import Pages.Infrastructure qualified as Infrastructure -import Pkg.Components.Table (Table (..)) +import Pkg.Components.Table (Features (customizeColumns, exportName, resultSummary), Table (..)) import Pkg.Components.Widget qualified as Widget import Pkg.Icons qualified as Icons import Pkg.TestUtils @@ -99,59 +98,19 @@ ingestFixture tr key = do k8sOther = k8sResource "kube-system" "coredns-c6d9fc49c-bj5sw" "coredns" k8sStateful = mkAttr "k8s.statefulset.name" "postgres" : k8sPodResource "data" "postgres-0" "postgres" - -- 0.5 cores against a 2-core limit and 512 MiB against 1 GiB: both percentages land on - -- round numbers so a unit slip anywhere in the pipeline is obvious rather than plausible. + -- Mixed receiver families, absent limits, controller fallback, pod-only telemetry, + -- and a pod rollup that must lose to its reporting containers. forM_ - ( [ ("container.cpu.usage", 0.5) - , ("container.memory.working_set", 536870912) - , ("k8s.container.cpu_limit", 2) - , ("k8s.container.cpu_request", 1) - , ("k8s.container.memory_limit", 1073741824) - , ("k8s.container.memory_request", 268435456) - , ("k8s.container.restarts", 3) - , ("k8s.container.ready", 1) + ( [ (k8sChecked, [("container.cpu.usage", 0.5), ("container.memory.working_set", 536870912), ("k8s.container.cpu_limit", 2), ("k8s.container.cpu_request", 1), ("k8s.container.memory_limit", 1073741824), ("k8s.container.memory_request", 268435456), ("k8s.container.restarts", 3), ("k8s.container.ready", 1)]) + , (k8sOther, [("container.cpu.usage", 0.25), ("container.memory.working_set", 33554432), ("k8s.container.ready", 0)]) + , (dockerResource "srv-captain--redpanda-0.1.tt13bkp5", [("container.cpu.utilization", 80), ("container.memory.usage.total", 3221225472), ("container.memory.usage.limit", 4294967296)]) + , (k8sStateful, [("container.cpu.usage", 0.1), ("container.memory.usage", 16777216)]) + , (mkAttr "k8s.deployment.name" "frontend" : k8sPodResource "shop" "frontend-544d9b6f4-2xk7z" "frontend", [("k8s.pod.cpu.usage", 0.75), ("k8s.pod.memory.working_set", 268435456)]) + , (k8sChecked, [("k8s.pod.cpu.usage", 9.9), ("k8s.pod.memory.working_set", 9999999)]) ] - :: [(Text, Double)] + :: [([PC.KeyValue], [(Text, Double)])] ) - (emit k8sChecked) - - -- A second namespace, and deliberately no CPU limit — the case Datadog documents as - -- "cannot infer the usage percentage", which must stay blank rather than become 0%. - forM_ - ( [ ("container.cpu.usage", 0.25) - , ("container.memory.working_set", 33554432) - , ("k8s.container.ready", 0) - ] - :: [(Text, Double)] - ) - (emit k8sOther) - - -- Docker: 80 means 0.8 cores under Docker's own formula, and the memory limit arrives on a - -- different metric name from the Kubernetes one. - forM_ - ( [ ("container.cpu.utilization", 80) - , ("container.memory.usage.total", 3221225472) - , ("container.memory.usage.limit", 4294967296) - ] - :: [(Text, Double)] - ) - (emit $ dockerResource "srv-captain--redpanda-0.1.tt13bkp5") - - -- A StatefulSet pod, and memory on kubeletstats' container.memory.usage rather than - -- working_set: neither the workload nor the memory reading survives without a fallback. - forM_ ([("container.cpu.usage", 0.1), ("container.memory.usage", 16777216)] :: [(Text, Double)]) (emit k8sStateful) - - -- A pod whose collector runs kubeletstats without the container metric group: only - -- k8s.pod.* arrives, so the pod itself has to stand in for its containers. - forM_ - ([("k8s.pod.cpu.usage", 0.75), ("k8s.pod.memory.working_set", 268435456)] :: [(Text, Double)]) - (emit $ mkAttr "k8s.deployment.name" "frontend" : k8sPodResource "shop" "frontend-544d9b6f4-2xk7z" "frontend") - - -- The same pod metrics for a pod that DOES report container metrics: the rollup must lose - -- to its containers rather than appear beside them. - forM_ - ([("k8s.pod.cpu.usage", 9.9), ("k8s.pod.memory.working_set", 9999999)] :: [(Text, Double)]) - (emit k8sChecked) + \(resource, metrics) -> mapM_ (emit resource) metrics -- A bare node. Four logical cores, each reporting idle and user; 0.25 busy on each of two -- of them is 0.5 cores of a 4-core box. Memory is per state and the limit is their sum. @@ -208,11 +167,11 @@ spec = sequential $ aroundAll withTfChartResources do conf.pageTitle `shouldBe` "Infrastructure" conf.prePageTitle `shouldBe` Nothing V.length table.rows `shouldBe` 0 - let html = LT.toStrict $ Lucid.renderText $ Lucid.toHtml page + let html = toText $ Lucid.renderText $ Lucid.toHtml page html `shouldContainAll` ["No containers reporting"] (_, hostMap) <- testServant tr $ Infrastructure.hostMapGetH testPid Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing (Just "1") - LT.toStrict (Lucid.renderText $ Lucid.toHtml hostMap) + toText (Lucid.renderText $ Lucid.toHtml hostMap) `shouldContainAll` ["No hosts reporting", "Set up host monitoring"] it "kubernetesAndDockerContainers_landInOneListWithNormalisedCpuAndMemory" \tr -> do @@ -315,17 +274,17 @@ spec = sequential $ aroundAll withTfChartResources do let Containers.ContainersPage (PageCtx _ body) = page table <- deferredBody body V.length table.rows `shouldBe` 8 - let html = LT.toStrict $ Lucid.renderText $ Lucid.toHtml page + let html = toText $ Lucid.renderText $ Lucid.toHtml page cacheKey = (testPid, Nothing, Nothing, Just "5M") :: ContainerSnapshotKey cached <- Cache.lookup tr.trATCtx.infrastructureCache cacheKey :: IO (Maybe (V.Vector ContainerRow)) V.length <$> cached `shouldBe` Just 8 -- Both runtimes in one table, with the facet menus the filter dropdown is built from. - html `shouldContainAll` ["checkout", "srv-captain--redpanda-0.1.tt13bkp5", "kube-system", "namespace=", "runtime=", "cluster=", "otel-demo", "vps-bare-01", "Search containers", "CPU limit used", "Memory limit used", "data-live-mode=\"refresh-only\"", "data-component=\"facet-rail\"", "data-component=\"facet-section\"", "data-component=\"facet-option\"", "Last 5 mins", "bg-bgAlternate sticky", "bg-fillWarning-strong", "hidden max-md:inline-flex", "data-header-actions", "/infrastructure/containers/detail?since=5M"] + html `shouldContainAll` ["checkout", "srv-captain--redpanda-0.1.tt13bkp5", "kube-system", "namespace=", "runtime=", "cluster=", "otel-demo", "vps-bare-01", "Search containers", "CPU limit used", "Memory limit used", "data-live-mode=\"refresh-only\"", "data-component=\"facet-rail\"", "data-component=\"facet-section\"", "data-component=\"facet-option\"", "Common Filters", "facet-count", "Last 5 mins", "bg-bgAlternate sticky", "bg-fillWarning-strong", "hidden max-md:inline-flex", "data-header-actions", "/infrastructure/containers/detail?since=5M"] T.isInfixOf "group/summary" html `shouldBe` False T.isInfixOf "Usage over time" html `shouldBe` False T.isInfixOf "min-height:" html `shouldBe` False T.isInfixOf "data-widget=" html `shouldBe` False - T.count "data-component=\"facet-section\" open>" html `shouldBe` 1 + T.count "data-component=\"facet-section\" open>" html `shouldBe` 6 -- The screenshot regression: "Not ready" wrapped onto two lines in the narrow status -- column, crossing the badge border. The label is one indivisible status. html `shouldContainAll` ["Not ready", "class=\"badge badge-sm badge-error whitespace-nowrap\""] @@ -333,6 +292,30 @@ spec = sequential $ aroundAll withTfChartResources do -- as a clipped stroke. The table export uses the correctly framed Font Awesome symbol. html `shouldContainAll` ["M288 32c0-17.7-14.3-32-32-32s-32 14.3-32 32"] + html + `shouldContainAll` [ "w-80 max-w-80 overflow-hidden" + , "w-72 max-w-72 overflow-hidden" + , "min-w-0 flex-1 truncate" + , "max-w-36 truncate" + , "data-tip=\"Runtime: kubernetes\"" + , "data-tippy-content=\"Container: checkout\"" + , "data-tippy-content=\"Image: demo:2.2.0\"" + , "data-tippy-content=\"Workload: checkout\"" + , "data-tippy-content=\"CPU limit used: " + , "data-tippy-content=\"Memory limit used: " + , "data-tippy-content=\"Latest reported readiness: Ready\"" + , "Latest cumulative restart count reported within the selected time range. This is not the number of restarts during the range." + ] + + let render features = toText $ Lucid.renderText $ Lucid.toHtml table{features} + customizeOnly = render table.features{resultSummary = Nothing, exportName = Nothing, customizeColumns = True} + exportOnly = render table.features{resultSummary = Nothing, exportName = Just "containers", customizeColumns = False} + render table.features `shouldContainAll` ["type=\"radio\"", "id=\"containersContainer-facets\"", "name=\"containersContainer_filter_namespace\""] + customizeOnly `shouldContainAll` ["Customize", "Reset columns", "data-column-key=", "data-column-toggle=", "[data-forced]:not(:checked)) [data-column-key=\"Pod\"]{display:none}"] + customizeOnly `shouldNotSatisfy` T.isInfixOf ">Export" + exportOnly `shouldContainAll` ["Export", "containers.csv"] + exportOnly `shouldNotSatisfy` T.isInfixOf "Customize" + -- Each facet narrows independently, and an unknown value yields an empty list rather -- than silently falling back to "all". let listWith f = do @@ -352,31 +335,18 @@ spec = sequential $ aroundAll withTfChartResources do -- A bare node is its own runtime, so it is reachable and does not pollute the others. listWith (containersPage noContainerFilters{runtime = Just "host"}) >>= (`shouldMatchList` ["vps-bare-01", "vps-d6d7e318"]) - -- Keep both identity columns readable when image and workload names are long. - it "longContainerMetadata_doesNotOverlapPodColumn" \tr -> do - (_, page) <- testServant tr $ containersPage noContainerFilters - let html = LT.toStrict $ Lucid.renderText $ Lucid.toHtml page - html - `shouldContainAll` [ "w-80 max-w-80 overflow-hidden" - , "w-72 max-w-72 overflow-hidden" - , "min-w-0 flex-1 truncate" - , "max-w-36 truncate" - , "data-tip=\"Runtime: kubernetes\"" - , "data-tippy-content=\"Container: checkout\"" - , "data-tippy-content=\"Image: demo:2.2.0\"" - , "data-tippy-content=\"Workload: checkout\"" - , "data-tippy-content=\"CPU limit used: " - , "data-tippy-content=\"Memory limit used: " - , "data-tippy-content=\"Latest reported readiness: Ready\"" - , "Latest cumulative restart count reported within the selected time range. This is not the number of restarts during the range." - ] - it "infrastructureViews_projectTheSameTelemetryIntoHostsImagesKubernetesAndMap" \tr -> do - (_, hosts@(Infrastructure.HostsPage (PageCtx hostsBW _))) <- testServant tr $ Infrastructure.hostsGetH testPid Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing (Just "1") + (_, hosts@(Infrastructure.HostsPage (PageCtx hostsBW body))) <- testServant tr $ Infrastructure.hostsGetH testPid Nothing Nothing Nothing Nothing (Just "integration") Nothing Nothing Nothing (Just "1") V.null hostsBW.serviceOptions `shouldBe` True hostsBW.pageTitle `shouldBe` "Infrastructure" hostsBW.prePageTitle `shouldBe` Nothing - let hostsHtml = LT.toStrict $ Lucid.renderText $ Lucid.toHtml hosts + let hostsHtml = toText $ Lucid.renderText $ Lucid.toHtml hosts + table <- deferredBody body + V.length table.rows `shouldBe` 3 + let tableHtml = toText $ Lucid.renderText $ Lucid.toHtml table + hostsHtml `shouldContainAll` ["data-table-group=\"Docker\"", "data-table-group=\"Kubernetes\"", "data-table-group=\"OpenTelemetry\"", "data-hx-trigger=\"change\"", "name=\"group\"", "data-column-key=\"CPU\""] + T.count "role=\"button\"" tableHtml `shouldBe` 3 + T.count " clusterUid <> "\""] (_, hostMap) <- testServant tr $ Infrastructure.hostMapGetH testPid (Just "storage") Nothing Nothing Nothing Nothing Nothing Nothing Nothing (Just "1") - let hostMapHtml = LT.toStrict $ Lucid.renderText $ Lucid.toHtml hostMap + let hostMapHtml = toText $ Lucid.renderText $ Lucid.toHtml hostMap hostMapHtml - `shouldContainAll` ["Host Map", "Fill by", "Group by", "vps-bare-01", "clip-path:polygon", "bg-fillNeutral-strong", "onchange=\"this.form.requestSubmit()\"", "name=\"os\""] + `shouldContainAll` ["Host Map", "Fill by", "Group by", "vps-bare-01", "clip-path:polygon", "bg-fillNeutral-strong", "name=\"fill\"", "data-hx-trigger=\"change\"", "data-component=\"facet-rail\"", "Provider", "Region", "OS"] hostMapHtml `shouldNotSatisfy` T.isInfixOf ">Apply" (_, hostDetail) <- testServant tr $ Infrastructure.hostDetailGetH testPid (Just "vps-bare-01") Nothing Nothing Nothing - LT.toStrict (Lucid.renderText $ Lucid.toHtml hostDetail) + toText (Lucid.renderText $ Lucid.toHtml hostDetail) `shouldContainAll` [ "Host summary" , "CPU usage" , "Memory usage" @@ -418,11 +388,11 @@ spec = sequential $ aroundAll withTfChartResources do ] (_, imageDetail) <- testServant tr $ Infrastructure.imageDetailGetH testPid (Just "ghcr.io/open-telemetry/demo") Nothing Nothing Nothing - LT.toStrict (Lucid.renderText $ Lucid.toHtml imageDetail) + toText (Lucid.renderText $ Lucid.toHtml imageDetail) `shouldContainAll` ["Image summary", "Running containers", "Security coverage", "View containers"] (_, kubeDetail) <- testServant tr $ Infrastructure.kubernetesDetailGetH testPid (Just "pods") (Just "checkout-7fb5b4f859-nlcjs") (Just "otel-demo") (Just "default") Nothing Nothing Nothing - LT.toStrict (Lucid.renderText $ Lucid.toHtml kubeDetail) + toText (Lucid.renderText $ Lucid.toHtml kubeDetail) `shouldContainAll` ["Pod summary", "CPU / limit", "View containers", "View logs", "View metrics"] it "hostCpuChart_normalizesAcrossLogicalCoresAndRepeatedSamples" \tr -> do @@ -432,17 +402,20 @@ spec = sequential $ aroundAll withTfChartResources do forM_ ([("idle", 0.875), ("user", 0.125)] :: [(Text, Double)]) \(mode, value) -> ingestMetric tr key [mkAttr "host.name" "four-core-host"] [mkAttr "cpu.logical_number" core, mkAttr "cpu.mode" mode] "system.cpu.utilization" value (addUTCTime offset frozenTime) ingestMetric tr key [mkAttr "host.name" "host-without-idle"] [mkAttr "cpu.logical_number" "cpu0", mkAttr "cpu.mode" "user"] "system.cpu.utilization" 0.5 (addUTCTime (-60) frozenTime) - forM_ ([("four-core-host", Just 12.5), ("vps-d6d7e318", Just 25), ("host-without-idle", Nothing)] :: [(Text, Maybe Double)]) \(host, expected) -> do + forM_ ([("four-core-host", Just 12.5), ("vps-d6d7e318", Just 25), ("host-without-idle", Nothing), ("0ce201583b04", Nothing)] :: [(Text, Maybe Double)]) \(host, expected) -> do html <- shellHtml tr $ Infrastructure.hostDetailGetH testPid (Just host) Nothing Nothing (Just "2H") case expected of Just percentage -> chartPlotsOnly tr testPid "host-cpu" percentage html Nothing -> do - html `shouldContainAll` ["No host metrics in this time range", "Metrics coverage: 0 of 4"] + html `shouldContainAll` ["No host metrics in this time range", "Try last 1 hour", "Set up host metrics", "View logs", "Metrics coverage: 0 of 4"] html `shouldNotSatisfy` T.isInfixOf "host-cpu" it "infrastructureDrawer_drilldownsKeepTheSelectedTimeWindow" \tr -> do containerHtml <- shellHtml tr $ Containers.containerDetailGetH testPid (Just "checkout") (Just "checkout-7fb5b4f859-nlcjs") Nothing Nothing Nothing Nothing Nothing Nothing (Just "24H") containerHtml `shouldContainAll` ["/log_explorer?since=24H&query=", "/metrics?since=24H&metric_prefix=k8s."] + containerHtml `shouldContainAll` ["Requests and limits", "vps-d6d7e318", "View logs", "resource.k8s.pod.name", "data-tippy-content=\"Pod: checkout-7fb5b4f859-nlcjs\"", ""hide_value":true", "text-textWeak widget-subtitle", "\\"bottom\\":0", "\\"tooltip\\":{\\"show\\":true}"] + gone <- shellHtml tr $ Containers.containerDetailGetH testPid (Just "ghost") Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing + gone `shouldContainAll` ["no longer reporting"] historicalContainer <- shellHtml tr $ Containers.containerDetailGetH testPid (Just "checkout") (Just "checkout-7fb5b4f859-nlcjs") Nothing Nothing Nothing Nothing (Just "2024-12-31T23:00:00Z") (Just "2025-01-01T00:00:00Z") Nothing historicalContainer `shouldContainAll` ["/log_explorer?from=2024-12-31T23%3A00%3A00Z&to=2025-01-01T00%3A00%3A00Z&query=", "/metrics?from=2024-12-31T23%3A00%3A00Z&to=2025-01-01T00%3A00%3A00Z&metric_prefix=k8s."] hostHtml <- shellHtml tr $ Infrastructure.hostDetailGetH testPid (Just "vps-bare-01") Nothing Nothing (Just "24H") @@ -481,36 +454,19 @@ spec = sequential $ aroundAll withTfChartResources do param "since" `shouldBe` Just "4H" it "firstRequest_rendersChromeAndASelfFetchingSkeleton_notRows" \tr -> do - -- Every infrastructure view is one multi-second metrics pivot. The request that paints - -- the page must not wait for it: it answers with nav, tabs and a skeleton that fetches - -- the body itself. Rows appearing here again would mean the deferral was undone and - -- navigating the section is back to seconds per click. - hostsShell <- shellHtml tr $ Infrastructure.hostsGetH testPid Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing - hostsShell `shouldContainAll` ["Infrastructure", "Hosts", "id=\"hostsContainer\"", "hx-trigger=\"load, update-query from:window\"", "/infrastructure/hosts?", "deferred=1"] - hostsShell `shouldNotSatisfy` T.isInfixOf "vps-bare-01" - - containersShell <- shellHtml tr $ Containers.containersGetH testPid Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing - containersShell `shouldContainAll` ["id=\"containersContainer\"", "hx-trigger=\"load, update-query from:window\"", "deferred=1"] - containersShell `shouldNotSatisfy` T.isInfixOf "checkout-7fb5b4f859-nlcjs" - - -- The filters the shell was asked for have to survive the round trip, or the rows that - -- arrive would not be the rows the visible filter chips claim. - filteredShell <- shellHtml tr $ Infrastructure.kubernetesGetH testPid (Just "pods") (Just "otel-demo") (Just "default") Nothing Nothing Nothing Nothing Nothing - filteredShell `shouldContainAll` ["resource=pods", "cluster=otel-demo", "namespace=default", "deferred=1", "hx-trigger=\"load, update-query from:window\""] - - imagesShell <- shellHtml tr $ Infrastructure.imagesGetH testPid Nothing Nothing Nothing Nothing Nothing Nothing - imagesShell `shouldContainAll` ["id=\"imagesContainer\"", "data-deferred-shell", "hx-trigger=\"load, update-query from:window\""] - mapShell <- shellHtml tr $ Infrastructure.hostMapGetH testPid Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing - mapShell `shouldContainAll` ["id=\"hostMapContainer\"", "Loading host map", "hx-trigger=\"load, update-query from:window\""] - mapShell `shouldNotSatisfy` T.isInfixOf "data-visible-host-label" - - it "hostDetail_withoutHostMetrics_collapsesChartsIntoRecoveryState" \tr -> do - -- The Docker host: containers report against it but no system.* series does. - -- (vps-d6d7e318 no longer qualifies — the in-cluster agent gives it host CPU.) - (_, hostWithoutMetrics) <- testServant tr $ Infrastructure.hostDetailGetH testPid (Just "0ce201583b04") Nothing Nothing Nothing - let html = LT.toStrict $ Lucid.renderText $ Lucid.toHtml hostWithoutMetrics - html `shouldContainAll` ["No host metrics in this time range", "Try last 1 hour", "Set up host metrics", "View logs", "Metrics coverage: 0 of 4"] - html `shouldNotSatisfy` T.isInfixOf "host-cpu" + forM_ + ( [ (shellHtml tr $ Infrastructure.hostsGetH testPid Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing, ["Infrastructure", "Hosts", "id=\"hostsContainer\"", "/infrastructure/hosts?", "deferred=1"], ["vps-bare-01"]) + , (shellHtml tr $ Containers.containersGetH testPid Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing, ["id=\"containersContainer\"", "deferred=1"], ["checkout-7fb5b4f859-nlcjs"]) + , (shellHtml tr $ Infrastructure.kubernetesGetH testPid (Just "pods") (Just "otel-demo") (Just "default") Nothing Nothing Nothing Nothing Nothing, ["resource=pods", "cluster=otel-demo", "namespace=default", "deferred=1"], []) + , (shellHtml tr $ Infrastructure.imagesGetH testPid Nothing Nothing Nothing Nothing Nothing Nothing, ["id=\"imagesContainer\"", "data-deferred-shell"], []) + , (shellHtml tr $ Infrastructure.hostMapGetH testPid Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing, ["id=\"hostMapContainer\"", "Loading host map"], ["data-visible-host-label"]) + ] + :: [(IO Text, [Text], [Text])] + ) + \(render, expected, absent) -> do + html <- render + html `shouldContainAll` ("hx-trigger=\"load, update-query from:window\"" : expected) + forM_ absent $ \value -> html `shouldNotSatisfy` T.isInfixOf value it "containerDrawer_sameNamesRemainDistinctAndKeepTheirMetricScope" \tr -> do pid <- createTestProject tr "container-scoped-drawer" @@ -550,19 +506,6 @@ spec = sequential $ aroundAll withTfChartResources do victim <- createTestProject tr "victim" assertNonMemberDenied tr victim $ Containers.containerDetailGetH victim (Just "checkout") Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing - it "detailDrawer_showsRequestsAndLimitsAndPivots" \tr -> do - (_, html') <- testServant tr $ Containers.containerDetailGetH testPid (Just "checkout") (Just "checkout-7fb5b4f859-nlcjs") Nothing Nothing Nothing Nothing Nothing Nothing Nothing - let html = LT.toStrict $ Lucid.renderText $ Lucid.toHtml html' - html `shouldContainAll` ["Requests and limits", "vps-d6d7e318", "View logs", "resource.k8s.pod.name", "data-tippy-content=\"Pod: checkout-7fb5b4f859-nlcjs\""] - - -- The chart options ride as a JSON string inside data-chart-config, so their quotes are - -- JSON-escaped and then attribute-escaped; the bare form would mean a broken JSON.parse. - html `shouldContainAll` [""hide_value":true", "text-textWeak widget-subtitle", "\\"bottom\\":0", "\\"tooltip\\":{\\"show\\":true}"] - - -- A container that stopped reporting must say so, not render a blank panel. - (_, gone) <- testServant tr $ Containers.containerDetailGetH testPid (Just "ghost") Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing - LT.toStrict (Lucid.renderText $ Lucid.toHtml gone) `shouldContainAll` ["no longer reporting"] - -- The pivot reads the newest datapoint per series, so scanning the picker's whole window -- can only turn up older points it then discards -- while paying to sort every one. Over a -- day that cost the statement timeout, which meant the snapshot never cached and every load diff --git a/web-components/src/main.ts b/web-components/src/main.ts index 8755d0f9e..322e7915c 100644 --- a/web-components/src/main.ts +++ b/web-components/src/main.ts @@ -391,9 +391,12 @@ window.exportTableCsv = (selector, filename) => { if (!table) return; const quote = (value: string) => `"${value.replaceAll('"', '""')}"`; const csv = Array.from(table.querySelectorAll('tr')) + .filter(row => !row.hasAttribute('data-table-group') && + (!row.closest('thead') || row.parentElement === table.tHead) && + !Array.from(row.cells).some(cell => cell.querySelector('table'))) .filter((row) => getComputedStyle(row).display !== 'none') .map((row) => - Array.from(row.querySelectorAll('th,td')) + Array.from(row.cells) .filter((cell) => getComputedStyle(cell).display !== 'none') .map((cell) => quote(cell.innerText.trim().replaceAll(/\s+/g, ' '))) .join(',') diff --git a/web-components/test/table-export.test.ts b/web-components/test/table-export.test.ts index 1577b9673..bbc5d3367 100644 --- a/web-components/test/table-export.test.ts +++ b/web-components/test/table-export.test.ts @@ -18,13 +18,13 @@ beforeEach(() => { afterEach(() => vi.restoreAllMocks()); describe('table CSV export', () => { - test('downloads escaped visible cells with the requested filename', async () => { - document.body.innerHTML = ` - - - - -
HostNoteSecret
api-1ready, "primary"token
hiddenrow
`; + test.each([false, true])('downloads escaped visible cells, grouped=%s', async (grouped) => { + const header = 'HostNoteSecret'; + const rows = `api-1ready, "primary"token + hiddenrow`; + document.body.innerHTML = `${header}${grouped + ? `` + : rows}
Europe: 2 hosts${header}${rows}
`; document.querySelectorAll('th,td').forEach((cell) => (cell.innerText = cell.textContent ?? '')); const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined);