Skip to content

Commit 252624c

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
feat(webapp,sdk,core,cli): manage custom metric dashboards from the API
Custom metric dashboards can now be managed from code: list, create, rename, replace and delete them and their widgets through the API, the SDK and the MCP server. ```ts import { query } from "@trigger.dev/sdk"; const dashboard = await query.dashboards.create({ title: "Email tasks", widgets: [ { title: "Total runs", query: "SELECT count() AS total FROM runs WHERE task_identifier = 'send-email'", display: { type: "bignumber", column: "total", aggregation: "sum" }, }, ], }); await query.dashboards.widgets.add(dashboard.id, { title: "Failed runs", query: "SELECT count() AS failed FROM runs WHERE status = 'Failed'", display: { type: "bignumber", column: "failed", aggregation: "sum" }, }); ``` How it works: - New endpoints under `/api/v1/query/dashboards`: create, retrieve, update, replace (the whole widget list, for dashboards as code), delete, and add, update or delete a single widget. The existing list endpoint now returns custom dashboards next to the built-in ones, with a `type` field. - `position` places a widget on the 12-column grid. On replace, a widget that already exists keeps its position unless you pass a new one; new widgets without a position are stacked full width below the others. - Every widget query is compiled against the queryable tables when it is saved, so an unknown table or column fails the request with the list of valid columns. - Writes take an optional `revision`. If the dashboard changed since that revision was read, the write fails with a 409 instead of overwriting the other change. - Creating dashboards or widgets past the plan's limits returns a 403. Concurrent creates can no longer exceed the dashboard limit. - Reading needs `read:query`; changing dashboards needs `write:query`. Public access tokens work, which is how the MCP tools authenticate. - The MCP server adds `create_dashboard` and `set_dashboard_widgets`, hidden in `--readonly`, and `run_dashboard_query` now runs custom dashboard widgets too. Mono-RevId: cee3a676407aecdaefa16724eea59c7fccab1563
1 parent 9c43235 commit 252624c

33 files changed

Lines changed: 2908 additions & 549 deletions

‎.changeset/dashboards-api.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
"@trigger.dev/core": patch
4+
"trigger.dev": patch
5+
---
6+
7+
Manage custom metric dashboards from code. `query.dashboards` lists, creates, updates and deletes custom dashboards and their widgets, and every widget query is checked when it is saved. The MCP server adds `create_dashboard` and `set_dashboard_widgets`, so your AI assistant can build a dashboard for you.
8+
9+
```ts
10+
import { query } from "@trigger.dev/sdk";
11+
12+
await query.dashboards.create({
13+
title: "Email tasks",
14+
widgets: [
15+
{
16+
title: "Total runs",
17+
query: "SELECT count() AS total FROM runs",
18+
display: { type: "bignumber", column: "total", aggregation: "sum" },
19+
},
20+
],
21+
});
22+
```

‎apps/webapp/app/components/metrics/QueryWidget.tsx‎

Lines changed: 15 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,19 @@ import { DocumentDuplicateIcon, PencilSquareIcon, TrashIcon } from "@heroicons/r
22
import { ClipboardIcon } from "@heroicons/react/24/outline";
33
import { ChartBarIcon } from "@heroicons/react/24/solid";
44
import { type OutputColumnMetadata } from "@internal/tsql";
5+
import {
6+
DashboardWidgetDisplay,
7+
type DashboardBigNumberAggregation,
8+
type DashboardBigNumberConfig,
9+
type DashboardChartAggregation,
10+
type DashboardChartConfig,
11+
type DashboardSortDirection,
12+
} from "@trigger.dev/core/v3/schemas";
513
import { DialogClose } from "@radix-ui/react-dialog";
614
import { IconBraces, IconChartHistogram, IconFileTypeCsv } from "@tabler/icons-react";
715
import { assertNever } from "assert-never";
816
import { Maximize2 } from "lucide-react";
917
import { useCallback, useRef, useState, type ReactNode } from "react";
10-
import { z } from "zod";
1118
import { Card } from "~/components/primitives/charts/Card";
1219
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
1320
import { SimpleTooltip } from "~/components/primitives/Tooltip";
@@ -31,88 +38,13 @@ import {
3138
PopoverVerticalEllipseTrigger,
3239
} from "../primitives/Popover";
3340

