Skip to content

Commit abdafb3

Browse files
committed
Add file-based dashboard provisioner
1 parent 6936ef8 commit abdafb3

6 files changed

Lines changed: 463 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hyperdx/api": minor
3+
---
4+
5+
feat: add file-based dashboard provisioner that watches a directory for JSON files and upserts dashboards into MongoDB

packages/api/docs/auto_provision/AUTO_PROVISION.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,62 @@ services:
163163
For more complex configurations, you can use environment files or Docker secrets
164164
to manage these values.
165165
166+
## Dashboard Provisioning
167+
168+
HyperDX supports file-based dashboard provisioning, similar to Grafana's
169+
provisioning system. A background process watches a directory for `.json` files
170+
and upserts dashboards into MongoDB, matched by name for idempotency.
171+
172+
### Environment Variables
173+
174+
| Variable | Required | Default | Description |
175+
| ----------------------------------- | -------- | ------- | --------------------------------------------------------------- |
176+
| `DASHBOARD_PROVISIONER_DIR` | Yes | — | Directory to watch for `.json` dashboard files |
177+
| `DASHBOARD_PROVISIONER_INTERVAL` | No | `30000` | Sync interval in milliseconds (minimum 1000) |
178+
| `DASHBOARD_PROVISIONER_TEAM_ID` | No\* | — | Scope provisioning to a specific team ID |
179+
| `DASHBOARD_PROVISIONER_ALL_TEAMS` | No\* | `false` | Set to `true` to provision dashboards to all teams |
180+
181+
\*One of `DASHBOARD_PROVISIONER_TEAM_ID` or `DASHBOARD_PROVISIONER_ALL_TEAMS=true`
182+
is required when `DASHBOARD_PROVISIONER_DIR` is set.
183+
184+
### Dashboard JSON Format
185+
186+
Each `.json` file in the provisioner directory should contain a dashboard object
187+
with at minimum a `name` and `tiles` array:
188+
189+
```json
190+
{
191+
"name": "My Dashboard",
192+
"tiles": [
193+
{
194+
"id": "tile-1",
195+
"x": 0,
196+
"y": 0,
197+
"w": 6,
198+
"h": 4,
199+
"config": {
200+
"name": "Request Count",
201+
"source": "Metrics",
202+
"displayType": "line",
203+
"select": [{ "aggFn": "count" }]
204+
}
205+
}
206+
],
207+
"tags": ["provisioned"]
208+
}
209+
```
210+
211+
### Behavior
212+
213+
- Dashboards are matched by name and team for idempotency
214+
- Provisioned dashboards are flagged with `provisioned: true` so they never
215+
overwrite user-created dashboards with the same name
216+
- Removing a file from the directory does **not** delete the dashboard from
217+
MongoDB (safe by default)
218+
- Files are validated against the `DashboardWithoutIdSchema` Zod schema; invalid
219+
files are skipped with a warning
220+
221+
166222
## Note on Security
167223

