Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions docs/dashboard-views.md
Original file line number Diff line number Diff line change
@@ -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.
63 changes: 53 additions & 10 deletions e2e/tests/rum-audience.spec.ts
Original file line number Diff line number Diff line change
@@ -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),
Expand All @@ -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();
Expand Down Expand Up @@ -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}';`);
}
});
3 changes: 1 addition & 2 deletions monoscope.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -381,7 +382,6 @@ library
Pages.Projects
Pages.Replay
Pages.Reports
Pages.Rum
Pages.ServiceMap
Pages.Settings
Pages.Share
Expand Down Expand Up @@ -1029,7 +1029,6 @@ test-suite test-dev
Pages.Projects
Pages.Replay
Pages.Reports
Pages.Rum
Pages.ServiceMap
Pages.Settings
Pages.Share
Expand Down
49 changes: 46 additions & 3 deletions src/Models/Projects/Dashboards.hs
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
module Models.Projects.Dashboards (
Dashboard (..),
DashboardVM (..),
DashboardView (..),
variablesForTab,
updateSavedView,
deleteSavedView,
mkDashboardVM,
DashboardId,
readDashboardFile,
Expand Down Expand Up @@ -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)
Expand All @@ -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]
Expand All @@ -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)
Expand Down Expand Up @@ -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]
}
Expand All @@ -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
Expand All @@ -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]
Expand Down Expand Up @@ -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} |]

Expand Down
Loading
Loading