@hachej/boring-bi-dashboard 0.1.60
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +83 -0
- package/dist/front/index.d.ts +28 -0
- package/dist/front/index.js +831 -0
- package/dist/shared/index.d.ts +16 -0
- package/dist/shared/index.js +131 -0
- package/dist/types-BGZKL9Rs.d.ts +146 -0
- package/docs/issues/bi-dashboard-plugin/README.md +349 -0
- package/docs/issues/bi-dashboard-plugin/data-access-unification.md +638 -0
- package/docs/issues/bi-dashboard-plugin/data-bridge.md +425 -0
- package/example/.gitignore +16 -0
- package/example/.pi/extensions/bi-dashboard/front/index.ts +1 -0
- package/example/.pi/extensions/bi-dashboard/package.json +11 -0
- package/example/README.md +10 -0
- package/example/dashboards/people.dashboard.json +178 -0
- package/example/data/people.csv +13 -0
- package/example/eval/bi-dashboard.yaml +31 -0
- package/package.json +85 -0
- package/playground/README.md +25 -0
- package/playground/run-eval.ts +100 -0
- package/playground/smoke-dashboard.ts +101 -0
- package/skills/bi-dashboard-authoring/SKILL.md +78 -0
|
@@ -0,0 +1,831 @@
|
|
|
1
|
+
// src/front/index.ts
|
|
2
|
+
import { LayoutDashboard as LayoutDashboard2 } from "lucide-react";
|
|
3
|
+
import { definePlugin } from "@hachej/boring-workspace/plugin";
|
|
4
|
+
|
|
5
|
+
// src/front/BiDashboardPane.tsx
|
|
6
|
+
import { useEffect as useEffect2, useMemo as useMemo2, useState as useState2 } from "react";
|
|
7
|
+
import { BarChart3, Database, ExternalLink, Gauge, RefreshCcw, SlidersHorizontal, Table2 } from "lucide-react";
|
|
8
|
+
import {
|
|
9
|
+
Card,
|
|
10
|
+
CardContent,
|
|
11
|
+
CardDescription,
|
|
12
|
+
CardHeader,
|
|
13
|
+
CardTitle,
|
|
14
|
+
EmptyState,
|
|
15
|
+
IconButton,
|
|
16
|
+
Toolbar,
|
|
17
|
+
ToolbarGroup
|
|
18
|
+
} from "@hachej/boring-ui-kit";
|
|
19
|
+
import { useApiBaseUrl, useWorkspaceRequestId } from "@hachej/boring-workspace";
|
|
20
|
+
import { defineGeneratedPaneProfile, GeneratedPaneRenderer } from "@hachej/boring-generated-pane/front";
|
|
21
|
+
|
|
22
|
+
// src/shared/validation.ts
|
|
23
|
+
import { parseGeneratedPaneSpec } from "@hachej/boring-generated-pane/shared";
|
|
24
|
+
|
|
25
|
+
// src/shared/schemas.ts
|
|
26
|
+
import { z } from "zod";
|
|
27
|
+
var chartRenderers = ["echarts", "vega-lite", "plotly"];
|
|
28
|
+
var chartTypes = ["bar", "line", "area", "scatter", "heatmap", "pie", "treemap", "sunburst", "gauge", "table"];
|
|
29
|
+
var perspectivePlugins = ["Datagrid", "Y Bar", "X Bar", "Y Line", "Y Area", "Y Scatter", "Y Treemap", "Sunburst", "Heatmap"];
|
|
30
|
+
var metricFormats = ["number", "currency", "percent"];
|
|
31
|
+
var filterControlTypes = ["select", "multiSelect", "dateRange", "numberRange", "search"];
|
|
32
|
+
var sortDirections = ["asc", "desc"];
|
|
33
|
+
var dashboardQuerySchema = z.union([
|
|
34
|
+
z.object({
|
|
35
|
+
id: z.string().min(1),
|
|
36
|
+
model: z.string().min(1),
|
|
37
|
+
query: z.string().min(1),
|
|
38
|
+
limit: z.number().int().safe().min(1).optional()
|
|
39
|
+
}),
|
|
40
|
+
z.object({
|
|
41
|
+
id: z.string().min(1),
|
|
42
|
+
source: z.string().min(1).optional(),
|
|
43
|
+
sql: z.string().min(1),
|
|
44
|
+
params: z.record(z.string(), z.unknown()).optional(),
|
|
45
|
+
limit: z.number().int().safe().min(1).optional()
|
|
46
|
+
})
|
|
47
|
+
]);
|
|
48
|
+
var dashboardGridPropsSchema = z.object({
|
|
49
|
+
title: z.string().optional(),
|
|
50
|
+
columns: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4), z.literal(6), z.literal(12)]).optional()
|
|
51
|
+
});
|
|
52
|
+
var bslMetricPropsSchema = z.object({
|
|
53
|
+
queryId: z.string(),
|
|
54
|
+
valueField: z.string(),
|
|
55
|
+
label: z.string(),
|
|
56
|
+
format: z.enum(metricFormats).optional()
|
|
57
|
+
});
|
|
58
|
+
var bslChartPropsSchema = z.object({
|
|
59
|
+
queryId: z.string(),
|
|
60
|
+
title: z.string().optional(),
|
|
61
|
+
renderer: z.enum(chartRenderers).optional(),
|
|
62
|
+
chartType: z.enum(chartTypes),
|
|
63
|
+
x: z.string().optional(),
|
|
64
|
+
y: z.union([z.string(), z.array(z.string())]).optional(),
|
|
65
|
+
color: z.string().optional(),
|
|
66
|
+
controls: z.array(z.string()).optional()
|
|
67
|
+
});
|
|
68
|
+
var bslPerspectiveViewerPropsSchema = z.object({
|
|
69
|
+
queryId: z.string(),
|
|
70
|
+
title: z.string().optional(),
|
|
71
|
+
plugin: z.enum(perspectivePlugins).optional(),
|
|
72
|
+
columns: z.array(z.string()).optional(),
|
|
73
|
+
groupBy: z.array(z.string()).optional(),
|
|
74
|
+
splitBy: z.array(z.string()).optional(),
|
|
75
|
+
sort: z.array(z.tuple([z.string(), z.enum(sortDirections)])).optional()
|
|
76
|
+
});
|
|
77
|
+
var bslFilterPropsSchema = z.object({
|
|
78
|
+
id: z.string(),
|
|
79
|
+
field: z.string(),
|
|
80
|
+
label: z.string().optional(),
|
|
81
|
+
controlType: z.enum(filterControlTypes),
|
|
82
|
+
targetQueries: z.array(z.string())
|
|
83
|
+
});
|
|
84
|
+
var bslTextPropsSchema = z.object({
|
|
85
|
+
markdown: z.string()
|
|
86
|
+
});
|
|
87
|
+
var componentPropsSchemas = {
|
|
88
|
+
DashboardGrid: dashboardGridPropsSchema,
|
|
89
|
+
BSLMetric: bslMetricPropsSchema,
|
|
90
|
+
BSLChart: bslChartPropsSchema,
|
|
91
|
+
BSLPerspectiveViewer: bslPerspectiveViewerPropsSchema,
|
|
92
|
+
BSLFilter: bslFilterPropsSchema,
|
|
93
|
+
BSLText: bslTextPropsSchema
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// src/shared/validation.ts
|
|
97
|
+
function isRecord(value) {
|
|
98
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
99
|
+
}
|
|
100
|
+
function formatSchemaErrors(prefix, error) {
|
|
101
|
+
return error.issues.map((issue) => `${prefix}${issue.path.length ? `.${issue.path.map(String).join(".")}` : ""}: ${issue.message}`);
|
|
102
|
+
}
|
|
103
|
+
function parseDashboardSpec(value) {
|
|
104
|
+
const base = parseGeneratedPaneSpec(value);
|
|
105
|
+
if (!base.spec) return { spec: null, errors: base.errors };
|
|
106
|
+
const errors = [];
|
|
107
|
+
if (base.spec.profile !== "bi-dashboard") errors.push('dashboard spec profile must be "bi-dashboard"');
|
|
108
|
+
if (typeof base.spec.title !== "string" || base.spec.title.length === 0) errors.push("dashboard spec needs a title");
|
|
109
|
+
if (!isRecord(base.spec.queries)) errors.push("dashboard spec needs a queries object");
|
|
110
|
+
if (errors.length > 0) return { spec: null, errors };
|
|
111
|
+
const queries = base.spec.queries;
|
|
112
|
+
for (const [id, query] of Object.entries(queries)) {
|
|
113
|
+
const parsed = dashboardQuerySchema.safeParse(query);
|
|
114
|
+
if (!parsed.success) {
|
|
115
|
+
errors.push(...formatSchemaErrors(`query ${id}`, parsed.error));
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (parsed.data.id !== id) errors.push(`query ${id} must repeat its id field`);
|
|
119
|
+
}
|
|
120
|
+
for (const [id, element] of Object.entries(base.spec.elements)) {
|
|
121
|
+
const type = element.type;
|
|
122
|
+
const schema = componentPropsSchemas[type];
|
|
123
|
+
if (!schema) {
|
|
124
|
+
errors.push(`component ${id} has unsupported type ${String(element.type)}`);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const props = schema.safeParse(element.props ?? {});
|
|
128
|
+
if (!props.success) errors.push(...formatSchemaErrors(`component ${id}.props`, props.error));
|
|
129
|
+
if (type === "DashboardGrid" && !Array.isArray(element.children)) {
|
|
130
|
+
errors.push(`DashboardGrid ${id} must include string children`);
|
|
131
|
+
}
|
|
132
|
+
if ((type === "BSLMetric" || type === "BSLChart" || type === "BSLPerspectiveViewer") && props.success) {
|
|
133
|
+
const queryId = props.data.queryId;
|
|
134
|
+
if (!queries[queryId]) errors.push(`component ${id} references unknown query ${queryId}`);
|
|
135
|
+
}
|
|
136
|
+
if (type === "BSLFilter" && props.success) {
|
|
137
|
+
for (const queryId of props.data.targetQueries) {
|
|
138
|
+
if (!queries[queryId]) errors.push(`BSLFilter ${id} references unknown query ${queryId}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (errors.length > 0) return { spec: null, errors };
|
|
143
|
+
return { spec: base.spec, errors: [] };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// src/front/sampleSpec.ts
|
|
147
|
+
var sampleBiDashboardSpec = {
|
|
148
|
+
kind: "boring.generated-pane",
|
|
149
|
+
version: 1,
|
|
150
|
+
profile: "bi-dashboard",
|
|
151
|
+
title: "Semantic Data Overview",
|
|
152
|
+
description: "Dashboard backed by data-bridge SQL query strings.",
|
|
153
|
+
queries: {
|
|
154
|
+
total_people: {
|
|
155
|
+
id: "total_people",
|
|
156
|
+
source: "people-duckdb",
|
|
157
|
+
sql: "SELECT count(*) AS count FROM people"
|
|
158
|
+
},
|
|
159
|
+
people_by_role: {
|
|
160
|
+
id: "people_by_role",
|
|
161
|
+
source: "people-duckdb",
|
|
162
|
+
sql: "SELECT role, count(*) AS count FROM people GROUP BY role ORDER BY count DESC"
|
|
163
|
+
},
|
|
164
|
+
status_by_owner: {
|
|
165
|
+
id: "status_by_owner",
|
|
166
|
+
source: "people-duckdb",
|
|
167
|
+
sql: "SELECT owner, count(*) AS count FROM workspace_status GROUP BY owner ORDER BY count DESC"
|
|
168
|
+
},
|
|
169
|
+
people_detail: {
|
|
170
|
+
id: "people_detail",
|
|
171
|
+
source: "people-duckdb",
|
|
172
|
+
sql: "SELECT role, active, count(*) AS count FROM people GROUP BY role, active ORDER BY count DESC"
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
root: "dashboard",
|
|
176
|
+
elements: {
|
|
177
|
+
dashboard: {
|
|
178
|
+
type: "DashboardGrid",
|
|
179
|
+
props: { title: "Workspace Data Overview", columns: 12 },
|
|
180
|
+
children: ["total-people", "people-role", "status-owner", "people-table"]
|
|
181
|
+
},
|
|
182
|
+
"total-people": {
|
|
183
|
+
type: "BSLMetric",
|
|
184
|
+
props: {
|
|
185
|
+
queryId: "total_people",
|
|
186
|
+
valueField: "count",
|
|
187
|
+
label: "People rows",
|
|
188
|
+
format: "number"
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
"people-role": {
|
|
192
|
+
type: "BSLChart",
|
|
193
|
+
props: {
|
|
194
|
+
queryId: "people_by_role",
|
|
195
|
+
renderer: "echarts",
|
|
196
|
+
chartType: "bar",
|
|
197
|
+
x: "role",
|
|
198
|
+
y: "count",
|
|
199
|
+
title: "People by role"
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
"status-owner": {
|
|
203
|
+
type: "BSLChart",
|
|
204
|
+
props: {
|
|
205
|
+
queryId: "status_by_owner",
|
|
206
|
+
renderer: "echarts",
|
|
207
|
+
chartType: "bar",
|
|
208
|
+
x: "owner",
|
|
209
|
+
y: "count",
|
|
210
|
+
title: "Workspace components by owner"
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
"people-table": {
|
|
214
|
+
type: "BSLPerspectiveViewer",
|
|
215
|
+
props: {
|
|
216
|
+
queryId: "people_detail",
|
|
217
|
+
title: "People explorer",
|
|
218
|
+
plugin: "Datagrid",
|
|
219
|
+
columns: ["role", "active", "count"],
|
|
220
|
+
sort: [["count", "desc"]]
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
// src/front/dashboardData.ts
|
|
227
|
+
import { useEffect, useMemo, useState } from "react";
|
|
228
|
+
var DATA_BRIDGE_QUERY_RUN_OP = "data.v1.query.run";
|
|
229
|
+
function inferColumns(rows) {
|
|
230
|
+
const names = [...new Set(rows.flatMap((row) => Object.keys(row)))];
|
|
231
|
+
return names.map((name) => ({ name, type: typeof rows.find((row) => row[name] != null)?.[name] }));
|
|
232
|
+
}
|
|
233
|
+
async function fetchDataBridgeQuery(options) {
|
|
234
|
+
const response = await fetch(`${options.apiBaseUrl}/api/v1/workspace-bridge/call`, {
|
|
235
|
+
method: "POST",
|
|
236
|
+
credentials: "include",
|
|
237
|
+
headers: {
|
|
238
|
+
"content-type": "application/json",
|
|
239
|
+
...options.workspaceId ? { "x-boring-workspace-id": options.workspaceId } : {}
|
|
240
|
+
},
|
|
241
|
+
body: JSON.stringify({
|
|
242
|
+
op: DATA_BRIDGE_QUERY_RUN_OP,
|
|
243
|
+
input: {
|
|
244
|
+
query: "sql" in options.query ? {
|
|
245
|
+
language: "sql",
|
|
246
|
+
source: options.query.source ?? "default",
|
|
247
|
+
sql: options.query.sql,
|
|
248
|
+
params: options.query.params,
|
|
249
|
+
limit: options.query.limit
|
|
250
|
+
} : {
|
|
251
|
+
language: "bsl",
|
|
252
|
+
model: options.query.model,
|
|
253
|
+
query: options.query.query,
|
|
254
|
+
limit: options.query.limit
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
})
|
|
258
|
+
});
|
|
259
|
+
const body = await response.json();
|
|
260
|
+
if (!response.ok || !body.ok || !body.output) {
|
|
261
|
+
const message = body.error?.message ?? `Data bridge failed with HTTP ${response.status}`;
|
|
262
|
+
throw new Error(message);
|
|
263
|
+
}
|
|
264
|
+
const rows = body.output.rows ?? [];
|
|
265
|
+
return {
|
|
266
|
+
queryId: options.query.id,
|
|
267
|
+
columns: body.output.columns ?? inferColumns(rows),
|
|
268
|
+
rows,
|
|
269
|
+
source: body.output.source,
|
|
270
|
+
loading: false
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
function useDashboardQueryData(spec, apiBaseUrl, workspaceId) {
|
|
274
|
+
const queries = useMemo(
|
|
275
|
+
() => spec ? Object.entries(spec.queries) : [],
|
|
276
|
+
[spec]
|
|
277
|
+
);
|
|
278
|
+
const [results, setResults] = useState({});
|
|
279
|
+
useEffect(() => {
|
|
280
|
+
let cancelled = false;
|
|
281
|
+
if (queries.length === 0) {
|
|
282
|
+
setResults({});
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
setResults(Object.fromEntries(queries.map(([queryId]) => [queryId, { queryId, columns: [], rows: [], loading: true }])));
|
|
286
|
+
void Promise.all(queries.map(async ([queryId, query]) => {
|
|
287
|
+
try {
|
|
288
|
+
return [queryId, await fetchDataBridgeQuery({ apiBaseUrl, workspaceId, query })];
|
|
289
|
+
} catch (error) {
|
|
290
|
+
return [queryId, {
|
|
291
|
+
queryId,
|
|
292
|
+
columns: [],
|
|
293
|
+
rows: [],
|
|
294
|
+
loading: false,
|
|
295
|
+
error: error instanceof Error ? error.message : String(error)
|
|
296
|
+
}];
|
|
297
|
+
}
|
|
298
|
+
})).then((entries) => {
|
|
299
|
+
if (!cancelled) setResults(Object.fromEntries(entries));
|
|
300
|
+
});
|
|
301
|
+
return () => {
|
|
302
|
+
cancelled = true;
|
|
303
|
+
};
|
|
304
|
+
}, [apiBaseUrl, queries, workspaceId]);
|
|
305
|
+
return results;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// src/front/BiDashboardPane.tsx
|
|
309
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
310
|
+
function BiDashboardPane({ params }) {
|
|
311
|
+
const apiBaseUrl = useApiBaseUrl();
|
|
312
|
+
const workspaceId = useWorkspaceRequestId();
|
|
313
|
+
const [loadedFile, setLoadedFile] = useState2({ spec: null, loading: false });
|
|
314
|
+
const [controllerValues, setControllerValues] = useState2({});
|
|
315
|
+
const [refreshKey, setRefreshKey] = useState2(0);
|
|
316
|
+
useEffect2(() => {
|
|
317
|
+
if (!params?.path || params.spec) {
|
|
318
|
+
setLoadedFile({ spec: null, loading: false });
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const controller = new AbortController();
|
|
322
|
+
setLoadedFile({ spec: null, loading: true });
|
|
323
|
+
void fetch(`${apiBaseUrl}/api/v1/files/raw?path=${encodeURIComponent(params.path)}`, {
|
|
324
|
+
signal: controller.signal,
|
|
325
|
+
credentials: "include",
|
|
326
|
+
headers: workspaceId ? { "x-boring-workspace-id": workspaceId } : {}
|
|
327
|
+
}).then(async (response) => {
|
|
328
|
+
if (!response.ok) throw new Error(`Failed to load ${params.path}: HTTP ${response.status}`);
|
|
329
|
+
return await response.text();
|
|
330
|
+
}).then((text) => {
|
|
331
|
+
try {
|
|
332
|
+
setLoadedFile({ spec: JSON.parse(text), loading: false });
|
|
333
|
+
} catch (error) {
|
|
334
|
+
setLoadedFile({
|
|
335
|
+
spec: null,
|
|
336
|
+
loading: false,
|
|
337
|
+
error: error instanceof Error ? error.message : "Dashboard file is not valid JSON"
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}).catch((error) => {
|
|
341
|
+
if (controller.signal.aborted) return;
|
|
342
|
+
setLoadedFile({
|
|
343
|
+
spec: null,
|
|
344
|
+
loading: false,
|
|
345
|
+
error: error instanceof Error ? error.message : String(error)
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
return () => controller.abort();
|
|
349
|
+
}, [apiBaseUrl, params?.path, params?.spec, refreshKey, workspaceId]);
|
|
350
|
+
const rawSpec = loadedFile.loading || loadedFile.error ? null : params?.spec ?? loadedFile.spec ?? sampleBiDashboardSpec;
|
|
351
|
+
const parsed = rawSpec ? parseDashboardSpec(rawSpec) : { spec: null, errors: [] };
|
|
352
|
+
const spec = useMemo2(() => parsed.spec ? applyControllerFilters(parsed.spec, controllerValues) : null, [controllerValues, parsed.spec]);
|
|
353
|
+
const queryData = useDashboardQueryData(spec, apiBaseUrl, workspaceId ?? void 0);
|
|
354
|
+
if (loadedFile.loading) {
|
|
355
|
+
return /* @__PURE__ */ jsx("div", { className: "flex h-full min-h-0 min-w-0 items-center justify-center bg-background p-6 text-foreground", children: /* @__PURE__ */ jsx(EmptyState, { title: "Loading BI dashboard", description: params?.path }) });
|
|
356
|
+
}
|
|
357
|
+
if (loadedFile.error) {
|
|
358
|
+
return /* @__PURE__ */ jsx("div", { className: "flex h-full min-h-0 min-w-0 items-center justify-center bg-background p-6 text-foreground", children: /* @__PURE__ */ jsx(EmptyState, { title: "Could not load BI dashboard", description: loadedFile.error }) });
|
|
359
|
+
}
|
|
360
|
+
if (!parsed.spec) {
|
|
361
|
+
return /* @__PURE__ */ jsx("div", { className: "flex h-full min-h-0 min-w-0 items-center justify-center bg-background p-6 text-foreground", children: /* @__PURE__ */ jsx(
|
|
362
|
+
EmptyState,
|
|
363
|
+
{
|
|
364
|
+
title: "Invalid BI dashboard spec",
|
|
365
|
+
description: parsed.errors.slice(0, 5).join(" \u2022 ")
|
|
366
|
+
}
|
|
367
|
+
) });
|
|
368
|
+
}
|
|
369
|
+
if (!spec) {
|
|
370
|
+
return /* @__PURE__ */ jsx("div", { className: "flex h-full min-h-0 min-w-0 items-center justify-center bg-background p-6 text-foreground", children: /* @__PURE__ */ jsx(EmptyState, { title: "Invalid BI dashboard spec", description: "Dashboard could not be prepared" }) });
|
|
371
|
+
}
|
|
372
|
+
return /* @__PURE__ */ jsxs("div", { className: "flex h-full min-h-0 min-w-0 flex-col bg-background text-foreground", children: [
|
|
373
|
+
/* @__PURE__ */ jsxs(Toolbar, { className: "border-b border-border bg-background/95 px-3 py-2", children: [
|
|
374
|
+
/* @__PURE__ */ jsx(ToolbarGroup, { children: /* @__PURE__ */ jsx("span", { className: "text-xs font-medium text-muted-foreground", children: "BI dashboard" }) }),
|
|
375
|
+
/* @__PURE__ */ jsxs(ToolbarGroup, { className: "ml-auto", children: [
|
|
376
|
+
/* @__PURE__ */ jsx(
|
|
377
|
+
IconButton,
|
|
378
|
+
{
|
|
379
|
+
type: "button",
|
|
380
|
+
variant: "ghost",
|
|
381
|
+
size: "icon-xs",
|
|
382
|
+
className: "text-muted-foreground hover:text-foreground",
|
|
383
|
+
onClick: () => setRefreshKey((value) => value + 1),
|
|
384
|
+
"aria-label": "Refresh dashboard",
|
|
385
|
+
title: "Refresh dashboard",
|
|
386
|
+
disabled: !params?.path || loadedFile.loading,
|
|
387
|
+
children: /* @__PURE__ */ jsx(RefreshCcw, { className: `h-3.5 w-3.5 ${loadedFile.loading ? "animate-spin" : ""}`, strokeWidth: 1.75 })
|
|
388
|
+
}
|
|
389
|
+
),
|
|
390
|
+
params?.path ? /* @__PURE__ */ jsx(
|
|
391
|
+
IconButton,
|
|
392
|
+
{
|
|
393
|
+
asChild: true,
|
|
394
|
+
variant: "ghost",
|
|
395
|
+
size: "icon-xs",
|
|
396
|
+
className: "text-muted-foreground hover:text-foreground",
|
|
397
|
+
"aria-label": "Open raw dashboard in new tab",
|
|
398
|
+
title: "Open raw dashboard in new tab",
|
|
399
|
+
children: /* @__PURE__ */ jsx("a", { href: `${apiBaseUrl}/api/v1/files/raw?path=${encodeURIComponent(params.path)}`, target: "_blank", rel: "noreferrer", children: /* @__PURE__ */ jsx(ExternalLink, { className: "h-3.5 w-3.5", strokeWidth: 1.75 }) })
|
|
400
|
+
}
|
|
401
|
+
) : null
|
|
402
|
+
] })
|
|
403
|
+
] }),
|
|
404
|
+
/* @__PURE__ */ jsxs("div", { className: "min-h-0 min-w-0 flex-1 overflow-auto bg-background p-4", children: [
|
|
405
|
+
/* @__PURE__ */ jsxs("div", { className: "mb-4", children: [
|
|
406
|
+
/* @__PURE__ */ jsx("h1", { className: "text-2xl font-semibold tracking-tight", children: spec.title }),
|
|
407
|
+
spec.description ? /* @__PURE__ */ jsx("p", { className: "mt-1 text-sm text-muted-foreground", children: spec.description }) : null
|
|
408
|
+
] }),
|
|
409
|
+
/* @__PURE__ */ jsxs("div", { className: "grid min-w-0 gap-4 xl:grid-cols-[minmax(0,1fr)_360px]", children: [
|
|
410
|
+
/* @__PURE__ */ jsx("div", { className: "min-w-0 space-y-4", children: /* @__PURE__ */ jsx(GeneratedPaneRenderer, { spec, profile: createBiDashboardPaneProfile(queryData, controllerValues, setControllerValues) }) }),
|
|
411
|
+
/* @__PURE__ */ jsxs(Card, { className: "min-w-0", children: [
|
|
412
|
+
/* @__PURE__ */ jsxs(CardHeader, { children: [
|
|
413
|
+
/* @__PURE__ */ jsxs(CardTitle, { className: "flex items-center gap-2 text-sm", children: [
|
|
414
|
+
/* @__PURE__ */ jsx(Database, { className: "h-4 w-4" }),
|
|
415
|
+
" Query manifest"
|
|
416
|
+
] }),
|
|
417
|
+
/* @__PURE__ */ jsx(CardDescription, { children: "The agent should generate this neutral BSL dashboard contract; the plugin maps it to BSL, ECharts, and Perspective runtime calls." })
|
|
418
|
+
] }),
|
|
419
|
+
/* @__PURE__ */ jsx(CardContent, { children: /* @__PURE__ */ jsx("pre", { className: "max-h-[520px] overflow-auto rounded-lg border border-border bg-muted/40 p-3 text-xs text-muted-foreground", children: JSON.stringify({ queries: spec.queries }, null, 2) }) })
|
|
420
|
+
] })
|
|
421
|
+
] })
|
|
422
|
+
] })
|
|
423
|
+
] });
|
|
424
|
+
}
|
|
425
|
+
function gridColumnsClass(columns) {
|
|
426
|
+
switch (columns) {
|
|
427
|
+
case 1:
|
|
428
|
+
return "grid-cols-1";
|
|
429
|
+
case 2:
|
|
430
|
+
return "lg:grid-cols-2";
|
|
431
|
+
case 3:
|
|
432
|
+
return "lg:grid-cols-3";
|
|
433
|
+
case 4:
|
|
434
|
+
return "lg:grid-cols-4";
|
|
435
|
+
case 6:
|
|
436
|
+
return "lg:grid-cols-6";
|
|
437
|
+
case 12:
|
|
438
|
+
return "lg:grid-cols-2 xl:grid-cols-4";
|
|
439
|
+
default:
|
|
440
|
+
return "lg:grid-cols-2";
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
function formatMetricValue(value, format) {
|
|
444
|
+
const number = typeof value === "number" ? value : Number(value);
|
|
445
|
+
if (!Number.isFinite(number)) return value == null ? "\u2014" : String(value);
|
|
446
|
+
if (format === "currency") return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 }).format(number);
|
|
447
|
+
if (format === "percent") return new Intl.NumberFormat("en-US", { style: "percent", maximumFractionDigits: 1 }).format(number);
|
|
448
|
+
return new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 }).format(number);
|
|
449
|
+
}
|
|
450
|
+
function applyControllerFilters(spec, _controllerValues) {
|
|
451
|
+
return spec;
|
|
452
|
+
}
|
|
453
|
+
function ChartPreview({ data, x, y, chartType }) {
|
|
454
|
+
if (!data) return /* @__PURE__ */ jsx(Placeholder, { text: "No live data source configured yet" });
|
|
455
|
+
if (data.loading) return /* @__PURE__ */ jsx(Placeholder, { text: "Loading data\u2026" });
|
|
456
|
+
if (data.error) return /* @__PURE__ */ jsx(Placeholder, { text: data.error, destructive: true });
|
|
457
|
+
const yField = Array.isArray(y) ? y[0] : y;
|
|
458
|
+
if (!x || !yField || data.rows.length === 0) return /* @__PURE__ */ jsx(Placeholder, { text: "No chartable rows" });
|
|
459
|
+
const values = data.rows.map((row) => Number(row[yField])).filter(Number.isFinite);
|
|
460
|
+
const max = Math.max(1, ...values);
|
|
461
|
+
return /* @__PURE__ */ jsx("div", { className: "h-56 rounded-lg border border-border bg-card p-3 text-foreground", children: /* @__PURE__ */ jsxs("svg", { viewBox: "0 0 640 210", className: "h-full w-full overflow-visible", role: "img", "aria-label": `${chartType} chart`, children: [
|
|
462
|
+
data.rows.map((row, index) => {
|
|
463
|
+
const value = Number(row[yField]);
|
|
464
|
+
const label = String(row[x] ?? index + 1);
|
|
465
|
+
const width = 560 / Math.max(1, data.rows.length);
|
|
466
|
+
const height = Number.isFinite(value) ? Math.max(3, value / max * 150) : 3;
|
|
467
|
+
const left = 55 + index * width;
|
|
468
|
+
if (chartType === "line" || chartType === "area") return null;
|
|
469
|
+
return /* @__PURE__ */ jsxs("g", { children: [
|
|
470
|
+
/* @__PURE__ */ jsx("rect", { x: left, y: 165 - height, width: Math.max(8, width - 8), height, rx: "4", fill: "var(--boring-primary, var(--primary))", opacity: "0.9" }),
|
|
471
|
+
/* @__PURE__ */ jsx("text", { x: left + width / 2, y: "190", textAnchor: "middle", className: "fill-current text-[10px] text-muted-foreground", children: label.slice(0, 10) })
|
|
472
|
+
] }, index);
|
|
473
|
+
}),
|
|
474
|
+
(chartType === "line" || chartType === "area") && /* @__PURE__ */ jsx(
|
|
475
|
+
"polyline",
|
|
476
|
+
{
|
|
477
|
+
fill: "none",
|
|
478
|
+
stroke: "var(--boring-primary, var(--primary))",
|
|
479
|
+
strokeWidth: "3",
|
|
480
|
+
points: data.rows.map((row, index) => {
|
|
481
|
+
const value = Number(row[yField]);
|
|
482
|
+
const xPos = 65 + index * (540 / Math.max(1, data.rows.length - 1));
|
|
483
|
+
const yPos = 165 - (Number.isFinite(value) ? value : 0) / max * 150;
|
|
484
|
+
return `${xPos},${yPos}`;
|
|
485
|
+
}).join(" ")
|
|
486
|
+
}
|
|
487
|
+
)
|
|
488
|
+
] }) });
|
|
489
|
+
}
|
|
490
|
+
function DataTable({ data, columns }) {
|
|
491
|
+
if (!data) return /* @__PURE__ */ jsx(Placeholder, { text: "No live data source configured yet" });
|
|
492
|
+
if (data.loading) return /* @__PURE__ */ jsx(Placeholder, { text: "Loading data\u2026" });
|
|
493
|
+
if (data.error) return /* @__PURE__ */ jsx(Placeholder, { text: data.error, destructive: true });
|
|
494
|
+
const visibleColumns = columns?.length ? columns : data.columns.map((column) => column.name);
|
|
495
|
+
return /* @__PURE__ */ jsx("div", { className: "max-h-72 overflow-auto rounded-lg border border-border", children: /* @__PURE__ */ jsxs("table", { className: "w-full border-collapse text-xs", children: [
|
|
496
|
+
/* @__PURE__ */ jsx("thead", { className: "sticky top-0 bg-muted", children: /* @__PURE__ */ jsx("tr", { children: visibleColumns.map((column) => /* @__PURE__ */ jsx("th", { className: "border-b border-border px-2 py-1.5 text-left font-medium", children: column }, column)) }) }),
|
|
497
|
+
/* @__PURE__ */ jsx("tbody", { children: data.rows.map((row, rowIndex) => /* @__PURE__ */ jsx("tr", { className: "odd:bg-muted/20", children: visibleColumns.map((column) => /* @__PURE__ */ jsx("td", { className: "border-b border-border/50 px-2 py-1.5 text-muted-foreground", children: String(row[column] ?? "") }, column)) }, rowIndex)) })
|
|
498
|
+
] }) });
|
|
499
|
+
}
|
|
500
|
+
function Placeholder({ text, destructive }) {
|
|
501
|
+
return /* @__PURE__ */ jsx("div", { className: `flex h-52 items-center justify-center rounded-lg border border-dashed border-border bg-card text-sm ${destructive ? "text-destructive" : "text-muted-foreground"}`, children: text });
|
|
502
|
+
}
|
|
503
|
+
function createBiDashboardPaneProfile(queryData, controllerValues, setControllerValues) {
|
|
504
|
+
return defineGeneratedPaneProfile({
|
|
505
|
+
id: "bi-dashboard",
|
|
506
|
+
label: "BI Dashboard",
|
|
507
|
+
components: {
|
|
508
|
+
DashboardGrid: {
|
|
509
|
+
description: "Responsive dashboard grid for chart, table, metric, filter, and text widgets.",
|
|
510
|
+
slots: ["default"],
|
|
511
|
+
props: dashboardGridPropsSchema,
|
|
512
|
+
component: ({ props, children }) => /* @__PURE__ */ jsx("div", { className: `grid min-w-0 gap-4 ${gridColumnsClass(props.columns)}`, children })
|
|
513
|
+
},
|
|
514
|
+
BSLMetric: {
|
|
515
|
+
description: "Metric card bound to a BI query result field.",
|
|
516
|
+
props: bslMetricPropsSchema,
|
|
517
|
+
component: ({ props }) => {
|
|
518
|
+
const queryId = String(props.queryId);
|
|
519
|
+
const valueField = String(props.valueField);
|
|
520
|
+
const data = queryData[queryId];
|
|
521
|
+
const value = data?.rows[0]?.[valueField];
|
|
522
|
+
return /* @__PURE__ */ jsxs(Card, { className: "min-w-0", children: [
|
|
523
|
+
/* @__PURE__ */ jsxs(CardHeader, { className: "pb-2", children: [
|
|
524
|
+
/* @__PURE__ */ jsx(CardDescription, { children: String(props.label) }),
|
|
525
|
+
/* @__PURE__ */ jsxs(CardTitle, { className: "flex items-center gap-2 text-3xl", children: [
|
|
526
|
+
/* @__PURE__ */ jsx(Gauge, { className: "h-5 w-5 text-muted-foreground" }),
|
|
527
|
+
" ",
|
|
528
|
+
data?.loading ? "\u2026" : formatMetricValue(value, props.format)
|
|
529
|
+
] })
|
|
530
|
+
] }),
|
|
531
|
+
/* @__PURE__ */ jsx(CardContent, { className: "text-xs text-muted-foreground", children: data?.error ? /* @__PURE__ */ jsx("span", { className: "text-destructive", children: data.error }) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
532
|
+
"query ",
|
|
533
|
+
/* @__PURE__ */ jsx("code", { children: queryId }),
|
|
534
|
+
" \xB7 field ",
|
|
535
|
+
/* @__PURE__ */ jsx("code", { children: valueField }),
|
|
536
|
+
data?.source ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
537
|
+
" \xB7 ",
|
|
538
|
+
data.source
|
|
539
|
+
] }) : null
|
|
540
|
+
] }) })
|
|
541
|
+
] });
|
|
542
|
+
}
|
|
543
|
+
},
|
|
544
|
+
BSLChart: {
|
|
545
|
+
description: "Chart preview bound to a BI query result.",
|
|
546
|
+
props: bslChartPropsSchema,
|
|
547
|
+
component: ({ props }) => {
|
|
548
|
+
const queryId = String(props.queryId);
|
|
549
|
+
const data = queryData[queryId];
|
|
550
|
+
return /* @__PURE__ */ jsxs(Card, { className: "min-w-0", children: [
|
|
551
|
+
/* @__PURE__ */ jsxs(CardHeader, { children: [
|
|
552
|
+
/* @__PURE__ */ jsxs(CardTitle, { className: "flex items-center gap-2 text-base", children: [
|
|
553
|
+
/* @__PURE__ */ jsx(BarChart3, { className: "h-4 w-4" }),
|
|
554
|
+
" ",
|
|
555
|
+
typeof props.title === "string" ? props.title : queryId
|
|
556
|
+
] }),
|
|
557
|
+
/* @__PURE__ */ jsxs(CardDescription, { children: [
|
|
558
|
+
String(props.renderer ?? "echarts"),
|
|
559
|
+
" \xB7 ",
|
|
560
|
+
String(props.chartType),
|
|
561
|
+
" \xB7 query ",
|
|
562
|
+
/* @__PURE__ */ jsx("code", { children: queryId }),
|
|
563
|
+
data?.source ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
564
|
+
" \xB7 ",
|
|
565
|
+
data.source
|
|
566
|
+
] }) : null
|
|
567
|
+
] })
|
|
568
|
+
] }),
|
|
569
|
+
/* @__PURE__ */ jsx(CardContent, { children: /* @__PURE__ */ jsx(ChartPreview, { data, x: props.x, y: props.y, chartType: String(props.chartType) }) })
|
|
570
|
+
] });
|
|
571
|
+
}
|
|
572
|
+
},
|
|
573
|
+
BSLPerspectiveViewer: {
|
|
574
|
+
description: "Table/Perspective-style viewer bound to a BI query result.",
|
|
575
|
+
props: bslPerspectiveViewerPropsSchema,
|
|
576
|
+
component: ({ props }) => {
|
|
577
|
+
const queryId = String(props.queryId);
|
|
578
|
+
const data = queryData[queryId];
|
|
579
|
+
return /* @__PURE__ */ jsxs(Card, { className: "min-w-0", children: [
|
|
580
|
+
/* @__PURE__ */ jsxs(CardHeader, { children: [
|
|
581
|
+
/* @__PURE__ */ jsxs(CardTitle, { className: "flex items-center gap-2 text-base", children: [
|
|
582
|
+
/* @__PURE__ */ jsx(Table2, { className: "h-4 w-4" }),
|
|
583
|
+
" ",
|
|
584
|
+
typeof props.title === "string" ? props.title : queryId
|
|
585
|
+
] }),
|
|
586
|
+
/* @__PURE__ */ jsxs(CardDescription, { children: [
|
|
587
|
+
"Perspective ",
|
|
588
|
+
String(props.plugin ?? "Datagrid"),
|
|
589
|
+
" \xB7 query ",
|
|
590
|
+
/* @__PURE__ */ jsx("code", { children: queryId }),
|
|
591
|
+
data?.source ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
592
|
+
" \xB7 ",
|
|
593
|
+
data.source
|
|
594
|
+
] }) : null
|
|
595
|
+
] })
|
|
596
|
+
] }),
|
|
597
|
+
/* @__PURE__ */ jsx(CardContent, { children: /* @__PURE__ */ jsx(DataTable, { data, columns: props.columns }) })
|
|
598
|
+
] });
|
|
599
|
+
}
|
|
600
|
+
},
|
|
601
|
+
BSLFilter: {
|
|
602
|
+
description: "Dashboard filter control placeholder for target queries.",
|
|
603
|
+
props: bslFilterPropsSchema,
|
|
604
|
+
component: ({ props }) => {
|
|
605
|
+
const id = String(props.id);
|
|
606
|
+
const field = String(props.field);
|
|
607
|
+
const targets = props.targetQueries;
|
|
608
|
+
const options = [...new Set(targets.flatMap((queryId) => queryData[queryId]?.rows.map((row) => row[field]).filter((value) => value != null).map(String) ?? []))].sort((a, b) => a.localeCompare(b));
|
|
609
|
+
return /* @__PURE__ */ jsxs(Card, { className: "min-w-0 border-primary/25 bg-card", children: [
|
|
610
|
+
/* @__PURE__ */ jsxs(CardHeader, { children: [
|
|
611
|
+
/* @__PURE__ */ jsxs(CardTitle, { className: "flex items-center gap-2 text-base", children: [
|
|
612
|
+
/* @__PURE__ */ jsx(SlidersHorizontal, { className: "h-4 w-4 text-primary" }),
|
|
613
|
+
" ",
|
|
614
|
+
String(props.label ?? props.field)
|
|
615
|
+
] }),
|
|
616
|
+
/* @__PURE__ */ jsxs(CardDescription, { children: [
|
|
617
|
+
String(props.controlType),
|
|
618
|
+
" controller \xB7 targets ",
|
|
619
|
+
targets.join(", ")
|
|
620
|
+
] })
|
|
621
|
+
] }),
|
|
622
|
+
/* @__PURE__ */ jsx(CardContent, { children: /* @__PURE__ */ jsxs(
|
|
623
|
+
"select",
|
|
624
|
+
{
|
|
625
|
+
className: "w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground shadow-sm outline-none focus:ring-2 focus:ring-ring/40",
|
|
626
|
+
value: controllerValues[id] ?? "__all",
|
|
627
|
+
onChange: (event) => setControllerValues((previous) => ({ ...previous, [id]: event.target.value })),
|
|
628
|
+
children: [
|
|
629
|
+
/* @__PURE__ */ jsxs("option", { value: "__all", children: [
|
|
630
|
+
"All ",
|
|
631
|
+
field
|
|
632
|
+
] }),
|
|
633
|
+
options.map((option) => /* @__PURE__ */ jsx("option", { value: option, children: option }, option))
|
|
634
|
+
]
|
|
635
|
+
}
|
|
636
|
+
) })
|
|
637
|
+
] });
|
|
638
|
+
}
|
|
639
|
+
},
|
|
640
|
+
BSLText: {
|
|
641
|
+
description: "Text/markdown explanation block for dashboard context.",
|
|
642
|
+
props: bslTextPropsSchema,
|
|
643
|
+
component: ({ props }) => /* @__PURE__ */ jsx(Card, { className: "min-w-0", children: /* @__PURE__ */ jsx(CardContent, { className: "p-4 text-sm text-muted-foreground", children: String(props.markdown) }) })
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// src/front/index.ts
|
|
650
|
+
import { createGeneratedPaneExplorerPane } from "@hachej/boring-generated-pane/front";
|
|
651
|
+
|
|
652
|
+
// src/front/constants.ts
|
|
653
|
+
var BI_DASHBOARD_PANEL_ID = "bi-dashboard.panel";
|
|
654
|
+
var BI_DASHBOARD_LEFT_TAB_ID = "bi-dashboard.dashboards";
|
|
655
|
+
|
|
656
|
+
// src/front/surfaceResolver.ts
|
|
657
|
+
import {
|
|
658
|
+
WORKSPACE_OPEN_PATH_SURFACE_KIND
|
|
659
|
+
} from "@hachej/boring-workspace/plugin";
|
|
660
|
+
function isDashboardPath(path) {
|
|
661
|
+
return /(^|\/)dashboards\/[^/].*\.dashboard\.json$/i.test(path) || /\.dashboard\.json$/i.test(path);
|
|
662
|
+
}
|
|
663
|
+
function titleFromPath(path) {
|
|
664
|
+
const file = path.split("/").pop() ?? path;
|
|
665
|
+
return file.replace(/\.dashboard\.json$/i, "").replace(/[-_]+/g, " ");
|
|
666
|
+
}
|
|
667
|
+
var biDashboardSurfaceResolver = {
|
|
668
|
+
id: "bi-dashboard.open-path",
|
|
669
|
+
kind: WORKSPACE_OPEN_PATH_SURFACE_KIND,
|
|
670
|
+
source: "app",
|
|
671
|
+
resolve: (request) => {
|
|
672
|
+
if (request.kind !== WORKSPACE_OPEN_PATH_SURFACE_KIND) return null;
|
|
673
|
+
const target = String(request.target ?? "");
|
|
674
|
+
if (!isDashboardPath(target)) return null;
|
|
675
|
+
return {
|
|
676
|
+
component: BI_DASHBOARD_PANEL_ID,
|
|
677
|
+
title: titleFromPath(target),
|
|
678
|
+
params: { path: target },
|
|
679
|
+
score: 110
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
};
|
|
683
|
+
|
|
684
|
+
// src/front/DashboardFilesPane.tsx
|
|
685
|
+
import { useEffect as useEffect3, useMemo as useMemo3, useState as useState3 } from "react";
|
|
686
|
+
import { FileJson2, LayoutDashboard, RefreshCw } from "lucide-react";
|
|
687
|
+
import { Badge, EmptyState as EmptyState2, IconButton as IconButton2 } from "@hachej/boring-ui-kit";
|
|
688
|
+
import { useApiBaseUrl as useApiBaseUrl2, useWorkspaceRequestId as useWorkspaceRequestId2 } from "@hachej/boring-workspace";
|
|
689
|
+
import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
690
|
+
function titleFromPath2(path) {
|
|
691
|
+
const file = path.split("/").pop() ?? path;
|
|
692
|
+
return file.replace(/\.dashboard\.json$/i, "").replace(/[-_]+/g, " ");
|
|
693
|
+
}
|
|
694
|
+
function matchesQuery(path, query) {
|
|
695
|
+
const value = query?.trim().toLowerCase();
|
|
696
|
+
if (!value) return true;
|
|
697
|
+
return path.toLowerCase().includes(value) || titleFromPath2(path).toLowerCase().includes(value);
|
|
698
|
+
}
|
|
699
|
+
function DashboardFilesPane({ params, containerApi }) {
|
|
700
|
+
const apiBaseUrl = useApiBaseUrl2();
|
|
701
|
+
const workspaceId = useWorkspaceRequestId2();
|
|
702
|
+
const [state, setState] = useState3({ loading: true, paths: [] });
|
|
703
|
+
const [refreshKey, setRefreshKey] = useState3(0);
|
|
704
|
+
useEffect3(() => {
|
|
705
|
+
const controller = new AbortController();
|
|
706
|
+
setState((prev) => ({ ...prev, loading: true, error: void 0 }));
|
|
707
|
+
void fetch(`${apiBaseUrl}/api/v1/files/search?q=**%2F*.dashboard.json&limit=500`, {
|
|
708
|
+
signal: controller.signal,
|
|
709
|
+
credentials: "include",
|
|
710
|
+
headers: workspaceId ? { "x-boring-workspace-id": workspaceId } : {}
|
|
711
|
+
}).then(async (response) => {
|
|
712
|
+
if (!response.ok) throw new Error(`Dashboard search failed with HTTP ${response.status}`);
|
|
713
|
+
return await response.json();
|
|
714
|
+
}).then((body) => {
|
|
715
|
+
setState({
|
|
716
|
+
loading: false,
|
|
717
|
+
paths: [...new Set(body.results ?? [])].sort((a, b) => a.localeCompare(b))
|
|
718
|
+
});
|
|
719
|
+
}).catch((error) => {
|
|
720
|
+
if (controller.signal.aborted) return;
|
|
721
|
+
setState({ loading: false, paths: [], error: error instanceof Error ? error.message : String(error) });
|
|
722
|
+
});
|
|
723
|
+
return () => controller.abort();
|
|
724
|
+
}, [apiBaseUrl, refreshKey, workspaceId]);
|
|
725
|
+
const paths = useMemo3(
|
|
726
|
+
() => state.paths.filter((path) => matchesQuery(path, params?.searchQuery)),
|
|
727
|
+
[params?.searchQuery, state.paths]
|
|
728
|
+
);
|
|
729
|
+
const openDashboard = (path) => {
|
|
730
|
+
containerApi.addPanel({
|
|
731
|
+
id: `${BI_DASHBOARD_PANEL_ID}:${path}`,
|
|
732
|
+
component: BI_DASHBOARD_PANEL_ID,
|
|
733
|
+
title: titleFromPath2(path),
|
|
734
|
+
params: { path }
|
|
735
|
+
});
|
|
736
|
+
};
|
|
737
|
+
return /* @__PURE__ */ jsxs2("div", { className: "flex h-full min-h-0 flex-col text-sm", children: [
|
|
738
|
+
/* @__PURE__ */ jsxs2("div", { className: "flex items-center justify-between border-b border-border/60 px-3 py-2", children: [
|
|
739
|
+
/* @__PURE__ */ jsxs2("div", { className: "flex min-w-0 items-center gap-2", children: [
|
|
740
|
+
/* @__PURE__ */ jsx2(LayoutDashboard, { className: "h-4 w-4 text-muted-foreground" }),
|
|
741
|
+
/* @__PURE__ */ jsx2("span", { className: "truncate font-medium", children: "Dashboards" }),
|
|
742
|
+
/* @__PURE__ */ jsx2(Badge, { variant: "secondary", children: state.paths.length })
|
|
743
|
+
] }),
|
|
744
|
+
/* @__PURE__ */ jsx2(
|
|
745
|
+
IconButton2,
|
|
746
|
+
{
|
|
747
|
+
type: "button",
|
|
748
|
+
"aria-label": "Refresh dashboards",
|
|
749
|
+
variant: "ghost",
|
|
750
|
+
size: "icon-xs",
|
|
751
|
+
onClick: () => setRefreshKey((value) => value + 1),
|
|
752
|
+
disabled: state.loading,
|
|
753
|
+
children: /* @__PURE__ */ jsx2(RefreshCw, { className: `h-3.5 w-3.5 ${state.loading ? "animate-spin" : ""}` })
|
|
754
|
+
}
|
|
755
|
+
)
|
|
756
|
+
] }),
|
|
757
|
+
/* @__PURE__ */ jsx2("div", { className: "min-h-0 flex-1 overflow-auto p-2", children: state.error ? /* @__PURE__ */ jsx2(EmptyState2, { title: "Could not list dashboards", description: state.error }) : state.loading ? /* @__PURE__ */ jsx2("div", { className: "px-2 py-3 text-xs text-muted-foreground", children: "Scanning dashboards\u2026" }) : paths.length === 0 ? /* @__PURE__ */ jsx2(
|
|
758
|
+
EmptyState2,
|
|
759
|
+
{
|
|
760
|
+
title: "No dashboards found",
|
|
761
|
+
description: "Create files under dashboards/*.dashboard.json to list them here."
|
|
762
|
+
}
|
|
763
|
+
) : /* @__PURE__ */ jsx2("div", { className: "space-y-1", children: paths.map((path) => /* @__PURE__ */ jsxs2(
|
|
764
|
+
"button",
|
|
765
|
+
{
|
|
766
|
+
type: "button",
|
|
767
|
+
onClick: () => openDashboard(path),
|
|
768
|
+
className: "flex w-full min-w-0 items-start gap-2 rounded-lg px-2 py-2 text-left hover:bg-background/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40",
|
|
769
|
+
children: [
|
|
770
|
+
/* @__PURE__ */ jsx2(FileJson2, { className: "mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" }),
|
|
771
|
+
/* @__PURE__ */ jsxs2("span", { className: "min-w-0 flex-1", children: [
|
|
772
|
+
/* @__PURE__ */ jsx2("span", { className: "block truncate text-[13px] font-medium text-foreground", children: titleFromPath2(path) }),
|
|
773
|
+
/* @__PURE__ */ jsx2("span", { className: "block truncate text-[11px] text-muted-foreground", children: path })
|
|
774
|
+
] })
|
|
775
|
+
]
|
|
776
|
+
},
|
|
777
|
+
path
|
|
778
|
+
)) }) })
|
|
779
|
+
] });
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// src/front/index.ts
|
|
783
|
+
var biDashboardPlugin = definePlugin({
|
|
784
|
+
id: "bi-dashboard",
|
|
785
|
+
label: "BI Dashboard",
|
|
786
|
+
panels: [
|
|
787
|
+
{
|
|
788
|
+
id: BI_DASHBOARD_PANEL_ID,
|
|
789
|
+
label: "BI Dashboard",
|
|
790
|
+
icon: LayoutDashboard2,
|
|
791
|
+
component: BiDashboardPane,
|
|
792
|
+
supportsFullPage: true
|
|
793
|
+
}
|
|
794
|
+
],
|
|
795
|
+
leftTabs: [
|
|
796
|
+
{
|
|
797
|
+
id: BI_DASHBOARD_LEFT_TAB_ID,
|
|
798
|
+
title: "Dashboards",
|
|
799
|
+
panelId: BI_DASHBOARD_LEFT_TAB_ID,
|
|
800
|
+
icon: LayoutDashboard2,
|
|
801
|
+
component: createGeneratedPaneExplorerPane({
|
|
802
|
+
title: "Dashboards",
|
|
803
|
+
patterns: ["**/*.dashboard.json"],
|
|
804
|
+
panelId: BI_DASHBOARD_PANEL_ID,
|
|
805
|
+
itemLabel: "Dashboard",
|
|
806
|
+
emptyDescription: "Create dashboards/*.dashboard.json files to list BI dashboards here."
|
|
807
|
+
}),
|
|
808
|
+
chromeless: true
|
|
809
|
+
}
|
|
810
|
+
],
|
|
811
|
+
surfaceResolvers: [biDashboardSurfaceResolver],
|
|
812
|
+
commands: [
|
|
813
|
+
{
|
|
814
|
+
id: "bi-dashboard.open",
|
|
815
|
+
title: "Open BI Dashboard",
|
|
816
|
+
panelId: BI_DASHBOARD_PANEL_ID,
|
|
817
|
+
keywords: ["bsl", "business intelligence", "dashboard", "perspective", "echarts"]
|
|
818
|
+
}
|
|
819
|
+
]
|
|
820
|
+
});
|
|
821
|
+
var front_default = biDashboardPlugin;
|
|
822
|
+
export {
|
|
823
|
+
BI_DASHBOARD_LEFT_TAB_ID,
|
|
824
|
+
BI_DASHBOARD_PANEL_ID,
|
|
825
|
+
BiDashboardPane,
|
|
826
|
+
DashboardFilesPane,
|
|
827
|
+
biDashboardPlugin,
|
|
828
|
+
biDashboardSurfaceResolver,
|
|
829
|
+
front_default as default,
|
|
830
|
+
sampleBiDashboardSpec
|
|
831
|
+
};
|