168224
While this feature is convenient for development and initial setup, be careful
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import fs from 'fs';
2+
import os from 'os';
3+
import path from 'path';
4+
5+
import { createTeam } from '@/controllers/team';
6+
import { readDashboardFiles, syncDashboards } from '@/dashboardProvisioner';
7+
import { clearDBCollections, closeDB, connectDB, makeTile } from '@/fixtures';
8+
import Dashboard from '@/models/dashboard';
9+
10+
describe('dashboardProvisioner', () => {
11+
let tmpDir: string;
12+
13+
beforeAll(async () => {
14+
await connectDB();
15+
});
16+
17+
beforeEach(() => {
18+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hdx-dash-test-'));
19+
});
20+
21+
afterEach(async () => {
22+
fs.rmSync(tmpDir, { recursive: true, force: true });
23+
await clearDBCollections();
24+
});
25+
26+
afterAll(async () => {
27+
await closeDB();
28+
});
29+
30+
describe('readDashboardFiles', () => {
31+
it('returns empty array for non-existent directory', () => {
32+
const result = readDashboardFiles('/non/existent/path');
33+
expect(result).toEqual([]);
34+
});
35+
36+
it('returns empty array for directory with no json files', () => {
37+
fs.writeFileSync(path.join(tmpDir, 'readme.txt'), 'not a dashboard');
38+
const result = readDashboardFiles(tmpDir);
39+
expect(result).toEqual([]);
40+
});
41+
42+
it('skips invalid JSON files', () => {
43+
fs.writeFileSync(path.join(tmpDir, 'bad.json'), '{invalid json');
44+
const result = readDashboardFiles(tmpDir);
45+
expect(result).toEqual([]);
46+
});
47+
48+
it('skips files that fail schema validation', () => {
49+
fs.writeFileSync(
50+
path.join(tmpDir, 'no-name.json'),
51+
JSON.stringify({ tiles: [] }),
52+
);
53+
const result = readDashboardFiles(tmpDir);
54+
expect(result).toEqual([]);
55+
});
56+
57+
it('parses valid dashboard files', () => {
58+
fs.writeFileSync(
59+
path.join(tmpDir, 'test.json'),
60+
JSON.stringify({ name: 'Test', tiles: [makeTile()], tags: [] }),
61+
);
62+
const result = readDashboardFiles(tmpDir);
63+
expect(result).toHaveLength(1);
64+
expect(result[0].name).toBe('Test');
65+
});
66+
});
67+
68+
describe('syncDashboards', () => {
69+
it('creates a new dashboard', async () => {
70+
const team = await createTeam({ name: 'My Team' });
71+
fs.writeFileSync(
72+
path.join(tmpDir, 'test.json'),
73+
JSON.stringify({
74+
name: 'New Dashboard',
75+
tiles: [makeTile()],
76+
tags: [],
77+
}),
78+
);
79+
80+
await syncDashboards(team._id.toString(), tmpDir);
81+
82+
const count = await Dashboard.countDocuments({ team: team._id });
83+
expect(count).toBe(1);
84+
});
85+
86+
it('updates an existing dashboard by name', async () => {
87+
const team = await createTeam({ name: 'My Team' });
88+
const tile = makeTile();
89+
await new Dashboard({
90+
name: 'Existing',
91+
tiles: [tile],
92+
tags: [],
93+
team: team._id,
94+
provisioned: true,
95+
}).save();
96+
97+
const newTile = makeTile();
98+
fs.writeFileSync(
99+
path.join(tmpDir, 'existing.json'),
100+
JSON.stringify({
101+
name: 'Existing',
102+
tiles: [newTile],
103+
tags: ['updated'],
104+
}),
105+
);
106+
107+
await syncDashboards(team._id.toString(), tmpDir);
108+
109+
const dashboard = (await Dashboard.findOne({
110+
name: 'Existing',
111+
team: team._id,
112+
})) as any;
113+
expect(dashboard.tiles[0].id).toBe(newTile.id);
114+
expect(dashboard.tags).toEqual(['updated']);
115+
});
116+
117+
it('does not create duplicates on repeated sync', async () => {
118+
const team = await createTeam({ name: 'My Team' });
119+
fs.writeFileSync(
120+
path.join(tmpDir, 'test.json'),
121+
JSON.stringify({ name: 'Dashboard', tiles: [makeTile()], tags: [] }),
122+
);
123+
124+
await syncDashboards(team._id.toString(), tmpDir);
125+
await syncDashboards(team._id.toString(), tmpDir);
126+
await syncDashboards(team._id.toString(), tmpDir);
127+
128+
const count = await Dashboard.countDocuments({ team: team._id });
129+
expect(count).toBe(1);
130+
});
131+
132+
it('provisions to multiple teams', async () => {
133+
const teamA = await createTeam({ name: 'Team A' });
134+
const teamB = await createTeam({ name: 'Team B' });
135+
fs.writeFileSync(
136+
path.join(tmpDir, 'shared.json'),
137+
JSON.stringify({
138+
name: 'Shared Dashboard',
139+
tiles: [makeTile()],
140+
tags: [],
141+
}),
142+
);
143+
144+
await syncDashboards(teamA._id.toString(), tmpDir);
145+
await syncDashboards(teamB._id.toString(), tmpDir);
146+
147+
expect(await Dashboard.countDocuments({ team: teamA._id })).toBe(1);
148+
expect(await Dashboard.countDocuments({ team: teamB._id })).toBe(1);
149+
});
150+
151+
it('does not overwrite user-created dashboards', async () => {
152+
const team = await createTeam({ name: 'My Team' });
153+
const userTile = makeTile();
154+
await new Dashboard({
155+
name: 'My Dashboard',
156+
tiles: [userTile],
157+
tags: ['user-tag'],
158+
team: team._id,
159+
// provisioned defaults to false
160+
}).save();
161+
162+
fs.writeFileSync(
163+
path.join(tmpDir, 'my-dashboard.json'),
164+
JSON.stringify({
165+
name: 'My Dashboard',
166+
tiles: [makeTile()],
167+
tags: ['provisioned-tag'],
168+
}),
169+
);
170+
171+
await syncDashboards(team._id.toString(), tmpDir);
172+
173+
// User-created dashboard is untouched
174+
const userDashboard = (await Dashboard.findOne({
175+
name: 'My Dashboard',
176+
team: team._id,
177+
provisioned: { $ne: true },
178+
})) as any;
179+
expect(userDashboard).toBeTruthy();
180+
expect(userDashboard.tiles[0].id).toBe(userTile.id);
181+
expect(userDashboard.tags).toEqual(['user-tag']);
182+
183+
// Provisioned copy was created alongside it
184+
const provisionedDashboard = await Dashboard.findOne({
185+
name: 'My Dashboard',
186+
team: team._id,
187+
provisioned: true,
188+
});
189+
expect(provisionedDashboard).toBeTruthy();
190+
});
191+
});
192+
});

0 commit comments

Comments
 (0)