Skip to content

Commit 05de9f6

Browse files
authored
Check in example dashboards. (#44)
Add example dashboards that render using the provided example config. We want these dashboards to work out of the box for customers and not become stale, so we also provide basic end-to-end tests that verify that each panel of each dashboard loads and renders a non-empty visualization.
1 parent 3a382aa commit 05de9f6

15 files changed

Lines changed: 5951 additions & 3 deletions

README.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,50 @@ Each log entry contains the full audit log JSON. The values are `[timestamp, bod
142142

143143
</details>
144144

145+
## Example Dashboards
146+
147+
The example stack includes pre-provisioned Grafana dashboards that visualize metrics and audit logs provided by the receivers in this repo. These dashboards are built around the configuration used by the Docker Compose example. To use them for a separate deployment, you'll need to configure OpenTelemetry, Grafana, and Loki to match the dashboards:
148+
149+
- **Prometheus datasource**: set `timeInterval` to the scrape interval of your Prometheus target (60s for the example configuration).
150+
- **Loki index labels**: The audit logs dashboard uses variables backed by Loki stream labels (`operation_id`, `actor_kind`, `actor_silo_user_id`). These require the `transform/promote-attrs` processor in the collector config and the `otlp_config` section in the Loki config. See [collector/config.example.yaml](collector/config.example.yaml) and [example/loki.yaml](example/loki.yaml).
151+
- **Datasource UIDs**: The dashboards reference datasources by UID (`prometheus` and `loki`). The provisioned datasources must set matching UIDs.
152+
153+
Alternatively, you can adjust the dashboards to match your configuration.
154+
155+
### Installing dashboards in an existing Grafana instance
156+
157+
Download the JSON files from [example/grafana/dashboards](example/grafana/dashboards/) and import them via the Grafana UI (**Dashboards > New > Import**) or the [Grafana API](https://grafana.com/docs/grafana/latest/developer-resources/api-reference/http-api/dashboard/).
158+
159+
### Testing dashboards
160+
161+
The dashboards include end-to-end tests that verify every panel renders data. The tests use Playwright to load each dashboard in a headless browser and check that all panels have non-blank content. These tests don't verify correctness, but will catch dashboards that are fully broken. Note that tests require running the monitoring stack against a real Oxide rack, and don't run on CI. If you update a dashboard or build a new release of the components after an upstream change, run the tests manually to verify that the dashboards are still working.
162+
163+
To run the tests, start the example stack and wait ~3 minutes for data to accumulate:
164+
165+
```bash
166+
# Start the stack
167+
cd example
168+
OXIDE_HOST=... OXIDE_TOKEN=... \
169+
docker compose up --build -d
170+
171+
# Wait for data (~3 minutes for rate() to have enough points)
172+
173+
# Run the tests
174+
cd ..
175+
npm install
176+
npx playwright install chromium
177+
npm run test:e2e
178+
```
179+
180+
To run against a different Grafana instance:
181+
182+
```bash
183+
GRAFANA_URL=https://grafana.example.com \
184+
GRAFANA_USER=admin \
185+
GRAFANA_PASSWORD=secret \
186+
npm run test:e2e
187+
```
188+
145189
## Contributing
146190

147191
See link:CONTRIBUTING.adoc[CONTRIBUTING.adoc] for development and release instructions.

collector/config.example.yaml

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@ receivers:
1010
# host: ${env:OXIDE_HOST}
1111
# token: ${env:OXIDE_TOKEN}
1212
metric_patterns:
13-
- virtual_machine:.*
14-
- virtual_disk:.*
1513
- hardware_component:.*
14+
- http_service:.*
15+
- instance_network_interface:.*
16+
- virtual_disk:.*
17+
- virtual_machine:.*
1618
add_labels: true
1719
add_utilization_metrics: true
1820
collection_interval: 60s
@@ -24,6 +26,16 @@ processors:
2426
send_batch_size: 1000
2527
send_batch_max_size: 1000
2628

29+
# Promote audit log fields to resource attributes so Loki can index them.
30+
transform/promote-attrs:
31+
log_statements:
32+
- context: log
33+
statements:
34+
- set(resource.attributes["operation_id"], body["operation_id"])
35+
- set(resource.attributes["actor_kind"], body["actor"]["kind"])
36+
- set(resource.attributes["actor_silo_user_id"], body["actor"]["kind"]) where body["actor"]["silo_user_id"] == nil
37+
- set(resource.attributes["actor_silo_user_id"], body["actor"]["silo_user_id"]) where body["actor"]["silo_user_id"] != nil
38+
2739
exporters:
2840
otlp_http/loki:
2941
endpoint: http://loki:3100/otlp
@@ -55,5 +67,5 @@ service:
5567

5668
logs:
5769
receivers: [oxideauditlogs]
58-
processors: [batch/logs]
70+
processors: [transform/promote-attrs, batch/logs]
5971
exporters: [otlp_http/loki]

example/docker-compose.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,15 @@ services:
3939

4040
grafana:
4141
image: grafana/grafana:latest
42+
environment:
43+
- GF_AUTH_ANONYMOUS_ENABLED=true
44+
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
4245
ports:
4346
- "3000:3000"
4447
volumes:
4548
- ./grafana/datasources.yaml:/etc/grafana/provisioning/datasources/datasources.yaml:ro
49+
- ./grafana/dashboards.yaml:/etc/grafana/provisioning/dashboards/dashboards.yaml:ro
50+
- ./grafana/dashboards:/etc/grafana/dashboards:ro
4651
depends_on:
4752
- prometheus
4853
- loki

example/e2e/dashboards.spec.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { test, expect } from "@playwright/test";
2+
import * as fs from "fs";
3+
import * as path from "path";
4+
5+
const dashboardDir = path.join(__dirname, "../grafana/dashboards");
6+
7+
// Allow specified panels and sections to be empty, e.g. because
8+
// metrics aren't available or are otherwise expected to be empty.
9+
const allowEmptyPanels = new Set(["5xx error rate"]);
10+
const allowEmptySections = new Set(["sled-agent"]);
11+
12+
const dashboards = fs
13+
.readdirSync(dashboardDir)
14+
.filter((f) => f.endsWith(".json"))
15+
.map((f) => {
16+
const content = JSON.parse(
17+
fs.readFileSync(path.join(dashboardDir, f), "utf-8"),
18+
);
19+
let section = "";
20+
const panels = (content.panels || [])
21+
.map((p: { id: number; title: string; type: string }) => {
22+
if (p.type === "row") {
23+
section = p.title;
24+
return null;
25+
}
26+
return { id: p.id, title: p.title, section };
27+
})
28+
.filter(Boolean) as { id: number; title: string; section: string }[];
29+
return {
30+
file: f,
31+
uid: content.uid as string,
32+
title: content.title as string,
33+
panels,
34+
};
35+
});
36+
37+
for (const dashboard of dashboards) {
38+
test(`${dashboard.title}: all ${dashboard.panels.length} panels render data`, async ({
39+
page,
40+
}) => {
41+
await page.goto(`/d/${dashboard.uid}`);
42+
await page.waitForLoadState("networkidle");
43+
44+
// Scroll to the bottom to force Grafana to render all panels.
45+
for (let attempt = 0; attempt < 20; attempt++) {
46+
await page.evaluate(() => window.scrollBy(0, 1000));
47+
await page.waitForTimeout(300);
48+
const currentCount = await page.locator("[data-viz-panel-key]").count();
49+
if (currentCount === dashboard.panels.length) {
50+
break;
51+
}
52+
}
53+
54+
// Wait for panels to finish loading.
55+
await page.waitForLoadState("networkidle");
56+
await expect(page.locator('[aria-label="Panel loading bar"]')).toHaveCount(
57+
0,
58+
{ timeout: 30_000 },
59+
);
60+
61+
// Assert the expected number of panels are present.
62+
const panelLocators = page.locator("[data-viz-panel-key]");
63+
await expect(panelLocators).toHaveCount(dashboard.panels.length, {
64+
timeout: 5_000,
65+
});
66+
67+
// Assert each panel has rendered content. For panels rendered
68+
// with <canvas>, the canvas shouldn't be empty. For tables,
69+
// there should be >0 rows.
70+
for (let i = 0; i < dashboard.panels.length; i++) {
71+
const panel = panelLocators.nth(i);
72+
const key = await panel.getAttribute("data-viz-panel-key");
73+
const meta = dashboard.panels.find((p) => `panel-${p.id}` === key);
74+
75+
if (
76+
meta &&
77+
(allowEmptyPanels.has(meta.title) ||
78+
allowEmptySections.has(meta.section))
79+
) {
80+
continue;
81+
}
82+
83+
const hasCanvas = (await panel.locator("canvas").count()) > 0;
84+
if (hasCanvas) {
85+
const isBlank = await panel
86+
.locator("canvas")
87+
.first()
88+
.evaluate((canvas: HTMLCanvasElement) => {
89+
const ctx = canvas.getContext("2d");
90+
if (!ctx) {
91+
return true;
92+
}
93+
const data = ctx.getImageData(
94+
0,
95+
0,
96+
canvas.width,
97+
canvas.height,
98+
).data;
99+
return data.every((v) => v === 0);
100+
});
101+
expect(isBlank, `panel ${key} canvas should not be blank`).toBe(false);
102+
} else {
103+
const content = panel.locator(
104+
".unwrapped-log-line, .rdg-row:not(.rdg-header-row)",
105+
);
106+
await expect(
107+
content.first(),
108+
`panel ${key} should have log lines or table rows`,
109+
).toBeAttached({ timeout: 10_000 });
110+
}
111+
}
112+
});
113+
}

example/e2e/playwright.config.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { defineConfig } from "@playwright/test";
2+
3+
const user = process.env.GRAFANA_USER;
4+
const password = process.env.GRAFANA_PASSWORD;
5+
6+
export default defineConfig({
7+
testDir: ".",
8+
timeout: 30_000,
9+
use: {
10+
baseURL: process.env.GRAFANA_URL || "http://localhost:3000",
11+
extraHTTPHeaders:
12+
user && password
13+
? {
14+
Authorization: `Basic ${Buffer.from(`${user}:${password}`).toString("base64")}`,
15+
}
16+
: undefined,
17+
},
18+
});

example/grafana/dashboards.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
apiVersion: 1
2+
providers:
3+
- name: oxide
4+
type: file
5+
options:
6+
path: /etc/grafana/dashboards

0 commit comments

Comments
 (0)