From b1f774b46c4bd89fd38c2649b2f2f321c6dcc145 Mon Sep 17 00:00:00 2001 From: Anthony Alaribe Date: Mon, 26 Oct 2026 20:23:17 +0100 Subject: [PATCH] Generalize RUM segments into dashboard saved views and tab filters --- docs/dashboard-views.md | 39 ++++++++ e2e/tests/rum-audience.spec.ts | 63 +++++++++++-- monoscope.cabal | 3 +- src/Models/Projects/Dashboards.hs | 49 +++++++++- src/Pages/Dashboards.hs | 94 +++++++++++++++---- src/Pages/Rum.hs | 88 ----------------- src/Pages/Telemetry.hs | 2 +- src/Pkg/Components/Widget.hs | 21 +++-- src/Pkg/Rum.hs | 9 +- src/Web/Routes.hs | 11 +-- .../migrations/0255_dashboard_saved_views.sql | 24 +++++ static/public/assets/css/tailwind.css | 24 ++--- static/public/dashboards/rum.yaml | 10 +- .../integration/Pages/DashboardWidgetsSpec.hs | 90 +++++++++++++----- test/integration/Pages/DashboardsSpec.hs | 5 +- test/integration/Pages/GitSyncSpec.hs | 1 + 16 files changed, 345 insertions(+), 188 deletions(-) create mode 100644 docs/dashboard-views.md delete mode 100644 src/Pages/Rum.hs create mode 100644 static/migrations/0255_dashboard_saved_views.sql diff --git a/docs/dashboard-views.md b/docs/dashboard-views.md new file mode 100644 index 000000000..15eeb04cb --- /dev/null +++ b/docs/dashboard-views.md @@ -0,0 +1,39 @@ +# Dashboard views and tab filters + +A saved view names a set of dashboard variable values. It applies those filters +while keeping the current tab and time range. Use **Save view** on any dashboard +with variables; views are shared by that dashboard's project members. + +Views saved in the UI live separately from the dashboard schema, so saving a view +keeps template-backed dashboards receiving template updates. Existing RUM audience +segments migrate into views on their project's RUM dashboard. + +YAML can also declare views and choose which filters each tab displays: + +```yaml +variables: + - {key: service, title: Service, type: values, options: [[api], [worker]]} + - {key: region, title: Region, type: values, options: [[eu], [us]]} +views: + - name: Europe API + variables: {service: api, region: eu} +tabs: + - name: Overview + variables: [service] + widgets: [] + - name: Errors + variables: [service, region] + widgets: [] +widgets: [] +``` + +Omitting a tab's `variables` displays all dashboard filters. An empty list hides +them all. Hidden filters retain their values and continue filtering queries. +UI-saved views override YAML views with the same name; deleting the saved override +reveals the YAML view again. Change YAML to remove a declared view. + +Widget layouts accept `min_width` in pixels. For example, +`layout: {w: 4, h: 5, min_width: 500}` requests at least 500 pixels for a widget +occupying one third of a twelve-column grid. The grid stacks its widgets when its +available width falls below 1500 pixels. Tables wrap long values, and groups of +stat widgets use two columns on narrow screens. diff --git a/e2e/tests/rum-audience.spec.ts b/e2e/tests/rum-audience.spec.ts index 6f825e694..95095bee5 100644 --- a/e2e/tests/rum-audience.spec.ts +++ b/e2e/tests/rum-audience.spec.ts @@ -1,10 +1,10 @@ import { test, expect } from "@playwright/test"; import { DEMO_PROJECT, sql } from "./helpers"; -test("audience groups filter sessions, search users, and persist named segments", async ({ page }) => { +test("audience groups filter sessions, search users, and persist dashboard views", async ({ page }) => { const app = `e2e-rum-audience-${Date.now()}`; const segment = `${app} Mobile Safari`; - const cleanup = () => sql(`DELETE FROM otel_logs_and_spans WHERE project_id='${DEMO_PROJECT}' AND resource___service___name='${app}'; DELETE FROM projects.rum_segments WHERE project_id='${DEMO_PROJECT}' AND name='${segment}';`); + const cleanup = () => sql(`DELETE FROM otel_logs_and_spans WHERE project_id='${DEMO_PROJECT}' AND resource___service___name='${app}'; UPDATE projects.dashboards SET saved_views=ARRAY(SELECT v FROM unnest(saved_views) v WHERE v->>'name' <> '${segment}') WHERE project_id='${DEMO_PROJECT}' AND base_template='rum.yaml';`); sql(`INSERT INTO otel_logs_and_spans (project_id,summary,name,timestamp,start_time,end_time,resource___service___name,resource___user_agent___original,attributes___session___id,attributes___user___id,attributes___user___email,attributes___exception___type) VALUES ('${DEMO_PROJECT}',ARRAY['RUM audience'],'documentLoad',now(),now(),now(),'${app}','iPhone Mobile Safari/604','${app}-alice','alice','alice@example.com',NULL), @@ -16,8 +16,8 @@ test("audience groups filter sessions, search users, and persist named segments" page.on("pageerror", error => errors.push(error.message)); page.on("console", message => { if (message.type() === "error" && /SyntaxError|parse error/i.test(message.text())) errors.push(message.text()); }); await page.goto(`/p/${DEMO_PROJECT}/rum?tab=audience&var-app=${app}&since=1H`); - const toolbar = page.locator("#rum-segments"); - await expect(toolbar.getByRole("link", { name: "View sessions", exact: true })).toBeVisible(); + const toolbar = page.locator("#dashboard-views"); + await expect(toolbar.getByText("Views", { exact: true })).toBeVisible(); const browsers = page.getByRole("table").filter({ has: page.getByRole("columnheader", { name: "Browser", exact: true }) }); const systems = page.getByRole("table").filter({ has: page.getByRole("columnheader", { name: "Operating system", exact: true }) }); await expect(browsers.getByText("Unknown", { exact: true })).toBeVisible(); @@ -45,18 +45,61 @@ test("audience groups filter sessions, search users, and persist named segments" await safari.focus(); await safari.press("Enter"); await expect(page).toHaveURL(/var-browser=Safari/); - await toolbar.locator("summary").filter({ hasText: "Save segment" }).click(); - await toolbar.getByLabel("Segment name").fill(segment); - await toolbar.getByRole("button", { name: "Save segment", exact: true }).click(); + await toolbar.locator("summary").filter({ hasText: "Save view" }).click(); + await toolbar.getByLabel("View name").fill(segment); + await toolbar.getByRole("button", { name: "Save view", exact: true }).click(); await expect(toolbar.getByRole("link", { name: segment, exact: true })).toBeVisible(); await page.reload(); await expect(toolbar.getByRole("link", { name: segment, exact: true })).toBeVisible(); - await toolbar.getByRole("link", { name: "View errors", exact: true }).click(); + await page.getByRole("tab", { name: "Errors", exact: true }).click(); await expect(page).toHaveURL(/\/tab\/errors/); await expect(page).toHaveURL(/var-browser=Safari/); - await toolbar.getByRole("link", { name: "View sessions", exact: true }).click(); + await page.getByRole("tab", { name: "Audience", exact: true }).click(); + await users.getByRole("link", { name: "alice@example.com" }).click(); await expect(page).toHaveURL(/viz_type=sessions/); - expect(new URL(page.url()).searchParams.get("query")).toContain("Safari/"); + expect(new URL(page.url()).searchParams.get("query")).toContain("alice"); expect(errors).toEqual([]); } finally { cleanup(); } }); + +test("saved views work on custom dashboards across tabs and retain the time range", async ({ page }) => { + const id = crypto.randomUUID(); + const schema = { + title: "Shared dashboard views", widgets: [], + variables: [ + { key: "service", title: "Service", type: "values", options: [["api"], ["worker"]] }, + { key: "region", title: "Region", type: "values", options: [["eu"], ["us"]] }, + ], + tabs: [ + { name: "Overview", variables: ["service"], widgets: [] }, + { name: "Errors", variables: ["service", "region"], widgets: [] }, + ], + }; + sql(`INSERT INTO projects.dashboards (id,project_id,created_by,title,schema) VALUES ('${id}','${DEMO_PROJECT}','00000000-0000-0000-0000-000000000000','Shared dashboard views','${JSON.stringify(schema)}');`); + try { + const root = `/p/${DEMO_PROJECT}/dashboards/${id}`; + const toolbar = page.locator("#dashboard-views"); + await page.goto(`${root}/tab/errors?var-service=worker&var-region=us&since=7D`); + await toolbar.locator("summary").filter({ hasText: "Save view" }).click(); + await toolbar.getByLabel("View name").fill("US worker"); + await toolbar.getByRole("button", { name: "Save view", exact: true }).click(); + await expect(toolbar.getByRole("link", { name: "US worker", exact: true })).toBeVisible(); + await page.reload(); + await expect(toolbar.getByRole("link", { name: "US worker", exact: true })).toBeVisible(); + await page.getByRole("tab", { name: "Overview", exact: true }).click(); + await expect(page.locator('#dashboard-variables [name="region"]')).toHaveCount(0); + await toolbar.getByRole("link", { name: "Clear filters", exact: true }).click(); + await expect(page).not.toHaveURL(/var-service=/); + await toolbar.getByRole("link", { name: "US worker", exact: true }).click(); + await expect(page).toHaveURL(/\/tab\/overview.*var-service=worker/); + expect(new URL(page.url()).searchParams.get("var-service")).toBe("worker"); + expect(new URL(page.url()).searchParams.get("var-region")).toBe("us"); + expect(new URL(page.url()).searchParams.get("since")).toBe("7D"); + await page.getByRole("tab", { name: "Errors", exact: true }).click(); + await expect(page.locator('#dashboard-variables [name="region"]')).toHaveCount(1); + await toolbar.getByRole("button", { name: "Delete view US worker", exact: true }).click(); + await expect(toolbar.getByRole("link", { name: "US worker", exact: true })).toHaveCount(0); + } finally { + sql(`DELETE FROM projects.dashboards WHERE id='${id}';`); + } +}); diff --git a/monoscope.cabal b/monoscope.cabal index 5f0710fae..7a0fbd58c 100644 --- a/monoscope.cabal +++ b/monoscope.cabal @@ -270,6 +270,7 @@ extra-source-files: static/migrations/0253_project_memory_legacy_writes.sql static/migrations/0254_ingest_sampling.sql static/migrations/0254_rum_segments.sql + static/migrations/0255_dashboard_saved_views.sql test/fixtures/github-app-test.pem test/fixtures/wiki-browser.mjs static/public/assets/web-components/dist/manifest.json @@ -381,7 +382,6 @@ library Pages.Projects Pages.Replay Pages.Reports - Pages.Rum Pages.ServiceMap Pages.Settings Pages.Share @@ -1029,7 +1029,6 @@ test-suite test-dev Pages.Projects Pages.Replay Pages.Reports - Pages.Rum Pages.ServiceMap Pages.Settings Pages.Share diff --git a/src/Models/Projects/Dashboards.hs b/src/Models/Projects/Dashboards.hs index 92962d8eb..48e1b3f0d 100644 --- a/src/Models/Projects/Dashboards.hs +++ b/src/Models/Projects/Dashboards.hs @@ -1,6 +1,10 @@ module Models.Projects.Dashboards ( Dashboard (..), DashboardVM (..), + DashboardView (..), + variablesForTab, + updateSavedView, + deleteSavedView, mkDashboardVM, DashboardId, readDashboardFile, @@ -98,6 +102,7 @@ data DashboardVM = DashboardVM , filePath :: Maybe Text , fileSha :: Maybe Text , gitSyncId :: Maybe (UUIDId "github_sync") + , savedViews :: V.Vector DashboardView } deriving stock (Generic, Show) deriving anyclass (FromRow, HI.DecodeRow, NFData, ToRow) @@ -121,6 +126,7 @@ data Dashboard = Dashboard , timeRange :: Maybe TimePicker.TimePicker , constants :: Maybe [Constant] , variables :: Maybe [Variable] + , views :: Maybe [DashboardView] , tabs :: Maybe [Tab] , discoveryMetrics :: Maybe (NonEmpty Text) -- Metric-name prefixes (e.g. "postgresql.") that auto-enable this template for projects emitting them , widgets :: [Widget.Widget] @@ -132,6 +138,17 @@ data Dashboard = Dashboard deriving (HI.DecodeValue, HI.EncodeValue) via HI.AsJsonb Dashboard +data DashboardView = DashboardView + { name :: Text + , variables :: Map Text Text + } + deriving stock (Eq, Generic, Show, THS.Lift) + deriving anyclass (NFData) + deriving (FromField, ToField) via Aeson DashboardView + deriving (AE.FromJSON, AE.ToJSON) via DAE.Snake DashboardView + deriving (HI.DecodeValue, HI.EncodeValue) via HI.AsJsonb DashboardView + + data VariableType = VTQuery | VTValues deriving stock (Enum, Eq, Generic, Show, THS.Lift) deriving anyclass (NFData) @@ -177,6 +194,7 @@ data Constant = Constant data Tab = Tab { name :: Text , icon :: Maybe Text + , variables :: Maybe [Text] , requires :: Maybe Text -- Required variable name for tab to be active , widgets :: [Widget.Widget] } @@ -187,7 +205,7 @@ data Tab = Tab -- | A blank dashboard row; callers override the fields they actually set. mkDashboardVM :: DashboardId -> Projects.ProjectId -> UTCTime -> Projects.UserId -> DashboardVM -mkDashboardVM did pid now uid = DashboardVM{id = did, projectId = pid, createdAt = now, updatedAt = now, createdBy = uid, baseTemplate = Nothing, schema = Nothing, starredSince = Nothing, homepageSince = Nothing, tags = V.empty, title = "", teams = V.empty, filePath = Nothing, fileSha = Nothing, gitSyncId = Nothing} +mkDashboardVM did pid now uid = DashboardVM{id = did, projectId = pid, createdAt = now, updatedAt = now, createdBy = uid, baseTemplate = Nothing, schema = Nothing, starredSince = Nothing, homepageSince = Nothing, tags = V.empty, title = "", teams = V.empty, filePath = Nothing, fileSha = Nothing, gitSyncId = Nothing, savedViews = V.empty} insert :: DB es => DashboardVM -> Eff es DashboardVM @@ -213,12 +231,12 @@ getOrInsertByBaseTemplate d = do insertSql :: DashboardVM -> HI.Sql insertSql d = - [HI.sql| INSERT INTO projects.dashboards (id, project_id, created_at, updated_at, created_by, base_template, schema, starred_since, homepage_since, tags, title, teams, file_path, file_sha, git_sync_id) + [HI.sql| INSERT INTO projects.dashboards (id, project_id, created_at, updated_at, created_by, base_template, schema, starred_since, homepage_since, tags, title, teams, file_path, file_sha, git_sync_id, saved_views) VALUES (#{d.id}, #{d.projectId}, #{d.createdAt}, #{d.updatedAt}, #{d.createdBy}, #{d.baseTemplate}, #{d.schema}, #{d.starredSince}, #{d.homepageSince}, #{d.tags}, #{d.title}, CASE WHEN cardinality(#{d.teams}::uuid[]) = 0 THEN ARRAY(SELECT id FROM projects.teams WHERE project_id = #{d.projectId} AND is_everyone AND deleted_at IS NULL) ELSE #{d.teams}::uuid[] END, - #{d.filePath}, #{d.fileSha}, #{d.gitSyncId}) RETURNING teams |] + #{d.filePath}, #{d.fileSha}, #{d.gitSyncId}, #{d.savedViews}) RETURNING teams |] yamlFiles :: FilePath -> IO [FilePath] @@ -380,6 +398,31 @@ updateSchema dashId dashboard updatedAt = <> [HI.sql| WHERE id = #{dashId} |] +-- | Absent tab variable lists retain the dashboard's full filter bar. +variablesForTab :: Dashboard -> Maybe Tab -> [Variable] +variablesForTab dash tab = filter (\v -> maybe True (v.key `elem`) (tab >>= (.variables))) (fold dash.variables) + + +-- | Update one named view atomically; concurrent saves of other views survive. +updateSavedView :: DB es => Projects.ProjectId -> DashboardId -> DashboardView -> Eff es Int64 +updateSavedView pid did savedView = + Hasql.interpExecute + [HI.sql| + UPDATE projects.dashboards SET saved_views = + ARRAY(SELECT v FROM unnest(saved_views) v WHERE v->>'name' <> #{savedView.name}) || ARRAY[#{savedView}::jsonb] + WHERE project_id = #{pid} AND id = #{did} +|] + + +deleteSavedView :: DB es => Projects.ProjectId -> DashboardId -> Text -> Eff es Int64 +deleteSavedView pid did viewName = + Hasql.interpExecute + [HI.sql| + UPDATE projects.dashboards SET saved_views = ARRAY(SELECT v FROM unnest(saved_views) v WHERE v->>'name' <> #{viewName}) + WHERE project_id = #{pid} AND id = #{did} +|] + + updateTitle :: DB es => Projects.ProjectId -> DashboardId -> Text -> Eff es Int64 updateTitle pid dashId title = Hasql.interpExecute [HI.sql| UPDATE projects.dashboards SET title = #{title} WHERE project_id = #{pid} AND id = #{dashId} |] diff --git a/src/Pages/Dashboards.hs b/src/Pages/Dashboards.hs index 3e68f8aae..c157542f1 100644 --- a/src/Pages/Dashboards.hs +++ b/src/Pages/Dashboards.hs @@ -1,5 +1,8 @@ module Pages.Dashboards ( dashboardGetH, + DashboardViewForm (..), + dashboardViewPostH, + dashboardViewDeleteH, dashboardTabGetH, dashboardSettledPostH, dashboardTabRenamePatchH, @@ -105,7 +108,6 @@ import Pages.GitSync qualified as GitSyncPage import Pages.Issues qualified as IssuesPage import Pages.LogExplorer.LogItem qualified as LogItem import Pages.Monitors qualified as Alerts -import Pages.Rum qualified as Rum import Pkg.Components.LogQueryBox (LogQueryBoxConfig (..), logQueryBox_) import Pkg.Components.ServiceMap (endpointDependencyMapPanel_) import Pkg.Components.Table (Table (..)) @@ -131,7 +133,7 @@ import Text.Slugify (slugify) import UnliftIO qualified import UnliftIO.Exception (try) import Utils -import Web.FormUrlEncoded (FromForm) +import Web.FormUrlEncoded (Form (..), FromForm (..), parseUnique) import Web.HttpApiData (FromHttpApiData, parseUrlPiece) @@ -300,6 +302,65 @@ dashboardVariables_ pid dashId hasTabs variables extraAttrs = div_ ([id_ "dashbo faSprite_ "chevron-down" "regular" "h-3 w-3 shrink-0 text-iconNeutral pointer-events-none" +data DashboardViewForm = DashboardViewForm {name :: Text, params :: [(Text, Maybe Text)]} + deriving stock (Generic, Show) + + +instance FromForm DashboardViewForm where + fromForm form = DashboardViewForm <$> parseUnique "name" form <*> traverse (\key -> (key,) . Just <$> parseUnique key form) (filter (/= "name") $ HM.keys $ unForm form) + + +dashboardViewPostH :: Projects.ProjectId -> Dashboards.DashboardId -> DashboardViewForm -> ATAuthCtx (RespHeaders (Html ())) +dashboardViewPostH pid did form = do + _ <- Projects.sessionAndProject pid + (_, dash) <- getDashAndVM pid did Nothing + let name = T.strip form.name + variables = Map.fromList [(v.key, fromMaybe (fromMaybe "" v.value) $ join $ lookup ("var-" <> v.key) form.params) | v <- fold dash.variables] + when (T.null name || T.length name > 100) $ throwError err400{errBody = "View name must contain 1–100 characters."} + _ <- Dashboards.updateSavedView pid did Dashboards.DashboardView{name, variables} + (vm, _) <- getDashAndVM pid did Nothing + addRespHeaders $ dashboardViews_ pid did dash vm form.params [] + + +dashboardViewDeleteH :: Projects.ProjectId -> Dashboards.DashboardId -> Text -> [(Text, Maybe Text)] -> ATAuthCtx (RespHeaders (Html ())) +dashboardViewDeleteH pid did name params = do + _ <- Projects.sessionAndProject pid + (_, dash) <- getDashAndVM pid did Nothing + _ <- Dashboards.deleteSavedView pid did name + (vm, _) <- getDashAndVM pid did Nothing + addRespHeaders $ dashboardViews_ pid did dash vm params [] + + +dashboardViews_ :: Projects.ProjectId -> Dashboards.DashboardId -> Dashboards.Dashboard -> Dashboards.DashboardVM -> [(Text, Maybe Text)] -> [Attribute] -> Html () +dashboardViews_ pid did dash vm params extraAttrs = + div_ ([id_ "dashboard-views", class_ "flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-2 text-sm"] <> extraAttrs) do + nav_ [Aria.label_ "Saved dashboard views", class_ "flex flex-wrap items-center gap-2"] do + span_ [class_ "text-textWeak"] "Views" + forM_ availableViews \savedView -> do + let url = root <> tabPath <> queryStringFrom (context <> [("var-" <> k, Just v) | (k, v) <- Map.toList savedView.variables, k `elem` keys]) + selected = all (\v -> fromMaybe (fromMaybe "" v.value) (join $ lookup ("var-" <> v.key) params) == fromMaybe (fromMaybe "" v.value) (Map.lookup v.key savedView.variables)) (fold dash.variables) + a_ ([href_ url, data_ "preserve-page-context" "", class_ $ "rounded px-2 py-1 hover:bg-fillWeak " <> if selected then "bg-fillBrand-weak text-textBrand font-semibold" else "text-textStrong"] <> dashboardContentNavAttrs (maybe WholePage (const TabPanel) tab) url) $ toHtml savedView.name + when (any ((== savedView.name) . (.name)) vm.savedViews) $ button_ [type_ "button", class_ "btn btn-ghost btn-xs", Aria.label_ $ "Delete view " <> savedView.name, hxTarget_ "#dashboard-views", hxSwap_ "outerHTML", hxVals_ "js:{...Object.fromEntries(new URLSearchParams(location.search)), ...getTimeRange()}", hxDelete_ $ root <> "/views/" <> toUriStr savedView.name <> queryStringFrom [(activeTabSlugKey, tab)]] $ faSprite_ "xmark" "regular" "h-3 w-3" + details_ [class_ "relative"] do + summary_ [class_ "cursor-pointer text-textBrand"] "Save view" + form_ [class_ "absolute z-20 mt-2 p-3 w-64 rounded-lg border border-strokeWeak bg-bgRaised shadow-lg flex flex-col gap-3", hxTarget_ "#dashboard-views", hxSwap_ "outerHTML", hxVals_ "js:{...Object.fromEntries(new URLSearchParams(location.search)), ...getTimeRange()}", hxPost_ $ root <> "/views"] do + input_ [type_ "hidden", name_ activeTabSlugKey, value_ $ fromMaybe "" tab] + label_ [class_ "flex flex-col gap-1"] do + "View name" + input_ [name_ "name", required_ "", maxlength_ "100", class_ "input input-sm w-full", placeholder_ "Production API"] + button_ [type_ "submit", class_ "btn btn-primary btn-sm"] "Save view" + when (any (T.isPrefixOf "var-" . fst) params) do + let url = root <> tabPath <> queryStringFrom context + a_ ([href_ url, data_ "preserve-page-context" "", class_ "text-textWeak hover:underline"] <> dashboardContentNavAttrs (maybe WholePage (const TabPanel) tab) url) "Clear filters" + where + root = "/p/" <> pid.toText <> "/dashboards/" <> did.toText + tab = join (lookup activeTabSlugKey params) >>= \slug -> slug <$ findTabBySlug (fold dash.tabs) slug + tabPath = foldMap ("/tab/" <>) tab + context = filter (\(k, _) -> not ("var-" `T.isPrefixOf` k) && k `notElem` [activeTabSlugKey, "name", "expand"] && not ("const-" `T.isPrefixOf` k)) params + keys = map (.key) (fold dash.variables) + availableViews = sortOn (.name) $ toList vm.savedViews <> filter (\v -> all ((/= v.name) . (.name)) vm.savedViews) (fold dash.views) + + dashboardPage_ :: Projects.ProjectId -> Dashboards.DashboardId -> Dashboards.Dashboard -> Dashboards.DashboardVM -> [(Text, Maybe Text)] -> Html () dashboardPage_ pid dashId dash dashVM allParams = do let pidText = pid.toText @@ -330,7 +391,8 @@ dashboardPage_ pid dashId dash dashVM allParams = do dashboardTabStrip_ pidText dashIdText activeTabIdx tabs queryStr [] -- Variables section (pushed to the right, collapsible on mobile) - whenJust dash.variables \_ -> dashboardVariables_ pid dashId (isJust dash.tabs) (Rum.variablesForTab dash renderTabSlug) [] + whenJust dash.variables \_ -> dashboardVariables_ pid dashId (isJust dash.tabs) (Dashboards.variablesForTab dash renderTab) [] + unless (null $ fold dash.variables) $ dashboardViews_ pid dashId dash dashVM allParams [] let widgetOrderUrl = "/p/" <> pidText <> "/dashboards/" <> dashIdText <> "/widgets_order" <> maybe "" ("?tab=" <>) renderTabSlug constants = constantsJson allParams @@ -349,14 +411,11 @@ dashboardPage_ pid dashId dash dashVM allParams = do -- Tab system with htmx lazy loading - only render active tab content. -- Grid re-init after a swap is the document-level listener's job (see this page's script). div_ - ( [ class_ "dashboard-tabs-container" - , id_ "dashboard-tabs-content" - , data_ "constants" constants - ] - <> [data_ "rum-audience" "" | dash.file == Just "rum.yaml" && renderTabSlug == Just "audience"] - ) - $ whenJust (tabs !!? activeTabIdx) \activeTab -> do - when (dash.file == Just "rum.yaml" && slugify activeTab.name `elem` ["audience", "errors"]) $ Rum.segmentToolbar_ pid allParams + [ class_ "dashboard-tabs-container" + , id_ "dashboard-tabs-content" + , data_ "constants" constants + ] + $ whenJust (tabs !!? activeTabIdx) \activeTab -> -- An unanswered required variable IS the tab's content, not a modal over -- it. Rendering the widgets underneath meant every one of them ran with -- the variable interpolated to '' and reported "no data in the selected @@ -457,7 +516,7 @@ dashboardPage_ pid dashId dash dashVM allParams = do animate: false, columnOpts: { breakpointForWindow: false, - breakpoints: gridEl.closest('[data-rum-audience]') ? [{w: 1500, c: 1}] : [{w: 800, c: 6}, {w: 400, c: 1}], + breakpoints: gridEl.dataset.minWidth ? [{w: Number(gridEl.dataset.minWidth), c: 1}] : [{w: 800, c: 6}, {w: 400, c: 1}], layout: 'list' }, }, gridEl); @@ -517,7 +576,7 @@ dashboardPage_ pid dashId dash dashVM allParams = do animate: false, columnOpts: { breakpointForWindow: false, - breakpoints: nestedEl.closest('[data-rum-audience]') ? [{w: 600, c: 6}, {w: 320, c: 1}] : [{w: 600, c: 6}, {w: 400, c: 1}], + breakpoints: nestedEl.dataset.minWidth ? [{w: Number(nestedEl.dataset.minWidth), c: 1}] : [{w: 600, c: 6}, {w: 400, c: 1}], layout: 'list' }, }, nestedEl); @@ -2771,7 +2830,7 @@ processDashWidgets prefill pid dashId now timeParams paramsWithConstants widgets -- | Handler for dashboard with tab in path: /p/{pid}/dashboards/{dash_id}/tab/{tab_slug} -- This renders the full page with the specified tab active dashboardTabGetH :: Projects.ProjectId -> Dashboards.DashboardId -> Text -> Maybe Text -> Maybe Text -> Maybe Text -> Maybe Text -> Maybe Text -> Maybe Text -> [(Text, Maybe Text)] -> ATAuthCtx (RespHeaders (NavigationResponse DashboardGet)) -dashboardTabGetH pid dashId tabSlug fileM fromDStr toDStr sinceStr hxRequest hxTriggerName allParams = +dashboardTabGetH pid dashId tabSlug fileM fromDStr toDStr sinceStr hxRequest _hxTriggerName allParams = Metrics.timed Metrics.dashboardShellDuration [("navigation", OA.toAttribute $ if isJust hxRequest then ("tab" :: Text) else "initial")] $ withSpan_ "dashboard.tab.render" [("monoscope.dashboard.navigation", OA.toAttribute $ if isJust hxRequest then ("tab" :: Text) else "initial")] $ do @@ -2810,16 +2869,15 @@ dashboardTabGetH pid dashId tabSlug fileM fromDStr toDStr sinceStr hxRequest hxT queryStr = queryStringFrom $ filter (\(k, _) -> k `notElem` [activeTabSlugKey, "expand"]) paramsWithTab fragment = do dashboardTabStrip_ pid.toText dashId.toText activeTabIdx (fold dash''.tabs) queryStr [hxSwapOob_ "outerMorph"] - when (hxTriggerName == Just "dashboard-variable-option" || dash''.file == Just "rum.yaml") - $ whenJust dash''.variables \_ -> dashboardVariables_ pid dashId True (Rum.variablesForTab dash'' $ Just tabSlug) [hxSwapOob_ "true"] - div_ ([class_ "dashboard-tabs-container", id_ "dashboard-tabs-content", data_ "constants" $ constantsJson paramsWithTab] <> [data_ "rum-audience" "" | dash''.file == Just "rum.yaml" && tabSlug == "audience"]) + whenJust dash''.variables \_ -> dashboardVariables_ pid dashId True (Dashboards.variablesForTab dash'' (snd <$> activeTabInfo)) [hxSwapOob_ "true"] + unless (null $ fold dash''.variables) $ dashboardViews_ pid dashId dash'' dashVM paramsWithTab [hxSwapOob_ "true"] + div_ [class_ "dashboard-tabs-container", id_ "dashboard-tabs-content", data_ "constants" $ constantsJson paramsWithTab] $ case findVarToPrompt (snd <$> activeTabInfo) (fold dash''.variables) of Just v -> do whenJust activeTabName breadcrumbSuffixOob_ variablePickerModal_ pid dashId (Just tabSlug) paramsWithTab v Nothing -> whenJust (dash''.tabs >>= (!!? activeTabIdx)) \activeTab -> do breadcrumbSuffixOob_ activeTab.name - when (dash''.file == Just "rum.yaml" && tabSlug `elem` ["audience", "errors"]) $ Rum.segmentToolbar_ pid paramsWithTab widgetCanvas_ pid ("tab-panel-" <> dashId.toText <> "-" <> show activeTabIdx) activeTab.widgets addRespHeaders $ navigationResponse hxRequest page fragment diff --git a/src/Pages/Rum.hs b/src/Pages/Rum.hs deleted file mode 100644 index e119f7206..000000000 --- a/src/Pages/Rum.hs +++ /dev/null @@ -1,88 +0,0 @@ -module Pages.Rum (SegmentForm (..), segmentsGetH, segmentsPostH, segmentsDeleteH, segmentToolbar_, variablesForTab) where - -import Data.Effectful.Hasql qualified as Hasql -import Data.List (lookup) -import Data.Text qualified as T -import Effectful.Error.Static (throwError) -import Hasql.Interpolate qualified as HI -import Lucid -import Lucid.Aria qualified as Aria -import Lucid.Htmx -import Models.Projects.Dashboards qualified as Dashboards -import Models.Projects.Projects qualified as Projects -import Pkg.Rum (AudienceFilters (..), audienceFilters, audienceParams) -import Relude -import Servant (err400, errBody) -import System.Types -import Utils (faSprite_, queryStringFrom, toUriStr) -import Web.FormUrlEncoded (FromForm (..), parseUnique) - - -data SegmentForm = SegmentForm {name :: Text, filters :: AudienceFilters} - deriving stock (Generic, Show) - - -instance FromForm SegmentForm where - fromForm form = SegmentForm <$> parseUnique "name" form <*> fromForm form - - -variablesForTab :: Dashboards.Dashboard -> Maybe Text -> [Dashboards.Variable] -variablesForTab dash tab = filter (\v -> dash.file /= Just "rum.yaml" || tab == Just "audience" || tab == Just "errors" || v.key == "app") $ fold dash.variables - - -segmentToolbar_ :: Projects.ProjectId -> [(Text, Maybe Text)] -> Html () -segmentToolbar_ pid params = - div_ - [ id_ "rum-segments" - , hxGet_ $ "/p/" <> pid.toText <> "/rum/segments" <> queryStringFrom params - , hxTrigger_ "load, update-query from:window" - , hxVals_ "js:{...Object.fromEntries(new URLSearchParams(location.search))}" - , hxSwap_ "innerHTML" - , class_ "px-4 py-2 border-b border-strokeWeak" - ] - mempty - - -segmentsGetH :: Projects.ProjectId -> [(Text, Maybe Text)] -> ATAuthCtx (RespHeaders (Html ())) -segmentsGetH pid params = do - _ <- Projects.sessionAndProject pid - segments <- Hasql.interp @[(Text, HI.AsJsonb AudienceFilters)] [HI.sql|SELECT name, filters FROM projects.rum_segments WHERE project_id = #{pid} ORDER BY name|] - let current = audienceFilters params - root = "/p/" <> pid.toText <> "/rum" - window = filter (\(key, _) -> key `elem` ["from", "to", "since", "environment"]) params - tab = [("tab", Just $ fromMaybe "audience" $ join (lookup "activeTabSlug" params) <|> join (lookup "tab" params))] - addRespHeaders $ div_ [class_ "flex flex-wrap items-center gap-x-4 gap-y-2 text-sm"] do - nav_ [Aria.label_ "Saved audience segments", class_ "flex flex-wrap items-center gap-2"] do - span_ [class_ "text-textWeak"] "Segments" - forM_ segments \(name, HI.AsJsonb filters) -> do - a_ [href_ $ root <> queryStringFrom (tab <> audienceParams filters <> window), class_ $ "rounded px-2 py-1 hover:bg-fillWeak " <> if filters == current then "bg-fillBrand-weak text-textBrand font-semibold" else "text-textStrong"] $ toHtml name - button_ [type_ "button", class_ "btn btn-ghost btn-xs", Aria.label_ $ "Delete segment " <> name, hxDelete_ $ root <> "/segments/" <> toUriStr name <> queryStringFrom params, hxTarget_ "#rum-segments", hxSwap_ "innerHTML"] $ faSprite_ "xmark" "regular" "h-3 w-3" - details_ [class_ "relative"] do - summary_ [class_ "cursor-pointer text-textBrand"] "Save segment" - form_ [class_ "absolute z-20 mt-2 p-3 w-64 rounded-lg border border-strokeWeak bg-bgRaised shadow-lg flex flex-col gap-3", hxPost_ $ root <> "/segments" <> queryStringFrom params, hxTarget_ "#rum-segments", hxSwap_ "innerHTML"] do - label_ [class_ "flex flex-col gap-1"] do - "Segment name" - input_ [name_ "name", required_ "", maxlength_ "100", class_ "input input-sm w-full", placeholder_ "Mobile Safari"] - forM_ ([("app", current.app), ("browser", current.browser), ("os", current.os), ("device", current.device), ("errored", Just $ if current.errored then "true" else "false")] :: [(Text, Maybe Text)]) \(key, value) -> input_ [type_ "hidden", name_ key, value_ $ fromMaybe "" value] - button_ [type_ "submit", class_ "btn btn-primary btn-sm"] "Save segment" - a_ [class_ "text-textBrand hover:underline", href_ $ root <> "/sessions" <> queryStringFrom (audienceParams current <> window)] "View sessions" - a_ [class_ "text-textBrand hover:underline", href_ $ root <> queryStringFrom ([("tab", Just "errors")] <> audienceParams current <> window)] "View errors" - when (any isJust [current.app, current.browser, current.os, current.device] || current.errored) - $ a_ [class_ "text-textWeak hover:underline", href_ $ root <> queryStringFrom (tab <> window)] "Clear filters" - - -segmentsPostH :: Projects.ProjectId -> [(Text, Maybe Text)] -> SegmentForm -> ATAuthCtx (RespHeaders (Html ())) -segmentsPostH pid params form = do - _ <- Projects.sessionAndProject pid - let name = T.strip form.name - filters = audienceFilters $ audienceParams form.filters - when (T.null name || T.length name > 100) $ throwError err400{errBody = "Segment name must contain 1–100 characters."} - Hasql.interpExecute_ [HI.sql|INSERT INTO projects.rum_segments (project_id, name, filters) VALUES (#{pid}, #{name}, #{HI.AsJsonb filters}) ON CONFLICT (project_id, name) DO UPDATE SET filters = EXCLUDED.filters|] - segmentsGetH pid params - - -segmentsDeleteH :: Projects.ProjectId -> Text -> [(Text, Maybe Text)] -> ATAuthCtx (RespHeaders (Html ())) -segmentsDeleteH pid name params = do - _ <- Projects.sessionAndProject pid - Hasql.interpExecute_ [HI.sql|DELETE FROM projects.rum_segments WHERE project_id = #{pid} AND name = #{name}|] - segmentsGetH pid params diff --git a/src/Pages/Telemetry.hs b/src/Pages/Telemetry.hs index ab9f79946..c69eda412 100644 --- a/src/Pages/Telemetry.hs +++ b/src/Pages/Telemetry.hs @@ -752,7 +752,7 @@ metricWidget pid metricName metricType metricUnit source mLabel mTitle mId mExpa { Widget.wType = Widget.WTTimeseriesLine , Widget.title = if isDistribution then (<> " · mean") <$> mTitle else mTitle , Widget.query = Just metricQuery - , Widget.layout = Just $ Widget.Layout{x = Just 0, y = Just 0, w = Just 2, h = Just 1} + , Widget.layout = Just $ Widget.Layout{minWidth = Nothing, x = Just 0, y = Just 0, w = Just 2, h = Just 1} , Widget.unit = Just metricUnit , Widget.eager = Just True , Widget.hideValue = Just True diff --git a/src/Pkg/Components/Widget.hs b/src/Pkg/Components/Widget.hs index c4c12a0d3..0d27c6fa9 100644 --- a/src/Pkg/Components/Widget.hs +++ b/src/Pkg/Components/Widget.hs @@ -76,6 +76,7 @@ data Layout = Layout , y :: Maybe Int , w :: Maybe Int , h :: Maybe Int + , minWidth :: Maybe Int } deriving stock (Generic, Show, THS.Lift) deriving anyclass (Default, FromForm, NFData) @@ -568,7 +569,7 @@ widget_ w' = case w.wType of input_ [type_ "checkbox", class_ "sr-only peer wgt-collapse"] Utils.faSprite_ "chevron-up" "regular" "w-5 h-5 transition-transform peer-checked:rotate-180" -- Nested grid: flex-1 fills remaining space - div_ (class_ "grid-stack grid-stack-preloaded nested-grid flex-1 group-has-[.wgt-collapse:checked]/wgt:hidden" : gridStackAttrs normalizedChildren) $ forM_ normalizedChildren (\wChild -> widget_ (wChild{_isNested = Just True})) + div_ (class_ "grid-stack grid-stack-preloaded nested-grid flex-1 group-has-[.wgt-collapse:checked]/wgt:hidden" : [data_ "stat-grid" "" | not (null normalizedChildren) && all (\child -> child.wType `elem` [WTStat, WTTimeseriesStat]) normalizedChildren] <> gridStackAttrs normalizedChildren) $ forM_ normalizedChildren (\wChild -> widget_ (wChild{_isNested = Just True})) WTTable -> wgtCard_ $ renderTable w WTLogs -> wgtCard_ $ renderLogsWidget w WTServiceMap -> wgtCard_ $ withCardFrame True w (ctaAction w) $ div_ [class_ "h-full overflow-auto p-3"] $ whenJust w.html toHtmlRaw @@ -618,7 +619,9 @@ gridStackAttrs widgets = <> "height:" <> gridRem rows ] + <> [data_ "min-width" (show minWidth) | minWidth > 0] where + minWidth = foldl' max 0 [max 0 pixels * 12 `div` max 1 (fromMaybe defaultWidgetWidth layout.w) | widget <- widgets, layout <- toList widget.layout, pixels <- toList layout.minWidth] rows = if null widgets then 2 else layoutRows widgets @@ -633,22 +636,22 @@ gridStackAttrs widgets = -- -- Missing positions, oversized widths, overlaps, and row packing: -- --- >>> let widgets = [testWidget WTList Layout{x = Just 0, y = Just 2, w = Just 20, h = Just 2}, testWidget WTList Layout{x = Just 0, y = Just 0, w = Just 6, h = Just 1}, testWidget WTList Layout{x = Nothing, y = Nothing, w = Just 6, h = Just 1}] +-- >>> let widgets = [testWidget WTList Layout{minWidth = Nothing, x = Just 0, y = Just 2, w = Just 20, h = Just 2}, testWidget WTList Layout{minWidth = Nothing, x = Just 0, y = Just 0, w = Just 6, h = Just 1}, testWidget WTList Layout{minWidth = Nothing, x = Nothing, y = Nothing, w = Just 6, h = Just 1}] -- >>> map coordinates (normalizeWidgetLayouts widgets) -- [Just (Just 0,Just 0,Just 6,Just 1),Just (Just 6,Just 0,Just 6,Just 1),Just (Just 0,Just 1,Just 12,Just 2)] -- -- Saved visual order wins over YAML array order, and a clamped full-width group -- derives its height from its children: -- --- >>> let lower = testWidget WTList Layout{x = Just 0, y = Just 4, w = Just 6, h = Just 2} --- >>> let golden = (testWidget WTGroup Layout{x = Just 0, y = Just 0, w = Just 20, h = Just 10}){children = Just []} +-- >>> let lower = testWidget WTList Layout{minWidth = Nothing, x = Just 0, y = Just 4, w = Just 6, h = Just 2} +-- >>> let golden = (testWidget WTGroup Layout{minWidth = Nothing, x = Just 0, y = Just 0, w = Just 20, h = Just 10}){children = Just []} -- >>> map coordinates (normalizeWidgetLayouts [lower, golden]) -- [Just (Just 0,Just 0,Just 12,Just 2),Just (Just 0,Just 2,Just 6,Just 2)] -- -- Nested group children are normalized recursively: -- -- >>> let child position = testWidget WTList position --- >>> let groupWidget = (testWidget WTGroup Layout{x = Just 0, y = Just 0, w = Just 12, h = Just 1}){children = Just [child Layout{x = Just 0, y = Just 0, w = Just 8, h = Just 1}, child Layout{x = Just 0, y = Just 0, w = Just 8, h = Just 1}]} +-- >>> let groupWidget = (testWidget WTGroup Layout{minWidth = Nothing, x = Just 0, y = Just 0, w = Just 12, h = Just 1}){children = Just [child Layout{minWidth = Nothing, x = Just 0, y = Just 0, w = Just 8, h = Just 1}, child Layout{minWidth = Nothing, x = Just 0, y = Just 0, w = Just 8, h = Just 1}]} -- >>> fmap coordinates (listToMaybe $ normalizeWidgetLayouts [groupWidget]) -- Just (Just (Just 0,Just 0,Just 12,Just 3)) -- >>> map coordinates (fold $ listToMaybe (normalizeWidgetLayouts [groupWidget]) >>= (.children)) @@ -656,8 +659,8 @@ gridStackAttrs widgets = -- -- A saved four-stat row with shrunken cards still fills its group: -- --- >>> let stat x width height = testWidget WTStat Layout{x = Just x, y = Just 0, w = Just width, h = Just height} --- >>> let overview = (testWidget WTGroup Layout{x = Just 0, y = Just 0, w = Just 12, h = Just 3}){children = Just [stat 0 3 2, stat 3 3 2, stat 6 2 1, stat 9 2 2]} +-- >>> let stat x width height = testWidget WTStat Layout{minWidth = Nothing, x = Just x, y = Just 0, w = Just width, h = Just height} +-- >>> let overview = (testWidget WTGroup Layout{minWidth = Nothing, x = Just 0, y = Just 0, w = Just 12, h = Just 3}){children = Just [stat 0 3 2, stat 3 3 2, stat 6 2 1, stat 9 2 2]} -- >>> map coordinates (fold $ listToMaybe (normalizeWidgetLayouts [overview]) >>= (.children)) -- [Just (Just 0,Just 0,Just 3,Just 2),Just (Just 3,Just 0,Just 3,Just 2),Just (Just 6,Just 0,Just 3,Just 2),Just (Just 9,Just 0,Just 3,Just 2)] -- >>> let html = toStrict $ renderText $ widget_ overview @@ -711,7 +714,7 @@ normalizeWidgetLayouts widgets = else [(y, x) | y <- [0 ..], x <- [0 .. 12 - width]] (placedY, placedX) = fromMaybe (requestedY, requestedX) $ find (isFree occupied width height) candidates rect = (placedX, placedY, width, height) - widget' = normalizedWidget{layout = Just Layout{x = Just placedX, y = Just placedY, w = Just width, h = Just height}} + widget' = normalizedWidget{layout = Just layout{x = Just placedX, y = Just placedY, w = Just width, h = Just height}} in (rect : occupied, (idx, widget') : acc) isFree occupied width height (y, x) = not (any (overlaps (x, y, width, height)) occupied) @@ -1617,7 +1620,7 @@ renderTableWithDataAndParams widget dataRows params = do <> [term "hx-preload" "mousedown"] <> foldMap (\var -> [name_ "dashboard-variable-option", data_ "variable-key" var, data_ "variable-value" value, data_ "variable-label" value, term "hx-on:htmx:before:request" "syncDashboardVariable(this, true)", term "hx-on:htmx:finally:request" "syncDashboardVariable(this, false)"]) action.setVariable - table_ [class_ "table table-zebra table-sm w-full relative", id_ tableId] do + table_ [class_ "dashboard-table table table-zebra table-sm w-full relative", id_ tableId] do sortableTableHead_ widget (find ((== "table-sort") . fst) params >>= snd) tbody_ [] do let maxValues = calculateMaxValues columns dataRows diff --git a/src/Pkg/Rum.hs b/src/Pkg/Rum.hs index dfdc1d6d2..98d832ad2 100644 --- a/src/Pkg/Rum.hs +++ b/src/Pkg/Rum.hs @@ -1,12 +1,10 @@ -module Pkg.Rum (AudienceFilters (..), audienceFilters, audienceParams, audienceSessionsSql, audienceKql) where +module Pkg.Rum (AudienceFilters (..), audienceFilters, audienceSessionsSql, audienceKql) where -import Data.Aeson qualified as AE import Data.List (lookup) import Data.Text qualified as T import Pkg.Parser.Expr (sqlStringLit) import Relude import Utils (kqlQuoted, nonEmptyT) -import Web.FormUrlEncoded (FromForm) data AudienceFilters = AudienceFilters @@ -17,7 +15,6 @@ data AudienceFilters = AudienceFilters , errored :: Bool } deriving stock (Eq, Generic, Show) - deriving anyclass (AE.FromJSON, AE.ToJSON, FromForm) audienceFilters :: [(Text, Maybe Text)] -> AudienceFilters @@ -26,10 +23,6 @@ audienceFilters params = AudienceFilters (param "app") (param "browser") (param param key = nonEmptyT $ join $ lookup ("var-" <> key) params -audienceParams :: AudienceFilters -> [(Text, Maybe Text)] -audienceParams f = [("var-app", f.app), ("var-browser", f.browser), ("var-os", f.os), ("var-device", f.device), ("var-errors", Just $ if f.errored then "true" else "false")] - - -- Ordered rules: embedded browser/OS names must not win over their host family. data AgentRule = Contains (NonEmpty Text) | Tablet | AnyAgent diff --git a/src/Web/Routes.hs b/src/Web/Routes.hs index d7449ff08..6940a70f7 100644 --- a/src/Web/Routes.hs +++ b/src/Web/Routes.hs @@ -77,7 +77,6 @@ import Pages.Projects qualified as ListProjects import Pages.Projects qualified as ManageMembers import Pages.Replay qualified as Replay import Pages.Reports qualified as Reports -import Pages.Rum qualified as Rum import Pages.ServiceMap qualified as ServiceMap import Pages.Settings qualified as Settings import Pages.Share qualified as Share @@ -474,9 +473,8 @@ data CookieProtectedRoutes mode = CookieProtectedRoutes , slackIdentityPost :: mode :- "slack" :> "link" :> Capture "linkId" (UUIDId "slack_link") :> ReqBody '[FormUrlEncoded] Slack.SlackLinkForm :> Post '[HTML] (RespHeaders (Html ())) , slackLinkProjectGet :: mode :- "slack" :> "oauth" :> "callback" :> QPT "code" :> QPT "state" :> LocationRedirect BotUtils.BotResponse , endpointDetailsRedirect :: mode :- "p" :> ProjectId :> "endpoints" :> "details" :> AllQueryParams :> LocationRedirect NoContent - , rumSegmentsGet :: mode :- "p" :> ProjectId :> "rum" :> "segments" :> AllQueryParams :> Get '[HTML] (RespHeaders (Html ())) - , rumSegmentsPost :: mode :- "p" :> ProjectId :> "rum" :> "segments" :> AllQueryParams :> ReqBody '[FormUrlEncoded] Rum.SegmentForm :> Post '[HTML] (RespHeaders (Html ())) - , rumSegmentsDelete :: mode :- "p" :> ProjectId :> "rum" :> "segments" :> Capture "name" Text :> AllQueryParams :> Delete '[HTML] (RespHeaders (Html ())) + , dashboardViewPost :: mode :- "p" :> ProjectId :> "dashboards" :> Capture "dashboard_id" Dashboards.DashboardId :> "views" :> ReqBody '[FormUrlEncoded] Dashboards.DashboardViewForm :> Post '[HTML] (RespHeaders (Html ())) + , dashboardViewDelete :: mode :- "p" :> ProjectId :> "dashboards" :> Capture "dashboard_id" Dashboards.DashboardId :> "views" :> Capture "name" Text :> AllQueryParams :> Delete '[HTML] (RespHeaders (Html ())) , rumRedirect :: mode :- "p" :> ProjectId :> "rum" :> CaptureAll "view" Text :> AllQueryParams :> LocationRedirect NoContent , dashboardNewGet :: mode :- "p" :> ProjectId :> "dashboards" :> "new" :> Get '[HTML] (RespHeaders Dashboards.DashboardNew) , dashboardsGet :: mode :- "p" :> ProjectId :> "dashboards" :> Capture "dashboard_id" Dashboards.DashboardId :> QPT "file" :> QPT "from" :> QPT "to" :> QPT "since" :> HXRequest :> AllQueryParams :> Get '[HTML] (RespHeaders (PageCtx Dashboards.DashboardGet)) @@ -981,9 +979,8 @@ cookieProtectedServer = , slackLinkProjectGet = Slack.linkProjectGetH , endpointDetailsRedirect = Dashboards.entrypointRedirectGetH "endpoint-stats.yaml" "Endpoint Analytics" ["endpoints", "http", "events"] , rumRedirect = Dashboards.rumRedirectH - , rumSegmentsGet = Rum.segmentsGetH - , rumSegmentsPost = Rum.segmentsPostH - , rumSegmentsDelete = Rum.segmentsDeleteH + , dashboardViewPost = Dashboards.dashboardViewPostH + , dashboardViewDelete = Dashboards.dashboardViewDeleteH , dashboardsGet = Dashboards.dashboardGetH , dashboardNewGet = Dashboards.dashboardNewGetH , dashboardsGetList = Dashboards.dashboardsGetH diff --git a/static/migrations/0255_dashboard_saved_views.sql b/static/migrations/0255_dashboard_saved_views.sql new file mode 100644 index 000000000..4d5531131 --- /dev/null +++ b/static/migrations/0255_dashboard_saved_views.sql @@ -0,0 +1,24 @@ +BEGIN; + +ALTER TABLE projects.dashboards ADD COLUMN saved_views jsonb[] NOT NULL DEFAULT '{}'; + +-- Segments could be saved before a project had opened its RUM dashboard. +INSERT INTO projects.dashboards (project_id, created_by, base_template, title, teams) +SELECT DISTINCT s.project_id, '00000000-0000-0000-0000-000000000000'::uuid, 'rum.yaml', 'RUM', + ARRAY(SELECT id FROM projects.teams WHERE project_id = s.project_id AND is_everyone AND deleted_at IS NULL) +FROM projects.rum_segments s +WHERE NOT EXISTS (SELECT 1 FROM projects.dashboards d WHERE d.project_id = s.project_id AND d.base_template = 'rum.yaml'); + +UPDATE projects.dashboards d SET saved_views = s.views +FROM ( + SELECT project_id, array_agg(jsonb_build_object( + 'name', name, + 'variables', jsonb_strip_nulls(filters - 'errored') || jsonb_build_object('errors', COALESCE(filters->>'errored', 'false')) + ) ORDER BY name) AS views + FROM projects.rum_segments GROUP BY project_id +) s +WHERE d.project_id = s.project_id AND d.base_template = 'rum.yaml'; + +DROP TABLE projects.rum_segments; + +COMMIT; diff --git a/static/public/assets/css/tailwind.css b/static/public/assets/css/tailwind.css index 1463f542e..73dbeacb8 100644 --- a/static/public/assets/css/tailwind.css +++ b/static/public/assets/css/tailwind.css @@ -2588,36 +2588,29 @@ table td a[href]:not(.btn):not([data-no-preview]) { --tooltip-z: 100; --tooltip- container: dashboard / inline-size; } -[data-rum-audience] table { +.dashboard-table { table-layout: fixed; } -[data-rum-audience] :is(th, td) { +.dashboard-table :is(th, td) { overflow-wrap: anywhere; white-space: normal; } -[data-rum-audience] th > div, -[data-rum-audience] th > div > span { +.dashboard-table th > div, +.dashboard-table th > div > span { min-width: 0; } @container dashboard (max-width: 400px) { - [data-rum-audience] > .grid-stack { - width: 100%; - margin-inline: 0 !important; - } - [data-rum-audience] .grid-stack-item > .grid-stack-item-content { - inset: auto !important; - } - [data-rum-audience] :is(th, td) { + .dashboard-table :is(th, td) { padding-inline: 0.375rem; } - [data-rum-audience] .nested-grid { + [data-stat-grid].grid-stack { display: grid !important; grid-template-columns: repeat(2, minmax(0, 1fr)); width: 100% !important; height: auto !important; margin-inline: 0 !important; } - [data-rum-audience] .nested-grid.grid-stack > .grid-stack-item:not([data-mobile-autofit]) { + [data-stat-grid].grid-stack > .grid-stack-item { width: auto !important; min-width: 0; height: 7.5rem !important; @@ -2650,6 +2643,8 @@ table td a[href]:not(.btn):not([data-no-preview]) { --tooltip-z: 100; --tooltip- @container dashboard (max-width: 400px) { /* Override GridStack absolute positioning with flex flow on mobile */ .grid-stack { + width: 100%; + margin-inline: 0 !important; display: flex !important; flex-direction: column; gap: 0.5rem; @@ -2662,6 +2657,7 @@ table td a[href]:not(.btn):not([data-no-preview]) { --tooltip-z: 100; --tooltip- width: 100% !important; } .grid-stack-item > .grid-stack-item-content { + inset: auto !important; position: relative !important; height: 100% !important; } diff --git a/static/public/dashboards/rum.yaml b/static/public/dashboards/rum.yaml index 063b1dd1d..5d3a17038 100644 --- a/static/public/dashboards/rum.yaml +++ b/static/public/dashboards/rum.yaml @@ -59,6 +59,7 @@ widgets: [] tabs: - name: Overview + variables: [app] icon: web widgets: - type: group @@ -193,6 +194,7 @@ tabs: GROUP BY attributes___session___id ORDER BY MAX(timestamp) DESC LIMIT 50 - name: Web Vitals + variables: [app] icon: gauge widgets: - type: table @@ -363,6 +365,7 @@ tabs: FROM shown GROUP BY page ORDER BY SUM(total) DESC LIMIT 50 - name: Errors + variables: [app, browser, os, device, errors] icon: triangle-exclamation widgets: - type: timeseries_stat @@ -432,6 +435,7 @@ tabs: ) AS groups ORDER BY n DESC - name: Audience + variables: [app, browser, os, device, errors] icon: users widgets: - type: group @@ -470,7 +474,7 @@ tabs: - type: table title: Browsers description: Select a row to filter this audience. Share is within the current filters; error rate is within each group. - layout: { x: 0, w: 4, h: 5 } + layout: { x: 0, w: 4, h: 5, min_width: 500 } on_row_click: set_variable: browser navigate_to_tab: Audience @@ -504,7 +508,7 @@ tabs: - type: table title: Operating systems description: Select a row to filter this audience. Share is within the current filters; error rate is within each group. - layout: { x: 4, w: 4, h: 5 } + layout: { x: 4, w: 4, h: 5, min_width: 500 } on_row_click: set_variable: os navigate_to_tab: Audience @@ -538,7 +542,7 @@ tabs: - type: table title: Devices description: Select a row to filter this audience. Share is within the current filters; error rate is within each group. - layout: { x: 8, w: 4, h: 5 } + layout: { x: 8, w: 4, h: 5, min_width: 500 } on_row_click: set_variable: device navigate_to_tab: Audience diff --git a/test/integration/Pages/DashboardWidgetsSpec.hs b/test/integration/Pages/DashboardWidgetsSpec.hs index 614614cb9..fcd25d253 100644 --- a/test/integration/Pages/DashboardWidgetsSpec.hs +++ b/test/integration/Pages/DashboardWidgetsSpec.hs @@ -16,12 +16,12 @@ import Data.Aeson qualified as AE import Data.Aeson.KeyMap qualified as KM import Data.ByteString.Lazy qualified as LBS import Data.Default (def) -import Data.List (lookup) import Data.Effectful.Hasql qualified as Hasql +import Data.List (lookup) import Data.Map.Strict qualified as Map import Data.Pool (withResource) -import Data.Text qualified as T import Data.ProtoLens (defMessage) +import Data.Text qualified as T import Data.Time (addUTCTime) import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds) import Data.Time.Format.ISO8601 (iso8601Show) @@ -30,6 +30,7 @@ import Database.PostgreSQL.Simple qualified as PG import Database.PostgreSQL.Simple.Newtypes (Aeson (..)) import Database.PostgreSQL.Simple.SqlQQ qualified as SqlQQ import Database.PostgreSQL.Simple.Types (Query (..)) +import Effectful.Error.Static (catchError) import GHC.Clock (getMonotonicTime) import Lucid (renderText, toHtml) import Models.Apis.Endpoints qualified as Endpoints @@ -37,6 +38,7 @@ import Models.Projects.DashboardTemplates (dashboardTemplatesCompiled, loadDashb import Models.Projects.Dashboards (DashboardVM (..)) import Models.Projects.Dashboards qualified as DashboardModel import Models.Projects.Projects (Session (..)) +import Models.Projects.Projects qualified as Projects import Network.GRPC.Common.Protobuf (Proto (..)) import Opentelemetry.OtlpServer qualified as OtlpServer import Pages.BodyWrapper (BWConfig (..), NavigationResponse (..), PageCtx (..)) @@ -45,7 +47,6 @@ import Pages.Dashboards (DashboardFilters (..)) import Pages.Dashboards qualified as Dashboards import Pages.Issues qualified as Issues import Pages.LogExplorer.Log qualified as Log -import Pages.Rum qualified as Rum import Pkg.Components.Widget qualified as Widget import Pkg.Parser (browserKql, parseQueryToAST) import Pkg.QueryCache qualified as QueryCache @@ -162,6 +163,63 @@ reorder wid x y w h = Map.singleton wid (def :: Dashboards.WidgetReorderItem){Da spec :: Spec spec = sequential $ aroundAll withTestResources do + describe "Shared dashboard views" do + it "savesArbitraryVariables_withoutFreezingTheTemplate_andKeepsTabAndWindow" \tr -> do + did <- newDashboard tr "rum.yaml" "Shared views" + let params = [("var-browser", Just "Safari"), ("since", Just "7D"), ("activeTabSlug", Just "errors")] + render = toStrict . renderText + root = "/p/" <> testPid.toText <> "/dashboards/" <> did.toText <> "/tab/errors" + form <- either (fail . toString) pure $ urlDecodeAsForm @Dashboards.DashboardViewForm "name=Mobile+Safari&var-browser=Safari&since=7D&activeTabSlug=errors" + (_, saved) <- testServant tr $ Dashboards.dashboardViewPostH testPid did form + linkParams root (render saved) `shouldSatisfy` any (\ps -> lookup "var-browser" ps == Just "Safari" && lookup "since" ps == Just "7D") + (vm, _) <- runAuthHandler tr $ Dashboards.getDashAndVM testPid did Nothing + vm.schema `shouldSatisfy` isNothing + map (.name) (V.toList vm.savedViews) `shouldBe` ["Mobile Safari"] + (_, deleted) <- testServant tr $ Dashboards.dashboardViewDeleteH testPid did "Mobile Safari" params + render deleted `shouldNotSatisfy` T.isInfixOf ">Mobile Safari" + + it "rendersConfiguredViews_andTabScopedVariables_onAnyDashboard" \tr -> do + did <- newDashboard tr "overview.yaml" "Shared views" + _ <- testServant tr $ Dashboards.dashboardYamlPutH testPid did $ Dashboards.YamlForm "title: Shared views\nwidgets: []\nvariables:\n - {key: service, title: Service, type: values, options: [[api]]}\n - {key: region, title: Region, type: values, options: [[eu]]}\nviews:\n - {name: Europe API, variables: {service: api, region: eu}}\ntabs:\n - {name: Overview, variables: [service], widgets: []}\n - {name: Errors, variables: [service, region], widgets: []}\n" + (_, page) <- testServant tr $ Dashboards.dashboardTabGetH testPid did "overview" Nothing Nothing Nothing (Just "24H") Nothing Nothing [] + let html = toStrict $ renderText $ toHtml page + html `shouldSatisfy` T.isInfixOf ">Europe API" + html `shouldSatisfy` T.isInfixOf "name=\"service\"" + html `shouldNotSatisfy` T.isInfixOf "name=\"region\"" + (_, errors) <- testServant tr $ Dashboards.dashboardTabGetH testPid did "errors" Nothing Nothing Nothing (Just "24H") (Just "true") Nothing [] + toStrict (renderText $ toHtml errors) `shouldSatisfy` T.isInfixOf "name=\"region\"" + form <- either (fail . toString) pure $ urlDecodeAsForm @Dashboards.DashboardViewForm "name=US+worker&var-service=worker&var-region=us&var-unknown=ignored&since=24H&activeTabSlug=errors" + _ <- testServant tr $ Dashboards.dashboardViewPostH testPid did form + (saved, _) <- runAuthHandler tr $ Dashboards.getDashAndVM testPid did Nothing + map (.variables) (V.toList saved.savedViews) `shouldBe` [Map.fromList [("service", "worker"), ("region", "us")]] + (_, reloaded) <- testServant tr $ Dashboards.dashboardTabGetH testPid did "overview" Nothing Nothing Nothing (Just "24H") (Just "true") Nothing [("since", Just "24H")] + let root = "/p/" <> testPid.toText <> "/dashboards/" <> did.toText <> "/tab/overview" + linkParams root (toStrict $ renderText $ toHtml reloaded) `shouldSatisfy` any (\ps -> lookup "var-service" ps == Just "worker" && lookup "var-region" ps == Just "us" && lookup "since" ps == Just "24H") + + it "rejectsInvalidNames_andCrossProjectChanges" \tr -> do + did <- newDashboard tr "rum.yaml" "Private views" + other <- createTestProject tr "Other saved views" + let save pid name = Dashboards.dashboardViewPostH pid did (Dashboards.DashboardViewForm name []) + status action = runAuthHandler tr $ catchError @Servant.ServerError (action $> (200 :: Int)) (\_ err -> pure err.errHTTPCode) + status (save testPid " ") `shouldReturn` 400 + status (save testPid $ T.replicate 101 "x") `shouldReturn` 400 + status (save other "Wrong project") `shouldReturn` 404 + status (Dashboards.dashboardViewDeleteH other did "Missing" []) `shouldReturn` 404 + assertNonMemberDenied tr other (save other "Denied") + assertNonMemberDenied tr other (Dashboards.dashboardViewDeleteH other did "Denied" []) + + it "migratesLegacySegments_evenWithoutAnExistingDashboard" \tr -> do + pid <- createTestProject tr "Unopened RUM dashboard" + did <- newDashboard tr "rum.yaml" "Existing RUM dashboard" + migration <- Query <$> readFileBS "static/migrations/0255_dashboard_saved_views.sql" + withResource tr.trPool \conn -> do + PG.execute_ conn "ALTER TABLE projects.dashboards DROP COLUMN saved_views" `shouldReturn` 0 + _ <- PG.execute_ conn "CREATE TABLE projects.rum_segments (project_id uuid, name text, filters jsonb)" + _ <- PG.execute conn "INSERT INTO projects.rum_segments VALUES (?, 'Safari', '{\"browser\":\"Safari\",\"app\":null,\"errored\":true}'), (?, 'Safari', '{\"browser\":\"Safari\",\"errored\":false}')" (testPid, pid) + _ <- PG.execute_ conn migration + rows <- PG.query conn "SELECT project_id, saved_views[1]->>'name', saved_views[1]->'variables' FROM projects.dashboards WHERE id = ? OR project_id = ? ORDER BY project_id" (did, pid) + (rows :: [(Projects.ProjectId, Text, Aeson AE.Value)]) `shouldMatchList` [(testPid, "Safari", Aeson $ AE.object ["browser" AE..= ("Safari" :: Text), "errors" AE..= ("true" :: Text)]), (pid, "Safari", Aeson $ AE.object ["browser" AE..= ("Safari" :: Text), "errors" AE..= ("false" :: Text)])] + describe "Widget PNG export" do it "tablePng_aggregateChangesAlterRenderedImage" \tr -> for_ [Nothing, Just Widget.PngSlack] \profile -> do @@ -1419,7 +1477,7 @@ spec = sequential $ aroundAll withTestResources do html `shouldSatisfy` T.isInfixOf "id=\"dashboard-tabs-content\"" html `shouldSatisfy` T.isInfixOf "id=\"dashboard-tabs-container\"" html `shouldSatisfy` T.isInfixOf "hx-swap-oob=\"outerMorph\"" - html `shouldNotSatisfy` T.isInfixOf "id=\"dashboard-variables\"" + html `shouldSatisfy` T.isInfixOf "id=\"dashboard-variables\"" html `shouldSatisfy` T.isInfixOf "tab-active" html `shouldSatisfy` T.isInfixOf "hx-push-url=\"true\"" html `shouldNotSatisfy` T.isInfixOf " do - pid <- createTestProject tr "RUM segments" - otherPid <- createTestProject tr "Other RUM segments" - let params = [("var-app", Just "storefront"), ("var-browser", Just "Safari"), ("var-device", Just "Mobile"), ("since", Just "7D"), ("tab", Just "errors")] - root = "/p/" <> pid.toText <> "/rum" - render = toStrict . renderText - form <- either (fail . toString) pure $ urlDecodeAsForm @Rum.SegmentForm "name=Mobile+Safari&app=storefront&browser=Safari&device=Mobile&os=&errored=false" - (_, saved) <- testServant tr $ Rum.segmentsPostH pid params form - let links = linkParams root (render saved) - links `shouldSatisfy` any (\ps -> lookup "var-browser" ps == Just "Safari" && lookup "since" ps == Just "7D" && lookup "tab" ps == Just "errors") - (_, reloaded) <- testServant tr $ Rum.segmentsGetH pid [] - render reloaded `shouldSatisfy` T.isInfixOf ">Mobile Safari" - (_, other) <- testServant tr $ Rum.segmentsGetH otherPid [] - render other `shouldNotSatisfy` T.isInfixOf ">Mobile Safari" - (_, deleted) <- testServant tr $ Rum.segmentsDeleteH pid "Mobile Safari" [] - render deleted `shouldNotSatisfy` T.isInfixOf ">Mobile Safari" - -- /rum and its older /rum/dashboard open the dashboard; /rum/sessions the Explorer. Links from -- emails and alerts carry a window, which both must keep. it "rumRedirects_openTheDashboardOrSessions_andKeepTheWindow" \tr -> do - let location view ps = runAuthHandler tr (Dashboards.rumRedirectH testPid view ps) <&> \h -> case lookupResponseHeader @"Location" h of - Header l -> l - _ -> "" + let location view ps = + runAuthHandler tr (Dashboards.rumRedirectH testPid view ps) <&> \h -> case lookupResponseHeader @"Location" h of + Header l -> l + _ -> "" window = [("since", Just "7D"), ("environment", Just "prod")] dashboard <- location [] window dashboard `shouldSatisfy` T.isInfixOf ("/p/" <> testPid.toText <> "/dashboards/") diff --git a/test/integration/Pages/DashboardsSpec.hs b/test/integration/Pages/DashboardsSpec.hs index 0273c0349..616e000c5 100644 --- a/test/integration/Pages/DashboardsSpec.hs +++ b/test/integration/Pages/DashboardsSpec.hs @@ -97,6 +97,7 @@ spec = sequential $ aroundAll withTestResources do , title = "t" , teams = V.empty , filePath = Nothing + , savedViews = V.empty , gitSyncId = Nothing , fileSha = Nothing } @@ -137,6 +138,7 @@ spec = sequential $ aroundAll withTestResources do , title = "t" , teams = V.empty , filePath = Nothing + , savedViews = V.empty , gitSyncId = Nothing , fileSha = Nothing } @@ -151,7 +153,7 @@ spec = sequential $ aroundAll withTestResources do html `shouldSatisfy` (not . T.isInfixOf "remove .tab-active") it "reports a bounded dashboard-settlement sample after initial and tab renders" \_ -> do - let dash = (def :: DashboardModel.Dashboard){DashboardModel.tabs = Just [(def :: DashboardModel.Tab){DashboardModel.name = "Overview"}]} + let dash = (def :: DashboardModel.Dashboard){DashboardModel.tabs = Just [((def :: DashboardModel.Tab){DashboardModel.name = "Overview"} :: DashboardModel.Tab)]} vm = DashboardVM { id = UUIDId UUID.nil @@ -167,6 +169,7 @@ spec = sequential $ aroundAll withTestResources do , title = "Performance sample" , teams = V.empty , filePath = Nothing + , savedViews = V.empty , gitSyncId = Nothing , fileSha = Nothing } diff --git a/test/integration/Pages/GitSyncSpec.hs b/test/integration/Pages/GitSyncSpec.hs index b5add5aa6..bb6e063a7 100644 --- a/test/integration/Pages/GitSyncSpec.hs +++ b/test/integration/Pages/GitSyncSpec.hs @@ -241,6 +241,7 @@ createDash tr title tags = do , Dashboards.title = title , Dashboards.teams = V.empty , Dashboards.filePath = Nothing + , Dashboards.savedViews = V.empty , Dashboards.gitSyncId = Nothing , Dashboards.fileSha = Nothing }