34-
const ChartType = z.union([z.literal("bar"), z.literal("line")]);
35-
type ChartType = z.infer<typeof ChartType>;
36-
37-
const SortDirection = z.union([z.literal("asc"), z.literal("desc")]);
38-
export type SortDirection = z.infer<typeof SortDirection>;
39-
40-
const AggregationType = z.union([
41-
z.literal("sum"),
42-
z.literal("avg"),
43-
z.literal("count"),
44-
z.literal("min"),
45-
z.literal("max"),
46-
]);
47-
export type AggregationType = z.infer<typeof AggregationType>;
48-
49-
const chartConfigOptions = {
50-
chartType: ChartType,
51-
xAxisColumn: z.string().nullable(),
52-
yAxisColumns: z.string().array(),
53-
groupByColumn: z.string().nullable(),
54-
stacked: z.boolean(),
55-
sortByColumn: z.string().nullable(),
56-
sortDirection: SortDirection,
57-
aggregation: AggregationType,
58-
seriesColors: z.record(z.string(), z.string()).optional(),
59-
};
60-
61-
const ChartConfiguration = z.object({ ...chartConfigOptions });
62-
export type ChartConfiguration = z.infer<typeof ChartConfiguration>;
63-
64-
const BigNumberAggregationType = z.union([
65-
z.literal("sum"),
66-
z.literal("avg"),
67-
z.literal("count"),
68-
z.literal("min"),
69-
z.literal("max"),
70-
z.literal("first"),
71-
z.literal("last"),
72-
]);
73-
export type BigNumberAggregationType = z.infer<typeof BigNumberAggregationType>;
74-
75-
const BigNumberSortDirection = z.union([z.literal("asc"), z.literal("desc")]);
76-
77-
const bigNumberConfigOptions = {
78-
column: z.string(),
79-
aggregation: BigNumberAggregationType,
80-
sortDirection: BigNumberSortDirection.optional(),
81-
abbreviate: z.boolean().default(false),
82-
prefix: z.string().optional(),
83-
suffix: z.string().optional(),
84-
};
85-
86-
const BigNumberConfiguration = z.object({ ...bigNumberConfigOptions });
87-
export type BigNumberConfiguration = z.infer<typeof BigNumberConfiguration>;
88-
89-
export const QueryWidgetConfig = z.discriminatedUnion("type", [
90-
z.object({
91-
type: z.literal("table"),
92-
prettyFormatting: z.boolean().default(true),
93-
sorting: z
94-
.array(
95-
z.object({
96-
desc: z.boolean(),
97-
id: z.string(),
98-
})
99-
)
100-
.default([]),
101-
}),
102-
z.object({
103-
type: z.literal("chart"),
104-
...chartConfigOptions,
105-
}),
106-
z.object({
107-
type: z.literal("bignumber"),
108-
...bigNumberConfigOptions,
109-
}),
110-
z.object({
111-
type: z.literal("title"),
112-
}),
113-
]);
114-
115-
export type QueryWidgetConfig = z.infer<typeof QueryWidgetConfig>;
41+
export const QueryWidgetConfig = DashboardWidgetDisplay;
42+
export type QueryWidgetConfig = DashboardWidgetDisplay;
43+
export type SortDirection = DashboardSortDirection;
44+
export type AggregationType = DashboardChartAggregation;
45+
export type ChartConfiguration = DashboardChartConfig;
46+
export type BigNumberAggregationType = DashboardBigNumberAggregation;
47+
export type BigNumberConfiguration = DashboardBigNumberConfig;
11648

