From 77ec0555a9360afd67b7a0f47cd35ca70e6a497f Mon Sep 17 00:00:00 2001 From: Anthony Alaribe Date: Sun, 25 Oct 2026 17:29:35 +0100 Subject: [PATCH 01/10] Share Explorer facets and infrastructure table controls --- AGENTS.md | 7 + e2e/tests/container-identity.spec.ts | 6 +- e2e/tests/infrastructure-time-window.spec.ts | 62 +++++ monoscope.cabal | 2 + src/Pages/Components.hs | 51 +--- src/Pages/Containers.hs | 13 +- src/Pages/Infrastructure.hs | 150 ++++++------ src/Pages/LogExplorer/Log.hs | 194 +++++++--------- src/Pkg/Components/Facets.hs | 115 ++++++++++ src/Pkg/Components/Table.hs | 230 +++++++++++-------- test/integration/Pages/ContainersSpec.hs | 28 ++- web-components/src/main.ts | 51 +++- web-components/src/types/types.ts | 2 + web-components/test/table-export.test.ts | 53 +++++ 14 files changed, 615 insertions(+), 349 deletions(-) create mode 100644 src/Pkg/Components/Facets.hs diff --git a/AGENTS.md b/AGENTS.md index 25c3bcbd2..522af184e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -203,6 +203,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..024c35930 100644 --- a/e2e/tests/container-identity.spec.ts +++ b/e2e/tests/container-identity.spec.ts @@ -259,8 +259,10 @@ test("mobile namespace facet preserves the selected cluster", async ({ page }) = await page.keyboard.press("Enter"); await page.locator('[data-component="facet-section"] summary').filter({ hasText: /^Namespace$/ }).focus(); await page.keyboard.press("Enter"); + await expect(page.getByRole("checkbox", { 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.getByRole("checkbox", { name: "identity-a", exact: true }).focus(); await page.keyboard.press("Space"); const url = new URL((await changed).url()); await expect(rows).toHaveCount(3); @@ -272,7 +274,7 @@ test("mobile namespace facet preserves the selected cluster", async ({ page }) = await page.locator("#containersContainer summary").filter({ hasText: /^Filters$/ }).focus(); await page.keyboard.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.getByRole("checkbox", { name: "identity-b", exact: true }).focus(); await page.keyboard.press("Space"); const replacedUrl = new URL((await replaced).url()); await expect(rows).toHaveCount(1); diff --git a/e2e/tests/infrastructure-time-window.spec.ts b/e2e/tests/infrastructure-time-window.spec.ts index bf07abd29..7fb9a750b 100644 --- a/e2e/tests/infrastructure-time-window.spec.ts +++ b/e2e/tests/infrastructure-time-window.spec.ts @@ -26,6 +26,68 @@ const views = [ ["kubernetes", "kubernetesContainer", { resource: "pods", namespace, cluster }, "Search pods"], ["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); + 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(); + }); +} + +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"); 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..04b35111a 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. diff --git a/src/Pages/Containers.hs b/src/Pages/Containers.hs index 34f02a95f..42de73b9f 100644 --- a/src/Pages/Containers.hs +++ b/src/Pages/Containers.hs @@ -23,7 +23,7 @@ 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 Pkg.Components.Table (Column, Config (..), Features (..), SearchMode (..), Table (..), ZeroState (..), col, countedFilter, facetActions, withAttrs, withColHeaderExtra) import Pkg.Components.TimePicker qualified as TimePicker import Pkg.Components.Widget qualified as Widget import Relude @@ -145,13 +145,10 @@ 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 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} + { config = def{elemID = "containersForm", containerId = Just "containersContainer", addPadding = True, renderAsTable = True} , columns = containerColumns , rows = V.fromList $ map (ContainerVM pid window) rows , features = @@ -160,7 +157,7 @@ containersGetH pid runtimeM namespaceM nodeM imageM clusterM fromParam toParam s , search = Just ClientSide , searchPlaceholder = Just "Search containers" , rowAttrs = Just $ drawerRowAttrs_ . detailUrl - , tableHeaderActions = + , facets = Just $ facetActions (infraUrl pid "/infrastructure/containers" [] window) @@ -173,8 +170,8 @@ 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" + , customizeColumns = True , exportName = Just "containers" , zeroState = Just diff --git a/src/Pages/Infrastructure.hs b/src/Pages/Infrastructure.hs index 591e26be8..8eaf7a943 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 (Config (..), Features (..), Grouping (..), SearchMode (..), Table (..), TableTabs (..), ZeroState (..), col, countedFilter, facetActions, facetsConfig, groupingControl_, 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 @@ -142,16 +142,16 @@ hostGroupOption = \case -- | 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 +groupHosts grouping = groupRows (Just . hostGroupLabel grouping) -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,11 +167,11 @@ 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 = @@ -180,29 +180,30 @@ hostsTable pid window url filters grouping hosts allHosts = , 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 + , 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 + , rowAttrs = Just $ drawerRowAttrs_ . hostDetailUrl pid window . (.name) + , rowGroup = if grouping == GroupNone then Nothing else Just $ Just . hostGroupLabel grouping + , grouping = Just $ Grouping (hostGroupParam grouping) (map hostGroupOption [minBound ..]) url "hostsContainer" + , header = Just $ timeRefreshListener_ "hostsContainer" url , resultSummary = Just $ "Showing " <> show (V.length hosts) <> " of " <> show (V.length allHosts) <> " hosts" + , customizeColumns = True , 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 +216,6 @@ 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 -- 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 +225,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 @@ -411,7 +398,7 @@ 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} + { config = def{elemID = "imagesForm", containerId = Just "imagesContainer", addPadding = True, renderAsTable = True} , 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"] @@ -429,17 +416,17 @@ imagesTable pid window url runtimeM registryM images allImages = , search = Just ClientSide , searchPlaceholder = Just "Search images" , rowAttrs = Just \image -> drawerRowAttrs_ $ infraUrl pid "/infrastructure/images/detail" [("image", image.image)] window - , tableHeaderActions = + , 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" + , customizeColumns = True , exportName = Just "container-images" } } @@ -581,7 +568,7 @@ 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} + { config = def{elemID = "kubernetesForm", containerId = Just "kubernetesContainer", addPadding = True, renderAsTable = True} , 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"] @@ -601,18 +588,19 @@ kubernetesTable pid window url resource clusterM namespaceM statusM rows allRows { search = Just ClientSide , 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 + , header = Just $ timeRefreshListener_ "kubernetesContainer" url + , 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" + , customizeColumns = True , 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") } @@ -717,28 +705,38 @@ hostMap_ page = div_ [id_ "hostMapContainer", class_ "flex min-h-full flex-col b 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) + groupingControl_ $ Grouping (hostGroupParam page.grouping) (map hostGroupOption [minBound ..]) mapUrl "hostMapContainer" + forM_ [(field, value) | (field, Just value) <- [("provider", page.filters.provider), ("region", page.filters.region), ("os", page.filters.osType)]] $ \(field, value) -> input_ [type_ "hidden", name_ field, value_ value] 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 diff --git a/src/Pages/LogExplorer/Log.hs b/src/Pages/LogExplorer/Log.hs index 654716a09..71a2e2518 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,71 @@ 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 + , 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 +989,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 +999,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 +1651,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 = Just "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 = Nothing + , 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..917f35351 --- /dev/null +++ b/src/Pkg/Components/Facets.hs @@ -0,0 +1,115 @@ +module Pkg.Components.Facets (Config (..), Layout (..), FacetNode (..), FacetContent (..), FacetOverflow (..), FacetChoice (..), sidebar_, facetNode_, facetChoice_, facetTail_, facetHeadCount) where + +import Lucid +import Lucid.Hyperscript (__) +import Pages.Components (detailsClosedBelowAttr_, searchInput_) +import Relude +import Utils (LoadingSize (..), LoadingType (..), faSprite_, loadingIndicator_, prettyPrintCount) + + +data Layout = Plain | Collapsible + + +data Config = Config + { elemId :: Maybe 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_ [open_ "", class_ "w-68 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 lg:hidden [&::-webkit-details-marker]:hidden"] "Filters" + rail + where + rail = div_ ([class_ $ "facet-rail flex w-68 shrink-0 flex-col gap-2 text-sm text-textWeak max-md:w-full " <> cfg.extraClass <> case cfg.layout of { Plain -> ""; Collapsible -> " max-lg:w-full" }, data_ "component" "facet-rail"] <> [id_ x | x <- maybeToList 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" + , 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}))}" + ] + forM_ cfg.clear $ \attrs -> button_ ([type_ "button", class_ "flex items-center justify-between rounded px-2 py-1.5 text-xs text-textBrand hover:bg-fillWeak"] <> 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 = + details_ ([class_ $ "facet-section block border-t border-strokeWeak [&[open]>summary_.facet-chevron]:rotate-0 " <> case node.content of { FacetValues{} -> ""; _ -> "facet-section-group" }, 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 " <> case node.content of { FacetValues{} -> "font-normal"; _ -> "font-medium" }] <> [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 -> values_ choices overflow + where + values_ 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 + , 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_ "checkbox", class_ "checkbox checkbox-xs max-md:checkbox-sm"] <> 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..86689e45d 100644 --- a/src/Pkg/Components/Table.hs +++ b/src/Pkg/Components/Table.hs @@ -5,6 +5,11 @@ module Pkg.Components.Table ( Column (..), Config (..), Features (..), + Grouping (..), + TableTabs (..), + groupingControl_, + facetsConfig, + countedFilter, defaultFeaturesAreInert, SearchMode (..), BulkAction (..), @@ -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_) +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 @@ -121,9 +128,12 @@ 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 Grouping + , 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 @@ -135,8 +145,9 @@ data Features a = Features -- $setup --- >>> :set -XTypeApplications +-- >>> :set -XTypeApplications -XOverloadedRecordDot -XOverloadedStrings -- >>> import Data.Default (def) +-- >>> import Data.Vector qualified as V -- | The derived 'Default' for 'Features' must stay inert: no row link, no bulk @@ -147,13 +158,16 @@ data Features a = Features -- True defaultFeaturesAreInert :: Bool defaultFeaturesAreInert = - and + and @[] [ isNothing d.rowLink , isNothing d.search , isNothing d.pagination , isNothing d.rowGroup , null d.bulkActions - , not d.showFilterRail + , isNothing d.facets + , not d.customizeColumns + , isNothing d.grouping + , isNothing d.tabs ] where d = def @(Features ()) @@ -183,6 +197,20 @@ data Config = Config } +data Grouping = Grouping + { 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 +308,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 +485,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 +506,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 +548,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,14 +571,26 @@ renderRows tbl -- so htmx-swapped pages stay markup-identical to page 1. renderBody :: [Attribute] -> Table a -> Html () renderBody extra tbl + | tbl.config.renderAsTable + , Just groupOf <- tbl.features.rowGroup + , isJust tbl.features.grouping = + iforM_ (groups groupOf) $ \idx ((_, row) :| siblings) -> + 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"] do + toHtml label + span_ [class_ "ms-2 text-xs font-normal text-textWeak tabular-nums"] $ toHtml $ show (1 + length siblings) <> " items" + mapM_ (renderTableRow tbl) (row : map snd siblings) | 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 where + 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 = - forM_ (sortWith (fst . head) $ NE.groupAllWith (\(idx, row) -> maybeToRight idx $ groupOf row) $ zip [0 :: Int ..] (V.toList tbl.rows)) \((_, row) :| siblings) -> + forM_ (groups groupOf) $ \((_, 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)] + $ 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]"] do toHtml label @@ -565,7 +598,7 @@ renderBody extra tbl 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 + forM_ tbl.columns $ \c -> th_ [scope_ "col", data_ "column-key" c.name] $ toHtml c.name tbody_ $ mapM_ (renderTableRow tbl) (row : map snd siblings) _ -> renderTableRow tbl row @@ -608,7 +641,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 +671,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 Nothing "" (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 +745,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, 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_ $ "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 +808,35 @@ 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 groupingControl_ + 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" + div_ (popoverPanel_ (tbl.config.elemID <> "-columns") <> [data_ "column-picker" "", 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_ 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, onchange_ "window.setTableColumn(this)"] + toHtml column.name + button_ [type_ "button", class_ "btn btn-xs btn-ghost mt-1 w-full font-normal", onclick_ "window.resetTableColumns(this)"] "Reset columns" + + +groupingControl_ :: Grouping -> Html () +groupingControl_ cfg = label_ [class_ "flex items-center gap-2 whitespace-nowrap text-xs text-textWeak"] do + "Group by" + select_ ([name_ "group", 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 "group" cfg.baseUrl)) + $ forM_ cfg.options + $ \(value, label) -> option_ ([value_ value] <> [selected_ "" | value == cfg.current]) $ toHtml label renderPaginationFooter :: Pagination -> Html () diff --git a/test/integration/Pages/ContainersSpec.hs b/test/integration/Pages/ContainersSpec.hs index c43ca7b91..94827b5e7 100644 --- a/test/integration/Pages/ContainersSpec.hs +++ b/test/integration/Pages/ContainersSpec.hs @@ -16,7 +16,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 @@ -320,12 +320,12 @@ spec = sequential $ aroundAll withTfChartResources do 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\""] @@ -371,6 +371,26 @@ spec = sequential $ aroundAll withTfChartResources do , "Latest cumulative restart count reported within the selected time range. This is not the number of restarts during the range." ] + it "tableControls_customizeAndExportCanBeEnabledIndependently" \tr -> do + (_, Containers.ContainersPage (PageCtx _ body)) <- testServant tr $ containersPage noContainerFilters + table <- deferredBody body + let render features = LT.toStrict $ 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} + customizeOnly `shouldContainAll` ["Customize", "Reset columns", "data-column-key=", "data-column-toggle="] + customizeOnly `shouldNotSatisfy` T.isInfixOf ">Export" + exportOnly `shouldContainAll` ["Export", "containers.csv"] + exportOnly `shouldNotSatisfy` T.isInfixOf "Customize" + + it "hostGrouping_sharedTableKeepsSingletonGroupsAndRealRows" \tr -> do + (_, Infrastructure.HostsPage (PageCtx _ body)) <- testServant tr $ Infrastructure.hostsGetH testPid Nothing Nothing Nothing Nothing (Just "integration") Nothing Nothing (Just "1H") (Just "1") + table <- deferredBody body + let html = LT.toStrict $ Lucid.renderText $ Lucid.toHtml table + V.length table.rows `shouldBe` 3 + html `shouldContainAll` ["data-table-group=\"Docker\"", "data-table-group=\"Kubernetes\"", "data-table-group=\"OpenTelemetry\"", "data-hx-trigger=\"change\"", "since=1H", "name=\"group\"", "data-column-key=\"CPU\""] + T.count "role=\"button\"" html `shouldBe` 3 + T.count " do (_, hosts@(Infrastructure.HostsPage (PageCtx hostsBW _))) <- testServant tr $ Infrastructure.hostsGetH testPid Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing (Just "1") V.null hostsBW.serviceOptions `shouldBe` True @@ -399,7 +419,7 @@ spec = sequential $ aroundAll withTfChartResources do (_, 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 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", "onchange=\"this.form.requestSubmit()\"", "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 diff --git a/web-components/src/main.ts b/web-components/src/main.ts index 8755d0f9e..bc413320e 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(',') @@ -405,6 +408,52 @@ window.exportTableCsv = (selector, filename) => { URL.revokeObjectURL(url); }; +function tableColumnState(table: HTMLElement): Record { + try { + const state: unknown = JSON.parse(localStorage.getItem(`table-columns:${table.dataset.tableColumns}`) ?? '{}'); + return state && typeof state === 'object' && !Array.isArray(state) + ? Object.fromEntries(Object.entries(state).filter(([, value]) => typeof value === 'boolean')) + : {}; + } catch { return {}; } +} + +function restoreTableColumns() { + document.querySelectorAll('[data-table-columns]').forEach(table => { + const state = tableColumnState(table); + const cells = Array.from(table.querySelectorAll('[data-column-key]')); + cells.forEach(cell => { + const visible = state[cell.dataset.columnKey!]; + cell.style.display = visible === undefined ? '' : visible ? 'table-cell' : 'none'; + }); + table.querySelectorAll('[data-column-toggle]').forEach(input => { + const cell = cells.find(cell => cell.dataset.columnKey === input.dataset.columnToggle); + input.checked = state[input.dataset.columnToggle!] ?? (!cell || getComputedStyle(cell).display !== 'none'); + }); + }); +} + +window.setTableColumn = input => { + const table = input.closest('[data-table-columns]')!; + const state = { ...tableColumnState(table), [input.dataset.columnToggle!]: input.checked }; + try { localStorage.setItem(`table-columns:${table.dataset.tableColumns}`, JSON.stringify(state)); } catch { /* Storage can be disabled. */ } + table.querySelectorAll('[data-column-key]').forEach(cell => { + if (cell.dataset.columnKey === input.dataset.columnToggle) cell.style.display = input.checked ? 'table-cell' : 'none'; + }); +}; + +window.resetTableColumns = button => { + const table = button.closest('[data-table-columns]')!; + try { localStorage.removeItem(`table-columns:${table.dataset.tableColumns}`); } catch { /* Storage can be disabled. */ } + restoreTableColumns(); +}; + +restoreTableColumns(); +document.addEventListener('DOMContentLoaded', restoreTableColumns); +document.addEventListener('htmx:after:swap', restoreTableColumns); +document.addEventListener('beforetoggle', event => { + if (event.target instanceof Element && event.target.hasAttribute('data-column-picker')) restoreTableColumns(); +}, true); + function updateUrlState(key: string | string[], value: string, action: 'set' | 'delete' = 'set') { const params = new URLSearchParams(window.location.search); for (const k of Array.isArray(key) ? key : [key]) { diff --git a/web-components/src/types/types.ts b/web-components/src/types/types.ts index e5334007a..74b513c11 100644 --- a/web-components/src/types/types.ts +++ b/web-components/src/types/types.ts @@ -15,6 +15,8 @@ declare global { toggleLiveData: (liveData: HTMLElement | null, transport: HTMLElement | null) => void; shiftTimeRange: (direction: -1 | 1, transport?: HTMLElement | null) => void; exportTableCsv: (selector: string, filename: string) => void; + setTableColumn: (input: HTMLInputElement) => void; + resetTableColumns: (button: HTMLButtonElement) => void; evalScriptsFromContent: (container: HTMLElement) => void; params: () => Record; setQueryParamAndReload: (key: string, value: string) => void; diff --git a/web-components/test/table-export.test.ts b/web-components/test/table-export.test.ts index 1577b9673..c111a8b4a 100644 --- a/web-components/test/table-export.test.ts +++ b/web-components/test/table-export.test.ts @@ -5,6 +5,7 @@ let exported: Blob; beforeEach(() => { document.body.innerHTML = ''; + localStorage.clear(); Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn((blob: Blob) => { @@ -15,6 +16,42 @@ beforeEach(() => { Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() }); }); +test('column choices survive swaps and column insertion, stay scoped, and reset', () => { + const markup = (extra = '') => `
+ + ${extra} + ${extra}
CPU
25%
+
50%
`; + document.body.innerHTML = markup(); + const input = document.querySelector('input')!; + input.checked = false; + window.setTableColumn(input); + document.body.innerHTML = markup('Europe'); + document.dispatchEvent(new CustomEvent('htmx:after:swap')); + + const hosts = document.querySelector('[data-table-columns="hostsForm"]')!; + expect(Array.from(hosts.querySelectorAll('[data-column-key="CPU"]')).every(cell => cell.style.display === 'none')).toBe(true); + expect(hosts.querySelector('input')!.checked).toBe(false); + expect(document.querySelector('[data-table-columns="imagesForm"] td')!.style.display).toBe(''); + expect(hosts.querySelector('[data-column-key="Configuration"]')!.style.display).toBe(''); + window.resetTableColumns(hosts.querySelector('button')!); + expect(hosts.querySelector('th')!.style.display).toBe(''); + expect(hosts.querySelector('input')!.checked).toBe(true); + expect(localStorage.getItem('table-columns:hostsForm')).toBeNull(); +}); + +test('Customize reflects responsive visibility and can reveal a hidden column', () => { + document.body.innerHTML = ` +
+
Linux
`; + document.dispatchEvent(new CustomEvent('htmx:after:swap')); + const input = document.querySelector('input')!; + expect(input.checked).toBe(false); + input.checked = true; + window.setTableColumn(input); + expect(getComputedStyle(document.querySelector('td')!).display).toBe('table-cell'); +}); + afterEach(() => vi.restoreAllMocks()); describe('table CSV export', () => { @@ -44,4 +81,20 @@ describe('table CSV export', () => { expect(click).not.toHaveBeenCalled(); expect(URL.createObjectURL).not.toHaveBeenCalled(); }); + + test('grouped exports contain one header and only visible data rows', async () => { + document.body.innerHTML = ` + +
HostCPU
Europe: 2 hosts + + + +
HostCPU
api-125%
`; + document.querySelectorAll('th,td').forEach(cell => cell.innerText = cell.textContent ?? ''); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined); + + window.exportTableCsv('#inventory', 'hosts.csv'); + + expect(await exported.text()).toBe('"Host","CPU"\n"api-1","25%"'); + }); }); From 06a2d55af080fa0ffbbdbcb5a379199e741cd307 Mon Sep 17 00:00:00 2001 From: Anthony Alaribe Date: Sun, 25 Oct 2026 18:09:27 +0100 Subject: [PATCH 02/10] Verify shared controls across namespaces and responsive layouts --- e2e/tests/container-identity.spec.ts | 4 +- e2e/tests/infrastructure-time-window.spec.ts | 19 +++++-- src/Pages/Components.hs | 2 +- src/Pages/Containers.hs | 6 +-- src/Pages/Infrastructure.hs | 21 +++----- src/Pkg/Components/Facets.hs | 7 +-- src/Pkg/Components/Table.hs | 56 +++++++++++++++----- test/integration/Pages/ContainersSpec.hs | 4 +- web-components/src/main.ts | 46 ---------------- web-components/src/types/types.ts | 2 - web-components/test/table-export.test.ts | 37 ------------- 11 files changed, 77 insertions(+), 127 deletions(-) diff --git a/e2e/tests/container-identity.spec.ts b/e2e/tests/container-identity.spec.ts index 024c35930..d16a41283 100644 --- a/e2e/tests/container-identity.spec.ts +++ b/e2e/tests/container-identity.spec.ts @@ -148,7 +148,7 @@ test("changing the container time preset refreshes inventory and drawer windows" } }); -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 +163,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); }); } diff --git a/e2e/tests/infrastructure-time-window.spec.ts b/e2e/tests/infrastructure-time-window.spec.ts index 7fb9a750b..8f10ae003 100644 --- a/e2e/tests/infrastructure-time-window.spec.ts +++ b/e2e/tests/infrastructure-time-window.spec.ts @@ -67,6 +67,19 @@ for (const width of [390, 1440]) { 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 container.getByRole('button', { name: 'Customize', exact: true }).click(); + await container.getByRole('button', { name: 'Customize', exact: true }).click(); + await expect(system).toBeChecked(); + } }); } @@ -95,7 +108,7 @@ 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); await page.locator('[popovertarget="n-timepicker-popover"]').click(); await page.locator("[data-mobile-live-toggle]").click(); @@ -169,7 +182,7 @@ 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(); } @@ -184,7 +197,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/src/Pages/Components.hs b/src/Pages/Components.hs index 04b35111a..85c507043 100644 --- a/src/Pages/Components.hs +++ b/src/Pages/Components.hs @@ -1067,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 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 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 42de73b9f..8d8ebfa22 100644 --- a/src/Pages/Containers.hs +++ b/src/Pages/Containers.hs @@ -188,8 +188,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"] @@ -245,7 +245,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 8eaf7a943..134103aab 100644 --- a/src/Pages/Infrastructure.hs +++ b/src/Pages/Infrastructure.hs @@ -33,7 +33,7 @@ import Pages.Containers (deferUrl, infraSnapshot, infraUrl, infrastructureBW, ma import Pages.Containers qualified as Containers import Pages.LogExplorer.Log qualified as Log import Pkg.Components.Facets qualified as Facets -import Pkg.Components.Table (Config (..), Features (..), Grouping (..), SearchMode (..), Table (..), TableTabs (..), ZeroState (..), col, countedFilter, facetActions, facetsConfig, groupingControl_, withAttrs) +import Pkg.Components.Table (Config (..), Features (..), SearchMode (..), SelectControl (..), Table (..), TableTabs (..), ZeroState (..), col, countedFilter, facetActions, facetsConfig, selectControl_, withAttrs) import Pkg.Components.TimePicker qualified as TimePicker import Pkg.Components.Widget qualified as Widget import Pkg.DeriveUtils (WrappedEnumSC (..)) @@ -190,7 +190,7 @@ hostsTable pid window url filters grouping hosts allHosts = , searchPlaceholder = Just "Search hosts" , rowAttrs = Just $ drawerRowAttrs_ . hostDetailUrl pid window . (.name) , rowGroup = if grouping == GroupNone then Nothing else Just $ Just . hostGroupLabel grouping - , grouping = Just $ Grouping (hostGroupParam grouping) (map hostGroupOption [minBound ..]) url "hostsContainer" + , grouping = Just $ SelectControl "group" "Group by" (hostGroupParam grouping) (map hostGroupOption [minBound ..]) url "hostsContainer" , header = Just $ timeRefreshListener_ "hostsContainer" url , resultSummary = Just $ "Showing " <> show (V.length hosts) <> " of " <> show (V.length allHosts) <> " hosts" , customizeColumns = True @@ -555,8 +555,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 @@ -702,11 +703,9 @@ 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 ..]] - groupingControl_ $ Grouping (hostGroupParam page.grouping) (map hostGroupOption [minBound ..]) mapUrl "hostMapContainer" - forM_ [(field, value) | (field, Just value) <- [("provider", page.filters.provider), ("region", page.filters.region), ("os", page.filters.osType)]] $ \(field, value) -> input_ [type_ "hidden", name_ field, value_ value] + 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%" @@ -738,10 +737,6 @@ hostMap_ page = div_ [id_ "hostMapContainer", class_ "flex min-h-full flex-col b 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/Pkg/Components/Facets.hs b/src/Pkg/Components/Facets.hs index 917f35351..b1411cf29 100644 --- a/src/Pkg/Components/Facets.hs +++ b/src/Pkg/Components/Facets.hs @@ -2,7 +2,7 @@ module Pkg.Components.Facets (Config (..), Layout (..), FacetNode (..), FacetCon import Lucid import Lucid.Hyperscript (__) -import Pages.Components (detailsClosedBelowAttr_, searchInput_) +import Pages.Components (detailsClosedBelowAttr_, filterInputAttr_, searchInput_) import Relude import Utils (LoadingSize (..), LoadingType (..), faSprite_, loadingIndicator_, prettyPrintCount) @@ -31,10 +31,7 @@ sidebar_ cfg = case cfg.layout of searchInput_ "sticky top-0 z-10 h-9 w-[calc(100%-1rem)] mx-2 border-strokeStrong bg-bgBase" "Search filters" - [ 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}))}" - ] + [name_ "facet-search", filterInputAttr_ ".facet-rail .facet-item, .facet-rail .facet-section"] forM_ cfg.clear $ \attrs -> button_ ([type_ "button", class_ "flex items-center justify-between rounded px-2 py-1.5 text-xs text-textBrand hover:bg-fillWeak"] <> 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 diff --git a/src/Pkg/Components/Table.hs b/src/Pkg/Components/Table.hs index 86689e45d..89894d827 100644 --- a/src/Pkg/Components/Table.hs +++ b/src/Pkg/Components/Table.hs @@ -5,9 +5,9 @@ module Pkg.Components.Table ( Column (..), Config (..), Features (..), - Grouping (..), + SelectControl (..), TableTabs (..), - groupingControl_, + selectControl_, facetsConfig, countedFilter, defaultFeaturesAreInert, @@ -132,7 +132,7 @@ data Features a = Features , resultSummary :: Maybe Text , exportName :: Maybe Text , customizeColumns :: Bool - , grouping :: Maybe Grouping + , grouping :: Maybe SelectControl , tabs :: Maybe TableTabs } -- Every field's default is the generic one (Nothing / [] / False). The @@ -197,8 +197,10 @@ data Config = Config } -data Grouping = Grouping - { current :: Text +data SelectControl = SelectControl + { name :: Text + , label :: Text + , current :: Text , options :: [(Text, Text)] -- (value, label) , baseUrl :: Text , targetId :: Text @@ -810,7 +812,7 @@ renderResultToolbar tbl = div_ [class_ "flex min-h-9 flex-wrap items-center just whenJust tbl.features.resultSummary $ \summary -> span_ [class_ "text-sm text-textWeak", role_ "status", Aria.live_ "polite"] $ toHtml summary 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 groupingControl_ + forM_ tbl.features.grouping selectControl_ forM_ tbl.features.exportName $ \name -> button_ [ type_ "button" @@ -823,18 +825,46 @@ renderResultToolbar tbl = div_ [class_ "flex min-h-9 flex-wrap items-center just 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") <> [data_ "column-picker" "", class_ "dropdown dropdown-end z-50 mt-1 w-56 rounded-md border border-strokeWeak bg-bgRaised p-2 text-sm shadow-lg"]) do + -- 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|]]) 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, onchange_ "window.setTableColumn(this)"] + input_ + [ type_ "checkbox" + , checked_ + , class_ "checkbox checkbox-xs" + , data_ "column-toggle" column.name + , data_ "column-storage" $ "table-columns:" <> tbl.config.elemID <> ":" <> column.name + , [__|init send syncColumn to me end + on syncColumn + set saved to localStorage.getItem(@data-column-storage) + if saved is null + remove @data-forced then set my.checked to true + 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 + else + set @data-forced to 'true' then set my.checked to saved is 'true' + 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", onclick_ "window.resetTableColumns(this)"] "Reset columns" + 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 send syncColumn to t end|] + ] + "Reset columns" -groupingControl_ :: Grouping -> Html () -groupingControl_ cfg = label_ [class_ "flex items-center gap-2 whitespace-nowrap text-xs text-textWeak"] do - "Group by" - select_ ([name_ "group", 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 "group" cfg.baseUrl)) +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 diff --git a/test/integration/Pages/ContainersSpec.hs b/test/integration/Pages/ContainersSpec.hs index 94827b5e7..04c7d6395 100644 --- a/test/integration/Pages/ContainersSpec.hs +++ b/test/integration/Pages/ContainersSpec.hs @@ -377,7 +377,7 @@ spec = sequential $ aroundAll withTfChartResources do let render features = LT.toStrict $ 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} - customizeOnly `shouldContainAll` ["Customize", "Reset columns", "data-column-key=", "data-column-toggle="] + 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" @@ -419,7 +419,7 @@ spec = sequential $ aroundAll withTfChartResources do (_, 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 hostMapHtml - `shouldContainAll` ["Host Map", "Fill by", "Group by", "vps-bare-01", "clip-path:polygon", "bg-fillNeutral-strong", "onchange=\"this.form.requestSubmit()\"", "data-component=\"facet-rail\"", "Provider", "Region", "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 diff --git a/web-components/src/main.ts b/web-components/src/main.ts index bc413320e..322e7915c 100644 --- a/web-components/src/main.ts +++ b/web-components/src/main.ts @@ -408,52 +408,6 @@ window.exportTableCsv = (selector, filename) => { URL.revokeObjectURL(url); }; -function tableColumnState(table: HTMLElement): Record { - try { - const state: unknown = JSON.parse(localStorage.getItem(`table-columns:${table.dataset.tableColumns}`) ?? '{}'); - return state && typeof state === 'object' && !Array.isArray(state) - ? Object.fromEntries(Object.entries(state).filter(([, value]) => typeof value === 'boolean')) - : {}; - } catch { return {}; } -} - -function restoreTableColumns() { - document.querySelectorAll('[data-table-columns]').forEach(table => { - const state = tableColumnState(table); - const cells = Array.from(table.querySelectorAll('[data-column-key]')); - cells.forEach(cell => { - const visible = state[cell.dataset.columnKey!]; - cell.style.display = visible === undefined ? '' : visible ? 'table-cell' : 'none'; - }); - table.querySelectorAll('[data-column-toggle]').forEach(input => { - const cell = cells.find(cell => cell.dataset.columnKey === input.dataset.columnToggle); - input.checked = state[input.dataset.columnToggle!] ?? (!cell || getComputedStyle(cell).display !== 'none'); - }); - }); -} - -window.setTableColumn = input => { - const table = input.closest('[data-table-columns]')!; - const state = { ...tableColumnState(table), [input.dataset.columnToggle!]: input.checked }; - try { localStorage.setItem(`table-columns:${table.dataset.tableColumns}`, JSON.stringify(state)); } catch { /* Storage can be disabled. */ } - table.querySelectorAll('[data-column-key]').forEach(cell => { - if (cell.dataset.columnKey === input.dataset.columnToggle) cell.style.display = input.checked ? 'table-cell' : 'none'; - }); -}; - -window.resetTableColumns = button => { - const table = button.closest('[data-table-columns]')!; - try { localStorage.removeItem(`table-columns:${table.dataset.tableColumns}`); } catch { /* Storage can be disabled. */ } - restoreTableColumns(); -}; - -restoreTableColumns(); -document.addEventListener('DOMContentLoaded', restoreTableColumns); -document.addEventListener('htmx:after:swap', restoreTableColumns); -document.addEventListener('beforetoggle', event => { - if (event.target instanceof Element && event.target.hasAttribute('data-column-picker')) restoreTableColumns(); -}, true); - function updateUrlState(key: string | string[], value: string, action: 'set' | 'delete' = 'set') { const params = new URLSearchParams(window.location.search); for (const k of Array.isArray(key) ? key : [key]) { diff --git a/web-components/src/types/types.ts b/web-components/src/types/types.ts index 74b513c11..e5334007a 100644 --- a/web-components/src/types/types.ts +++ b/web-components/src/types/types.ts @@ -15,8 +15,6 @@ declare global { toggleLiveData: (liveData: HTMLElement | null, transport: HTMLElement | null) => void; shiftTimeRange: (direction: -1 | 1, transport?: HTMLElement | null) => void; exportTableCsv: (selector: string, filename: string) => void; - setTableColumn: (input: HTMLInputElement) => void; - resetTableColumns: (button: HTMLButtonElement) => void; evalScriptsFromContent: (container: HTMLElement) => void; params: () => Record; setQueryParamAndReload: (key: string, value: string) => void; diff --git a/web-components/test/table-export.test.ts b/web-components/test/table-export.test.ts index c111a8b4a..66827883a 100644 --- a/web-components/test/table-export.test.ts +++ b/web-components/test/table-export.test.ts @@ -5,7 +5,6 @@ let exported: Blob; beforeEach(() => { document.body.innerHTML = ''; - localStorage.clear(); Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn((blob: Blob) => { @@ -16,42 +15,6 @@ beforeEach(() => { Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() }); }); -test('column choices survive swaps and column insertion, stay scoped, and reset', () => { - const markup = (extra = '') => `
- - ${extra} - ${extra}
CPU
25%
-
50%
`; - document.body.innerHTML = markup(); - const input = document.querySelector('input')!; - input.checked = false; - window.setTableColumn(input); - document.body.innerHTML = markup('Europe'); - document.dispatchEvent(new CustomEvent('htmx:after:swap')); - - const hosts = document.querySelector('[data-table-columns="hostsForm"]')!; - expect(Array.from(hosts.querySelectorAll('[data-column-key="CPU"]')).every(cell => cell.style.display === 'none')).toBe(true); - expect(hosts.querySelector('input')!.checked).toBe(false); - expect(document.querySelector('[data-table-columns="imagesForm"] td')!.style.display).toBe(''); - expect(hosts.querySelector('[data-column-key="Configuration"]')!.style.display).toBe(''); - window.resetTableColumns(hosts.querySelector('button')!); - expect(hosts.querySelector('th')!.style.display).toBe(''); - expect(hosts.querySelector('input')!.checked).toBe(true); - expect(localStorage.getItem('table-columns:hostsForm')).toBeNull(); -}); - -test('Customize reflects responsive visibility and can reveal a hidden column', () => { - document.body.innerHTML = ` -
-
Linux
`; - document.dispatchEvent(new CustomEvent('htmx:after:swap')); - const input = document.querySelector('input')!; - expect(input.checked).toBe(false); - input.checked = true; - window.setTableColumn(input); - expect(getComputedStyle(document.querySelector('td')!).display).toBe('table-cell'); -}); - afterEach(() => vi.restoreAllMocks()); describe('table CSV export', () => { From 2fdb43869cb7a15b850e00bf248d6b743ea33243 Mon Sep 17 00:00:00 2001 From: Anthony Alaribe Date: Sun, 25 Oct 2026 18:25:15 +0100 Subject: [PATCH 03/10] Remove orphaned time inputs and await browser state --- e2e/tests/dashboard-add-widget.spec.ts | 1 + e2e/tests/repositories.spec.ts | 2 +- src/Pkg/Components/TimePicker.hs | 6 ------ 3 files changed, 2 insertions(+), 7 deletions(-) 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/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/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") From 6a474606844c9c9635d7e35516dce2ec54663bf4 Mon Sep 17 00:00:00 2001 From: Anthony Alaribe Date: Mon, 26 Oct 2026 00:08:05 +0100 Subject: [PATCH 04/10] Classify facet content exhaustively --- src/Pkg/Components/Facets.hs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Pkg/Components/Facets.hs b/src/Pkg/Components/Facets.hs index b1411cf29..a345d7caf 100644 --- a/src/Pkg/Components/Facets.hs +++ b/src/Pkg/Components/Facets.hs @@ -53,12 +53,13 @@ data FacetContent facetNode_ :: FacetNode -> Html () -facetNode_ node = - details_ ([class_ $ "facet-section block border-t border-strokeWeak [&[open]>summary_.facet-chevron]:rotate-0 " <> case node.content of { FacetValues{} -> ""; _ -> "facet-section-group" }, data_ "component" "facet-section"] <> node.attrs <> [open_ "" | node.expanded]) do +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 " <> case node.content of { FacetValues{} -> "font-normal"; _ -> "font-medium" }] <> [term "data-tippy-content" p | p <- maybeToList node.path]) $ toHtml node.label + 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 From c283198ae51bce9b8a236d5f9f105f84139a5f90 Mon Sep 17 00:00:00 2001 From: Anthony Alaribe Date: Mon, 26 Oct 2026 10:18:11 +0100 Subject: [PATCH 05/10] Consolidate inventory defaults and overlapping regression scenarios --- e2e/tests/container-identity.spec.ts | 186 ++++------------- e2e/tests/infrastructure-time-window.spec.ts | 13 +- src/Pages/Containers.hs | 19 +- src/Pages/Infrastructure.hs | 59 ++---- src/Pkg/Components/Table.hs | 49 ++--- test/integration/Pages/ContainersSpec.hs | 206 ++++++------------- web-components/test/table-export.test.ts | 30 +-- 7 files changed, 162 insertions(+), 400 deletions(-) diff --git a/e2e/tests/container-identity.spec.ts b/e2e/tests/container-identity.spec.ts index d16a41283..d42c9ddba 100644 --- a/e2e/tests/container-identity.spec.ts +++ b/e2e/tests/container-identity.spec.ts @@ -61,93 +61,6 @@ 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, 1920]) { test(`duplicate containers expose their identity before opening a drawer at ${width}px`, async ({ page }) => { await page.setViewportSize({ width, height: 900 }); @@ -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,15 +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.keyboard.press("Enter"); + 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("checkbox", { 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("checkbox", { name: "identity-a", exact: true }).focus(); - await page.keyboard.press("Space"); + await page.getByRole("checkbox", { 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]); @@ -271,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("checkbox", { name: "identity-b", exact: true }).focus(); - await page.keyboard.press("Space"); + await page.getByRole("checkbox", { 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); @@ -303,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); @@ -321,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/infrastructure-time-window.spec.ts b/e2e/tests/infrastructure-time-window.spec.ts index 8f10ae003..ec3652bb3 100644 --- a/e2e/tests/infrastructure-time-window.spec.ts +++ b/e2e/tests/infrastructure-time-window.spec.ts @@ -24,6 +24,7 @@ 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; @@ -110,6 +111,7 @@ for (const [route, container, filters, searchName] of views) { await expect(page.locator("[data-deferred-shell]")).toHaveCount(0, { timeout: 5_000 }); 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"); @@ -118,6 +120,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([""]); @@ -161,6 +164,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"); @@ -189,7 +200,7 @@ for (const [route, container, filters, searchName] of views) { }); } -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 = () => {}; diff --git a/src/Pages/Containers.hs b/src/Pages/Containers.hs index 8d8ebfa22..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, countedFilter, facetActions, 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 @@ -146,17 +146,14 @@ containersGetH pid runtimeM namespaceM nodeM imageM clusterM fromParam toParam s -- 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 = countedFilter label param selected (maybeToList . fromRow) allRows + let table = inventoryTable "containers" deferredUrl pure - Table - { config = def{elemID = "containersForm", containerId = Just "containersContainer", addPadding = True, renderAsTable = True} - , 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 + table.features + { rowAttrs = Just $ drawerRowAttrs_ . detailUrl , facets = Just $ facetActions @@ -171,8 +168,6 @@ containersGetH pid runtimeM namespaceM nodeM imageM clusterM fromParam toParam s , menu "Image" "image" filters.image (.image) ] , resultSummary = Just $ "Showing " <> show (length rows) <> " of " <> show (V.length allRows) <> " containers reporting in the final " <> freshnessLabel window <> " of this range" - , customizeColumns = True - , exportName = Just "containers" , zeroState = Just ZeroState diff --git a/src/Pages/Infrastructure.hs b/src/Pages/Infrastructure.hs index 134103aab..bb4082410 100644 --- a/src/Pages/Infrastructure.hs +++ b/src/Pages/Infrastructure.hs @@ -33,7 +33,7 @@ import Pages.Containers (deferUrl, infraSnapshot, infraUrl, infrastructureBW, ma import Pages.Containers qualified as Containers import Pages.LogExplorer.Log qualified as Log import Pkg.Components.Facets qualified as Facets -import Pkg.Components.Table (Config (..), Features (..), SearchMode (..), SelectControl (..), Table (..), TableTabs (..), ZeroState (..), col, countedFilter, facetActions, facetsConfig, selectControl_, withAttrs) +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 (..)) @@ -139,12 +139,6 @@ 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 (Just . hostGroupLabel grouping) - - hostGroupLabel :: HostGroup -> HostRow -> Text hostGroupLabel grouping host = case grouping of GroupNone -> "All hosts" @@ -173,28 +167,15 @@ newtype HostsGet = HostsPage (PageCtx (Deferred (Table HostRow))) 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 - , containerClasses = "w-full mx-auto space-y-2 group/columns" - } - , columns = hostColumns + 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 $ drawerRowAttrs_ . hostDetailUrl pid window . (.name) + 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" - , header = Just $ timeRefreshListener_ "hostsContainer" url , resultSummary = Just $ "Showing " <> show (V.length hosts) <> " of " <> show (V.length allHosts) <> " hosts" - , customizeColumns = True - , exportName = Just "hosts" , facets = Just $ facetActions @@ -216,6 +197,7 @@ hostsTable pid window url filters grouping hosts allHosts = } } where + 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 = @@ -397,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} - , 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"] @@ -411,11 +392,8 @@ 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 + table.features + { rowAttrs = Just \image -> drawerRowAttrs_ $ infraUrl pid "/infrastructure/images/detail" [("image", image.image)] window , facets = Just $ facetActions @@ -426,11 +404,11 @@ imagesTable pid window url runtimeM registryM images 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") , resultSummary = Just $ "Showing " <> show (V.length images) <> " of " <> show (V.length allImages) <> " images" - , customizeColumns = True , 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,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} - , 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"] @@ -585,14 +562,11 @@ 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 , 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" - , customizeColumns = True , exportName = Just $ "kubernetes-" <> display resource , facets = Just @@ -607,6 +581,7 @@ kubernetesTable pid window url resource clusterM namespaceM statusM rows allRows } } 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 @@ -691,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 diff --git a/src/Pkg/Components/Table.hs b/src/Pkg/Components/Table.hs index 89894d827..32992d6a6 100644 --- a/src/Pkg/Components/Table.hs +++ b/src/Pkg/Components/Table.hs @@ -1,5 +1,6 @@ module Pkg.Components.Table ( Table (..), + inventoryTable, TableRows (..), TableResponse (..), Column (..), @@ -10,7 +11,6 @@ module Pkg.Components.Table ( selectControl_, facetsConfig, countedFilter, - defaultFeaturesAreInert, SearchMode (..), BulkAction (..), TabFilter (..), @@ -53,7 +53,7 @@ import Lucid.Aria qualified as Aria import Lucid.Htmx import Lucid.Hyperscript (__) import Pages.BodyWrapper (PageCtx) -import Pages.Components (EmptyStateAction (..), EmptyStateCfg (..), emptyState_, 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 @@ -84,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 @@ -111,6 +123,11 @@ data Pagination = Pagination data SearchMode = ClientSide | ServerSide Text +-- | Features default to no optional behavior. +-- +-- >>> let f = def @(Features ()) +-- >>> and @[] [isNothing f.rowLink, isNothing f.search, isNothing f.pagination, isNothing f.rowGroup, null f.bulkActions, isNothing f.facets, not f.customizeColumns, isNothing f.grouping, isNothing f.tabs] +-- True data Features a = Features { rowLink :: Maybe (a -> Text) , rowId :: Maybe (a -> Text) @@ -135,11 +152,6 @@ data Features a = Features , 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) @@ -150,29 +162,6 @@ data Features a = Features -- >>> import Data.Vector qualified as V --- | 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 - , isNothing d.facets - , not d.customizeColumns - , isNothing d.grouping - , isNothing d.tabs - ] - where - d = def @(Features ()) - - data Config = Config { tableClasses :: Text , thClasses :: Text diff --git a/test/integration/Pages/ContainersSpec.hs b/test/integration/Pages/ContainersSpec.hs index 04c7d6395..d5ec3d3b1 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 @@ -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,7 +274,7 @@ 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 @@ -333,29 +292,6 @@ 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"] - -- Each facet narrows independently, and an unknown value yields an empty list rather - -- than silently falling back to "all". - let listWith f = do - (_, p) <- testServant tr f - let Containers.ContainersPage (PageCtx _ b) = p - t <- deferredBody b - pure $ V.toList $ V.map (\vm -> vm.row.containerName) t.rows - listWith (containersPage noContainerFilters{namespace = Just "default"}) >>= (`shouldBe` ["checkout"]) - listWith (containersPage noContainerFilters{runtime = Just "docker"}) >>= (`shouldMatchList` ["srv-captain--api.2.wcxx3mlmh1q1jpwk9ohyjjv7c", "srv-captain--redpanda-0.1.tt13bkp5"]) - listWith (containersPage noContainerFilters{runtime = Just "kubernetes"}) >>= (`shouldMatchList` ["checkout", "coredns", "postgres", "frontend-544d9b6f4-2xk7z"]) - listWith (containersPage noContainerFilters{node = Just "0ce201583b04"}) >>= (`shouldMatchList` ["srv-captain--api.2.wcxx3mlmh1q1jpwk9ohyjjv7c", "srv-captain--redpanda-0.1.tt13bkp5"]) - listWith (containersPage noContainerFilters{namespace = Just "no-such-namespace"}) >>= (`shouldBe` []) - -- Cluster prefers the name where the collector set one, and falls back to the uid - -- where it did not, so the facet is usable before anyone edits a collector config. - listWith (containersPage noContainerFilters{cluster = Just "otel-demo"}) >>= (`shouldBe` ["checkout"]) - listWith (containersPage noContainerFilters{cluster = Just clusterUid}) >>= (`shouldMatchList` ["coredns", "postgres", "frontend-544d9b6f4-2xk7z"]) - -- 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" @@ -371,10 +307,7 @@ spec = sequential $ aroundAll withTfChartResources do , "Latest cumulative restart count reported within the selected time range. This is not the number of restarts during the range." ] - it "tableControls_customizeAndExportCanBeEnabledIndependently" \tr -> do - (_, Containers.ContainersPage (PageCtx _ body)) <- testServant tr $ containersPage noContainerFilters - table <- deferredBody body - let render features = LT.toStrict $ Lucid.renderText $ Lucid.toHtml table{features} + 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} customizeOnly `shouldContainAll` ["Customize", "Reset columns", "data-column-key=", "data-column-toggle=", "[data-forced]:not(:checked)) [data-column-key=\"Pod\"]{display:none}"] @@ -382,21 +315,37 @@ spec = sequential $ aroundAll withTfChartResources do exportOnly `shouldContainAll` ["Export", "containers.csv"] exportOnly `shouldNotSatisfy` T.isInfixOf "Customize" - it "hostGrouping_sharedTableKeepsSingletonGroupsAndRealRows" \tr -> do - (_, Infrastructure.HostsPage (PageCtx _ body)) <- testServant tr $ Infrastructure.hostsGetH testPid Nothing Nothing Nothing Nothing (Just "integration") Nothing Nothing (Just "1H") (Just "1") - table <- deferredBody body - let html = LT.toStrict $ Lucid.renderText $ Lucid.toHtml table - V.length table.rows `shouldBe` 3 - html `shouldContainAll` ["data-table-group=\"Docker\"", "data-table-group=\"Kubernetes\"", "data-table-group=\"OpenTelemetry\"", "data-hx-trigger=\"change\"", "since=1H", "name=\"group\"", "data-column-key=\"CPU\""] - T.count "role=\"button\"" html `shouldBe` 3 - T.count " vm.row.containerName) t.rows + listWith (containersPage noContainerFilters{namespace = Just "default"}) >>= (`shouldBe` ["checkout"]) + listWith (containersPage noContainerFilters{runtime = Just "docker"}) >>= (`shouldMatchList` ["srv-captain--api.2.wcxx3mlmh1q1jpwk9ohyjjv7c", "srv-captain--redpanda-0.1.tt13bkp5"]) + listWith (containersPage noContainerFilters{runtime = Just "kubernetes"}) >>= (`shouldMatchList` ["checkout", "coredns", "postgres", "frontend-544d9b6f4-2xk7z"]) + listWith (containersPage noContainerFilters{node = Just "0ce201583b04"}) >>= (`shouldMatchList` ["srv-captain--api.2.wcxx3mlmh1q1jpwk9ohyjjv7c", "srv-captain--redpanda-0.1.tt13bkp5"]) + listWith (containersPage noContainerFilters{namespace = Just "no-such-namespace"}) >>= (`shouldBe` []) + -- Cluster prefers the name where the collector set one, and falls back to the uid + -- where it did not, so the facet is usable before anyone edits a collector config. + listWith (containersPage noContainerFilters{cluster = Just "otel-demo"}) >>= (`shouldBe` ["checkout"]) + listWith (containersPage noContainerFilters{cluster = Just clusterUid}) >>= (`shouldMatchList` ["coredns", "postgres", "frontend-544d9b6f4-2xk7z"]) + -- 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"]) 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", "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" @@ -438,11 +387,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 @@ -452,17 +401,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") @@ -501,36 +453,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" @@ -570,19 +505,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/test/table-export.test.ts b/web-components/test/table-export.test.ts index 66827883a..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); @@ -44,20 +44,4 @@ describe('table CSV export', () => { expect(click).not.toHaveBeenCalled(); expect(URL.createObjectURL).not.toHaveBeenCalled(); }); - - test('grouped exports contain one header and only visible data rows', async () => { - document.body.innerHTML = ` - -
HostCPU
Europe: 2 hosts - - - -
HostCPU
api-125%
`; - document.querySelectorAll('th,td').forEach(cell => cell.innerText = cell.textContent ?? ''); - vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined); - - window.exportTableCsv('#inventory', 'hosts.csv'); - - expect(await exported.text()).toBe('"Host","CPU"\n"api-1","25%"'); - }); }); From 969fb31fa06d12ae36f8bbe7a4c20bb5e26b2da2 Mon Sep 17 00:00:00 2001 From: Anthony Alaribe Date: Mon, 26 Oct 2026 11:09:20 +0100 Subject: [PATCH 06/10] Share Clear all across facet rails and demote facet glue to CSS Clear all lives in the shared rail for both Explorer and inventories and only shows while a facet is ticked. Column choices restore without a sync event, filter inputs skip re-filtering swaps while empty, and the collapsible rail opens on desktop via ::details-content instead of a resize script. --- e2e/tests/infrastructure-time-window.spec.ts | 4 +--- src/Pages/Components.hs | 2 +- src/Pages/LogExplorer/Log.hs | 2 +- src/Pkg/Components/Facets.hs | 9 +++++---- src/Pkg/Components/Table.hs | 18 ++++-------------- 5 files changed, 12 insertions(+), 23 deletions(-) diff --git a/e2e/tests/infrastructure-time-window.spec.ts b/e2e/tests/infrastructure-time-window.spec.ts index ec3652bb3..c2a8b69a6 100644 --- a/e2e/tests/infrastructure-time-window.spec.ts +++ b/e2e/tests/infrastructure-time-window.spec.ts @@ -70,15 +70,13 @@ for (const width of [390, 1440]) { 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.uncheck(); 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 container.getByRole('button', { name: 'Customize', exact: true }).click(); - await container.getByRole('button', { name: 'Customize', exact: true }).click(); await expect(system).toBeChecked(); } }); diff --git a/src/Pages/Components.hs b/src/Pages/Components.hs index 85c507043..a5f17932d 100644 --- a/src/Pages/Components.hs +++ b/src/Pages/Components.hs @@ -1067,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 on keydown[key is 'Escape'] set my value to '' then send input to me|] +filterInputAttr_ sel = term "_" [text|on input or htmx:after:swap from document set v to my value.toLowerCase() then if v is '' and event.type is not 'input' exit end 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 keydown[key is 'Escape'] set my value to '' then send input to me|] -- | Toned icon + label chip. diff --git a/src/Pages/LogExplorer/Log.hs b/src/Pages/LogExplorer/Log.hs index 71a2e2518..318b609f8 100644 --- a/src/Pages/LogExplorer/Log.hs +++ b/src/Pages/LogExplorer/Log.hs @@ -1655,7 +1655,7 @@ apiLogsPage page = do Facets.Config { elemId = Just "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 = Nothing + , clear = Just [[__|on click repeat for cb in in closest .facet-rail call queryEditorCall('toggleSubQuery', cb.dataset.field, cb.dataset.value) end|]] , content = facetGroups <$> page.facetSummary , layout = Facets.Plain } diff --git a/src/Pkg/Components/Facets.hs b/src/Pkg/Components/Facets.hs index a345d7caf..e0c266757 100644 --- a/src/Pkg/Components/Facets.hs +++ b/src/Pkg/Components/Facets.hs @@ -2,7 +2,7 @@ module Pkg.Components.Facets (Config (..), Layout (..), FacetNode (..), FacetCon import Lucid import Lucid.Hyperscript (__) -import Pages.Components (detailsClosedBelowAttr_, filterInputAttr_, searchInput_) +import Pages.Components (filterInputAttr_, searchInput_) import Relude import Utils (LoadingSize (..), LoadingType (..), faSprite_, loadingIndicator_, prettyPrintCount) @@ -23,16 +23,17 @@ data Config = Config sidebar_ :: Config -> Html () sidebar_ cfg = case cfg.layout of Plain -> rail - Collapsible -> details_ [open_ "", class_ "w-68 shrink-0 max-lg:w-full", detailsClosedBelowAttr_ 1024] do + 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 where - rail = div_ ([class_ $ "facet-rail flex w-68 shrink-0 flex-col gap-2 text-sm text-textWeak max-md:w-full " <> cfg.extraClass <> case cfg.layout of { Plain -> ""; Collapsible -> " max-lg:w-full" }, data_ "component" "facet-rail"] <> [id_ x | x <- maybeToList cfg.elemId]) do + rail = 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 <> case cfg.layout of { Plain -> ""; Collapsible -> " max-lg:w-full" }, data_ "component" "facet-rail"] <> [id_ x | x <- maybeToList 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_ ".facet-rail .facet-item, .facet-rail .facet-section"] - forM_ cfg.clear $ \attrs -> button_ ([type_ "button", class_ "flex items-center justify-between rounded px-2 py-1.5 text-xs text-textBrand hover:bg-fillWeak"] <> attrs) $ "Clear all" >> faSprite_ "xmark" "regular" "h-3 w-3" + -- Shown only while a facet is ticked; Explorer and inventories each supply how to clear. + 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 diff --git a/src/Pkg/Components/Table.hs b/src/Pkg/Components/Table.hs index 32992d6a6..ea4990469 100644 --- a/src/Pkg/Components/Table.hs +++ b/src/Pkg/Components/Table.hs @@ -815,11 +815,11 @@ renderResultToolbar tbl = div_ [class_ "flex min-h-9 flex-wrap items-center just 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. + -- user choice, so re-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|]]) do + 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_ tbl.columns $ \column -> label_ [class_ "flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-fillWeak"] do input_ @@ -828,24 +828,14 @@ renderResultToolbar tbl = div_ [class_ "flex min-h-9 flex-wrap items-center just , class_ "checkbox checkbox-xs" , data_ "column-toggle" column.name , data_ "column-storage" $ "table-columns:" <> tbl.config.elemID <> ":" <> column.name - , [__|init send syncColumn to me end - on syncColumn - set saved to localStorage.getItem(@data-column-storage) - if saved is null - remove @data-forced then set my.checked to true - 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 - else - set @data-forced to 'true' then set my.checked to saved is 'true' - end - end + , [__|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 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 send syncColumn to t end|] + , [__|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 set t.checked to true end|] ] "Reset columns" From 78cf8d754ff4da655eea7e2b88a49083d4544bdf Mon Sep 17 00:00:00 2001 From: Anthony Alaribe Date: Mon, 26 Oct 2026 11:12:24 +0100 Subject: [PATCH 07/10] Cover Explorer facet Clear all end to end --- e2e/tests/query-editor.spec.ts | 15 +++++++++++++++ src/Pkg/Components/Facets.hs | 1 - 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/e2e/tests/query-editor.spec.ts b/e2e/tests/query-editor.spec.ts index 4b892ebc5..1f702aee1 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" }).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/src/Pkg/Components/Facets.hs b/src/Pkg/Components/Facets.hs index e0c266757..debf117be 100644 --- a/src/Pkg/Components/Facets.hs +++ b/src/Pkg/Components/Facets.hs @@ -32,7 +32,6 @@ sidebar_ cfg = case cfg.layout of "sticky top-0 z-10 h-9 w-[calc(100%-1rem)] mx-2 border-strokeStrong bg-bgBase" "Search filters" [name_ "facet-search", filterInputAttr_ ".facet-rail .facet-item, .facet-rail .facet-section"] - -- Shown only while a facet is ticked; Explorer and inventories each supply how to clear. 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 From be5e2f6b4f42268809768811a406e43b21ad95be Mon Sep 17 00:00:00 2001 From: Anthony Alaribe Date: Mon, 26 Oct 2026 11:16:09 +0100 Subject: [PATCH 08/10] Clear facets through their own checkboxes; re-filter swaps only while searching --- src/Pages/Components.hs | 2 +- src/Pages/LogExplorer/Log.hs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Pages/Components.hs b/src/Pages/Components.hs index a5f17932d..fd3204785 100644 --- a/src/Pages/Components.hs +++ b/src/Pages/Components.hs @@ -1067,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 if v is '' and event.type is not 'input' exit end 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 keydown[key is 'Escape'] set my value to '' then send input to me|] +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/LogExplorer/Log.hs b/src/Pages/LogExplorer/Log.hs index 318b609f8..113bf0ffb 100644 --- a/src/Pages/LogExplorer/Log.hs +++ b/src/Pages/LogExplorer/Log.hs @@ -1655,7 +1655,7 @@ apiLogsPage page = do Facets.Config { elemId = Just "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 queryEditorCall('toggleSubQuery', cb.dataset.field, cb.dataset.value) end|]] + , clear = Just [[__|on click repeat for cb in in closest .facet-rail call cb.click() end|]] , content = facetGroups <$> page.facetSummary , layout = Facets.Plain } From 3f399e765f35d5faffb3ba31761633a74979a01f Mon Sep 17 00:00:00 2001 From: Anthony Alaribe Date: Mon, 26 Oct 2026 11:22:04 +0100 Subject: [PATCH 09/10] Skip the facet Clear all e2e quickly when the fixture has no facet values --- e2e/tests/query-editor.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/tests/query-editor.spec.ts b/e2e/tests/query-editor.spec.ts index 1f702aee1..eb5205e3e 100644 --- a/e2e/tests/query-editor.spec.ts +++ b/e2e/tests/query-editor.spec.ts @@ -149,7 +149,7 @@ test.describe("Query editor", () => { 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" }).catch(() => {}); + 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(); From 6850e40fc86c752197ba129b5535baa2ed99314f Mon Sep 17 00:00:00 2001 From: Anthony Alaribe Date: Mon, 26 Oct 2026 12:27:39 +0100 Subject: [PATCH 10/10] Distill grouped rendering and align facet controls with selection semantics --- e2e/tests/container-identity.spec.ts | 6 +- e2e/tests/infrastructure-time-window.spec.ts | 12 +- src/Pages/LogExplorer/Log.hs | 3 +- src/Pkg/Components/Facets.hs | 33 +++--- src/Pkg/Components/Table.hs | 111 ++++++++++--------- test/integration/Pages/ContainersSpec.hs | 1 + 6 files changed, 93 insertions(+), 73 deletions(-) diff --git a/e2e/tests/container-identity.spec.ts b/e2e/tests/container-identity.spec.ts index d42c9ddba..55a34eed5 100644 --- a/e2e/tests/container-identity.spec.ts +++ b/e2e/tests/container-identity.spec.ts @@ -162,10 +162,10 @@ test("mobile namespace facet preserves the selected cluster", async ({ page }) = expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); 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("checkbox", { name: "identity-a", exact: true })).toBeHidden(); + 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("checkbox", { name: "identity-a", exact: true }).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]); @@ -175,7 +175,7 @@ test("mobile namespace facet preserves the selected cluster", async ({ page }) = 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$/ }).press("Enter"); const replaced = page.waitForResponse(response => new URL(response.url()).searchParams.get("namespace") === "identity-b"); - await page.getByRole("checkbox", { name: "identity-b", exact: true }).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"]); diff --git a/e2e/tests/infrastructure-time-window.spec.ts b/e2e/tests/infrastructure-time-window.spec.ts index c2a8b69a6..acf2816f0 100644 --- a/e2e/tests/infrastructure-time-window.spec.ts +++ b/e2e/tests/infrastructure-time-window.spec.ts @@ -35,6 +35,16 @@ for (const width of [390, 1440]) { 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(); @@ -70,7 +80,7 @@ for (const width of [390, 1440]) { await expect(container.locator('td[data-column-key="CPU"]').first()).toBeVisible(); if (width === 390) { const system = container.locator('[data-column-toggle="System"]'); - await system.uncheck(); + 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(); diff --git a/src/Pages/LogExplorer/Log.hs b/src/Pages/LogExplorer/Log.hs index 113bf0ffb..53be42433 100644 --- a/src/Pages/LogExplorer/Log.hs +++ b/src/Pages/LogExplorer/Log.hs @@ -569,6 +569,7 @@ 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) @@ -1653,7 +1654,7 @@ apiLogsPage page = do facetsPanel = Facets.sidebar_ Facets.Config - { elemId = Just "facets-container" + { 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 diff --git a/src/Pkg/Components/Facets.hs b/src/Pkg/Components/Facets.hs index debf117be..bb4628dce 100644 --- a/src/Pkg/Components/Facets.hs +++ b/src/Pkg/Components/Facets.hs @@ -11,7 +11,7 @@ data Layout = Plain | Collapsible data Config = Config - { elemId :: Maybe Text + { elemId :: Text , extraClass :: Text , clear :: Maybe [Attribute] , content :: Maybe [FacetNode] @@ -22,16 +22,16 @@ data Config = Config -- | Explorer's facet sidebar, also embedded by inventory tables and the host map. sidebar_ :: Config -> Html () sidebar_ cfg = case cfg.layout of - Plain -> rail + 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 + rail " max-lg:w-full" where - rail = 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 <> case cfg.layout of { Plain -> ""; Collapsible -> " max-lg:w-full" }, data_ "component" "facet-rail"] <> [id_ x | x <- maybeToList cfg.elemId]) do + 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_ ".facet-rail .facet-item, .facet-rail .facet-section"] + [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 @@ -64,17 +64,15 @@ facetNode_ node = do 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 -> values_ choices overflow - where - values_ 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 + 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 () @@ -98,6 +96,7 @@ facetHeadCount = 5 data FacetChoice = FacetChoice { label :: Text + , multiSelect :: Bool , inputAttrs :: [Attribute] , marker :: Maybe (Html ()) , count :: Maybe Text @@ -107,7 +106,7 @@ data FacetChoice = FacetChoice 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_ "checkbox", class_ "checkbox checkbox-xs max-md:checkbox-sm"] <> choice.inputAttrs + 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 ea4990469..55f1ff97c 100644 --- a/src/Pkg/Components/Table.hs +++ b/src/Pkg/Components/Table.hs @@ -125,9 +125,9 @@ data SearchMode = ClientSide | ServerSide Text -- | Features default to no optional behavior. -- --- >>> let f = def @(Features ()) --- >>> and @[] [isNothing f.rowLink, isNothing f.search, isNothing f.pagination, isNothing f.rowGroup, null f.bulkActions, isNothing f.facets, not f.customizeColumns, isNothing f.grouping, isNothing f.tabs] --- True +-- >>> let html = renderText $ toHtml (Table def [] V.empty def :: Table ()) +-- >>> any @[] (`TL.isInfixOf` html) ["facet-rail", " Text) , rowId :: Maybe (a -> Text) @@ -160,6 +160,8 @@ data Features a = Features -- >>> :set -XTypeApplications -XOverloadedRecordDot -XOverloadedStrings -- >>> import Data.Default (def) -- >>> import Data.Vector qualified as V +-- >>> import Data.Text.Lazy qualified as TL +-- >>> import Lucid (renderText, toHtml) data Config = Config @@ -562,36 +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 - , Just groupOf <- tbl.features.rowGroup - , isJust tbl.features.grouping = - iforM_ (groups groupOf) $ \idx ((_, row) :| siblings) -> - 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"] do - toHtml label - span_ [class_ "ms-2 text-xs font-normal text-textWeak tabular-nums"] $ toHtml $ show (1 + length siblings) <> " items" - mapM_ (renderTableRow tbl) (row : map snd siblings) - | 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 + 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 = - forM_ (groups groupOf) $ \((_, row) :| siblings) -> - 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]"] 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", data_ "column-key" c.name] $ toHtml c.name - tbody_ $ mapM_ (renderTableRow tbl) (row : map snd siblings) - _ -> renderTableRow tbl row + 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] @@ -679,7 +677,7 @@ clearFiltersUrl actions = foldl' (\url menu -> deleteParam menu.paramName url) a -- | Adapt URL filters to the same sidebar data Explorer supplies. facetsConfig :: TableHeaderActions -> Facets.Config -facetsConfig actions = Facets.Config Nothing "" (Just $ swapTarget_ actions.targetId $ clearFiltersUrl actions) (Just [common]) Facets.Collapsible +facetsConfig actions = Facets.Config (actions.targetId <> "-facets") "" (Just $ swapTarget_ actions.targetId $ clearFiltersUrl actions) (Just [common]) Facets.Collapsible where common = FacetNode "Common Filters" Nothing True [] Nothing @@ -740,7 +738,7 @@ renderFilterMenuItem actions menu = div_ [class_ "relative"] do filterChoice :: TableHeaderActions -> FilterMenu -> FilterOption -> FacetChoice -filterChoice actions menu opt = FacetChoice{label = opt.label, inputAttrs, marker = Nothing, count = prettyPrintCount <$> opt.count} +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 @@ -751,7 +749,7 @@ filterChoice actions menu opt = FacetChoice{label = opt.label, inputAttrs, marke inputAttrs = [ Aria.label_ opt.label , value_ opt.value - , name_ $ "filter_" <> menu.paramName + , name_ $ actions.targetId <> "_filter_" <> menu.paramName , hxTrigger_ "change" ] <> swapTarget_ actions.targetId url @@ -815,29 +813,40 @@ renderResultToolbar tbl = div_ [class_ "flex min-h-9 flex-wrap items-center just 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 re-ticking a column that a breakpoint hides shows it. + -- 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"]) 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 + 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|] ] - 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 set t.checked to true end|] - ] - "Reset columns" + "Reset columns" selectControl_ :: SelectControl -> Html () diff --git a/test/integration/Pages/ContainersSpec.hs b/test/integration/Pages/ContainersSpec.hs index d5ec3d3b1..cc68f0e01 100644 --- a/test/integration/Pages/ContainersSpec.hs +++ b/test/integration/Pages/ContainersSpec.hs @@ -310,6 +310,7 @@ spec = sequential $ aroundAll withTfChartResources do 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"]