11749
/** Result data containing rows and column metadata */
11850
export type QueryWidgetData = {

‎apps/webapp/app/presenters/v3/MetricDashboardPresenter.server.ts‎

Lines changed: 11 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,21 @@
11
import { BasePresenter } from "./basePresenter.server";
22
import { getLimit } from "~/services/platform.v3.server";
3-
import { z } from "zod";
43
import { fromZodError } from "zod-validation-error";
54
import { builtInDashboard } from "./BuiltInDashboards.server";
6-
import { QueryWidgetConfig } from "~/components/metrics/QueryWidget";
5+
import {
6+
DashboardDefinition,
7+
DashboardLayoutItem,
8+
DashboardStoredWidget,
9+
} from "@trigger.dev/core/v3/schemas";
710

8-
export const LayoutItem = z.object({
9-
i: z.string(),
10-
x: z.number(),
11-
y: z.number(),
12-
w: z.number(),
13-
h: z.number(),
14-
minH: z.number().optional(),
15-
maxH: z.number().optional(),
16-
});
11+
export const LayoutItem = DashboardLayoutItem;
12+
export type LayoutItem = DashboardLayoutItem;
1713

18-
export type LayoutItem = z.infer<typeof LayoutItem>;
14+
export const Widget = DashboardStoredWidget;
15+
export type Widget = DashboardStoredWidget;
1916

20-
export const Widget = z.object({
21-
title: z.string(),
22-
query: z.string().default(""),
23-
display: QueryWidgetConfig,
24-
// Opt into server-side gap fill (carry-forward for gauges, zero-fill for counters).
25-
// Top-level rather than in `display` because display config is client-only and never reaches the query POST.
26-
fillGaps: z.boolean().optional(),
27-
});
28-
29-
export type Widget = z.infer<typeof Widget>;
30-
31-
export const DashboardLayout = z.discriminatedUnion("version", [
32-
z.object({
33-
version: z.literal("1"),
34-
layout: z.array(LayoutItem),
35-
widgets: z.record(z.string(), Widget),
36-
}),
37-
]);
38-
39-
export type DashboardLayout = z.infer<typeof DashboardLayout>;
17+
export const DashboardLayout = DashboardDefinition;
18+
export type DashboardLayout = DashboardDefinition;
4019

4120
export type CustomDashboard = {
4221
friendlyId: string;

‎apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.custom.$dashboardId/route.tsx‎

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ import { Sheet, SheetContent } from "~/components/primitives/SheetV3";
3636
import { useToast } from "~/components/primitives/Toast";
3737
import { SimpleTooltip } from "~/components/primitives/Tooltip";
3838
import { QueryEditor, type QueryEditorSaveData } from "~/components/query/QueryEditor";
39-
import { prisma } from "~/db.server";
4039
import { env } from "~/env.server";
4140
import { useDashboardEditor } from "~/hooks/useDashboardEditor";
4241
import { useEnvironment } from "~/hooks/useEnvironment";
@@ -49,6 +48,9 @@ import { getTaskIdentifiers } from "~/models/task.server";
4948
import { MetricDashboardPresenter } from "~/presenters/v3/MetricDashboardPresenter.server";
5049
import { QueryPresenter } from "~/presenters/v3/QueryPresenter.server";
5150
import { removeFavoritesByUrlSubstring } from "~/services/dashboardPreferences.server";
51+
import { ServiceValidationError } from "~/v3/services/common.server";
52+
import { MetricDashboardService } from "~/v3/services/metricDashboard.server";
53+
import { tryCatch } from "@trigger.dev/core/utils";
5254
import { hasAdminDisplayAccess, requireUser } from "~/services/session.server";
5355
import {
5456
EnvironmentParamSchema,
@@ -132,26 +134,23 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
132134
throw new Response("Project not found", { status: 404 });
133135
}
134136

135-
// Load the dashboard
136-
const dashboard = await prisma.metricsDashboard.findFirst({
137-
where: {
138-
friendlyId: dashboardId,
139-
organizationId: project.organizationId,
140-
},
141-
});
137+
const service = new MetricDashboardService();
138+
const ref = { organizationId: project.organizationId, friendlyId: dashboardId };
142139

143-
if (!dashboard) {
144-
throw new Response("Dashboard not found", { status: 404 });
140+
const [lookupError, dashboard] = await tryCatch(service.get(ref));
141+
if (lookupError) {
142+
if (lookupError instanceof ServiceValidationError) {
143+
throw new Response(lookupError.message, { status: lookupError.status ?? 400 });
144+
}
145+
throw lookupError;
145146
}
146147

147148
const formData = await request.formData();
148149
const action = formData.get("action");
149150

150151
switch (action) {
151152
case "delete": {
152-
await prisma.metricsDashboard.delete({
153-
where: { id: dashboard.id },
154-
});
153+
await service.delete(ref);
155154

156155
// Drop any favorites pointing at this dashboard so the side menu doesn't keep a dead link
157156
await removeFavoritesByUrlSubstring({
@@ -176,10 +175,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
176175
throw new Response("Title is required", { status: 400 });
177176
}
178177

179-
await prisma.metricsDashboard.update({
180-
where: { id: dashboard.id },
181-
data: { title: newTitle.trim() },
182-
});
178+
await service.update({ ...ref, title: newTitle.trim() });
183179

184180
return typedjson({ success: true });
185181
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { json } from "@remix-run/server-runtime";
2+
import { tryCatch } from "@trigger.dev/core/utils";
3+
import {
4+
ReplaceDashboardRequestBody,
5+
UpdateDashboardRequestBody,
6+
} from "@trigger.dev/core/v3/schemas";
7+
import { z } from "zod";
8+
import {
9+
createLoaderApiRoute,
10+
createMultiMethodApiRoute,
11+
} from "~/services/routeBuilders/apiBuilder.server";
12+
import {
13+
assertValidWidgetQueries,
14+
dashboardApiErrorResponse,
15+
MetricDashboardService,
16+
toDashboardObject,
17+
} from "~/v3/services/metricDashboard.server";
18+
19+
const ParamsSchema = z.object({
20+
dashboardId: z.string(),
21+
});
22+
23+
export const loader = createLoaderApiRoute(
24+
{
25+
params: ParamsSchema,
26+
allowJWT: true,
27+
corsStrategy: "all",
28+
findResource: async () => 1,
29+
authorization: {
30+
action: "read",
31+
resource: () => ({ type: "query", id: "dashboards" }),
32+
},
33+
},
34+
async ({ params, authentication }) => {
35+
const [error, dashboard] = await tryCatch(
36+
new MetricDashboardService().get({
37+
organizationId: authentication.environment.organizationId,
38+
friendlyId: params.dashboardId,
39+
})
40+
);
41+
42+
if (error) {
43+
return dashboardApiErrorResponse(error);
44+
}
45+
46+
return json(toDashboardObject(dashboard));
47+
}
48+
);
49+
50+
const { action } = createMultiMethodApiRoute({
51+
params: ParamsSchema,
52+
allowJWT: true,
53+
authorization: {
54+
action: "write",
55+
resource: () => ({ type: "query", id: "dashboards" }),
56+
},
57+
methods: {
58+
PATCH: {
59+
body: UpdateDashboardRequestBody,
60+
handler: async ({ params, body, authentication }) => {
61+
const input = body as z.infer<typeof UpdateDashboardRequestBody>;
62+
const [error, dashboard] = await tryCatch(
63+
new MetricDashboardService().update({
64+
organizationId: authentication.environment.organizationId,
65+
friendlyId: params.dashboardId,
66+
title: input.title,
67+
description: input.description,
68+
revision: input.revision,
69+
})
70+
);
71+
72+
if (error) {
73+
return dashboardApiErrorResponse(error);
74+
}
75+
76+
return json(toDashboardObject(dashboard));
77+
},
78+
},
79+
PUT: {
80+
body: ReplaceDashboardRequestBody,
81+
handler: async ({ params, body, authentication }) => {
82+
const input = body as z.infer<typeof ReplaceDashboardRequestBody>;
83+
const [error, dashboard] = await tryCatch(
84+
(async () => {
85+
assertValidWidgetQueries(input.widgets);
86+
87+
return new MetricDashboardService().replace({
88+
organizationId: authentication.environment.organizationId,
89+
friendlyId: params.dashboardId,
90+
title: input.title,
91+
description: input.description,
92+
widgets: input.widgets,
93+
revision: input.revision,
94+
});
95+
})()
96+
);
97+
98+
if (error) {
99+
return dashboardApiErrorResponse(error);
100+
}
101+
102+
return json(toDashboardObject(dashboard));
103+
},
104+
},
105+
DELETE: {
106+
handler: async ({ params, authentication }) => {
107+
const [error, dashboard] = await tryCatch(
108+
new MetricDashboardService().delete({
109+
organizationId: authentication.environment.organizationId,
110+
friendlyId: params.dashboardId,
111+
})
112+
);
113+
114+
if (error) {
115+
return dashboardApiErrorResponse(error);
116+
}
117+
118+
return json({ id: dashboard.friendlyId });
119+
},
120+
},
121+
},
122+
});
123+
124+
export { action };

0 commit comments

Comments
 (0)