Skip to content
Draft
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
5 changes: 4 additions & 1 deletion src/commands/dashboard/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import { buildRouteMap } from "@stricli/core";
import { createCommand } from "./create.js";
import { listCommand } from "./list.js";
import { viewCommand } from "./view.js";
import { widgetRoute } from "./widget/index.js";

export const dashboardRoute = buildRouteMap({
routes: {
list: listCommand,
view: viewCommand,
create: createCommand,
widget: widgetRoute,
},
docs: {
brief: "Manage Sentry dashboards",
Expand All @@ -16,7 +18,8 @@ export const dashboardRoute = buildRouteMap({
"Commands:\n" +
" list List dashboards\n" +
" view View a dashboard\n" +
" create Create a dashboard",
" create Create a dashboard\n" +
" widget Manage dashboard widgets (add, edit, delete)",
hideRoute: {},
},
});
64 changes: 64 additions & 0 deletions src/commands/dashboard/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ import type { parseOrgProjectArg } from "../../lib/arg-parsing.js";
import { ContextError, ValidationError } from "../../lib/errors.js";
import { resolveOrg } from "../../lib/resolve-target.js";
import { isAllDigits } from "../../lib/utils.js";
import {
type DashboardWidget,
DISPLAY_TYPES,
WIDGET_TYPES,
} from "../../types/dashboard.js";

/**
* Resolve org slug from a parsed org/project target argument.
Expand Down Expand Up @@ -119,3 +124,62 @@ export async function resolveDashboardId(

return match.id;
}

/**
* Resolve widget index from --index or --title flags.
*
* @param widgets - Array of widgets in the dashboard
* @param index - Explicit 0-based widget index
* @param title - Widget title to match
* @returns Resolved widget index
*/
export function resolveWidgetIndex(
widgets: DashboardWidget[],
index: number | undefined,
title: string | undefined
): number {
if (index !== undefined) {
if (index < 0 || index >= widgets.length) {
throw new ValidationError(
`Widget index ${index} out of range (dashboard has ${widgets.length} widgets).`,
"index"
);
}
return index;
}
const matchIndex = widgets.findIndex((w) => w.title === title);
if (matchIndex === -1) {
throw new ValidationError(
`No widget with title '${title}' found in dashboard.`,
"title"
);
}
return matchIndex;
}

/**
* Validate --display and --dataset flag values against known enums.
*
* @param display - Display type flag value
* @param dataset - Dataset flag value
*/
export function validateWidgetEnums(display?: string, dataset?: string): void {
if (
display &&
!DISPLAY_TYPES.includes(display as (typeof DISPLAY_TYPES)[number])
) {
throw new ValidationError(
`Invalid --display value "${display}".\nValid display types: ${DISPLAY_TYPES.join(", ")}`,
"display"
);
}
if (
dataset &&
!WIDGET_TYPES.includes(dataset as (typeof WIDGET_TYPES)[number])
) {
throw new ValidationError(
`Invalid --dataset value "${dataset}".\nValid datasets: ${WIDGET_TYPES.join(", ")}`,
"dataset"
);
}
}
209 changes: 209 additions & 0 deletions src/commands/dashboard/widget/add.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
/**
* sentry dashboard widget add
*
* Add a widget to an existing dashboard using inline flags.
*/

import type { SentryContext } from "../../../context.js";
import { getDashboard, updateDashboard } from "../../../lib/api-client.js";
import { parseOrgProjectArg } from "../../../lib/arg-parsing.js";
import { buildCommand, numberParser } from "../../../lib/command.js";
import { ValidationError } from "../../../lib/errors.js";
import { formatWidgetAdded } from "../../../lib/formatters/human.js";
import { buildDashboardUrl } from "../../../lib/sentry-urls.js";
import {
assignDefaultLayout,
type DashboardDetail,
type DashboardWidget,
parseAggregate,
parseSortExpression,
parseWidgetInput,
prepareDashboardForUpdate,
prepareWidgetQueries,
} from "../../../types/dashboard.js";
import {
parseDashboardPositionalArgs,
resolveDashboardId,
resolveOrgFromTarget,
validateWidgetEnums,
} from "../resolve.js";

type AddFlags = {
readonly display: string;
readonly dataset?: string;
readonly query?: string[];
readonly where?: string;
readonly "group-by"?: string[];
readonly sort?: string;
readonly limit?: number;
readonly json: boolean;
readonly fields?: string[];
};

type AddResult = {
dashboard: DashboardDetail;
widget: DashboardWidget;
url: string;
};

/**
* Parse positional args for widget add.
* Last arg is widget title, rest go to dashboard resolution.
*/
function parseAddPositionalArgs(args: string[]): {
dashboardArgs: string[];
title: string;
} {
if (args.length < 2) {
throw new ValidationError(
"Widget title is required as a positional argument.\n\n" +
"Example:\n" +
' sentry dashboard widget add <dashboard> "My Widget" --display line --query count',
"title"
);
}

const title = args.at(-1) as string;
const dashboardArgs = args.slice(0, -1);
return { dashboardArgs, title };
}

export const addCommand = buildCommand({
docs: {
brief: "Add a widget to a dashboard",
fullDescription:
"Add a widget to an existing Sentry dashboard.\n\n" +
"The dashboard can be specified by numeric ID or title.\n\n" +
"Examples:\n" +
" sentry dashboard widget add 'My Dashboard' \"Error Count\" \\\n" +
" --display big_number --query count\n\n" +
" sentry dashboard widget add 'My Dashboard' \"Errors by Browser\" \\\n" +
" --display line --query count --group-by browser.name\n\n" +
" sentry dashboard widget add 'My Dashboard' \"Top Endpoints\" \\\n" +
" --display table --query count --query p95:span.duration \\\n" +
" --group-by transaction --sort -count --limit 10\n\n" +
"Query shorthand (--query flag):\n" +
" count → count() (bare name = no-arg aggregate)\n" +
" p95:span.duration → p95(span.duration) (colon = function with arg)\n" +
" count() → count() (parens passthrough)\n\n" +
"Sort shorthand (--sort flag):\n" +
" count → count() (ascending)\n" +
" -count → -count() (descending)",
},
output: {
json: true,
human: formatWidgetAdded,
},
parameters: {
positional: {
kind: "array",
parameter: {
brief: "[<org/project>] <dashboard> <title>",
parse: String,
},
},
flags: {
display: {
kind: "parsed",
parse: String,
brief: "Display type (line, bar, table, big_number, ...)",
},
dataset: {
kind: "parsed",
parse: String,
brief: "Widget dataset (default: spans)",
optional: true,
},
query: {
kind: "parsed",
parse: String,
brief: "Aggregate expression (e.g. count, p95:span.duration)",
variadic: true,
optional: true,
},
where: {
kind: "parsed",
parse: String,
brief: "Search conditions filter (e.g. is:unresolved)",
optional: true,
},
"group-by": {
kind: "parsed",
parse: String,
brief: "Group-by column (repeatable)",
variadic: true,
optional: true,
},
sort: {
kind: "parsed",
parse: String,
brief: "Order by (prefix - for desc, e.g. -count)",
optional: true,
},
limit: {
kind: "parsed",
parse: numberParser,
brief: "Result limit",
optional: true,
},
},
aliases: {
d: "display",
q: "query",
w: "where",
g: "group-by",
s: "sort",
n: "limit",
},
},
async func(this: SentryContext, flags: AddFlags, ...args: string[]) {
const { cwd } = this;

const { dashboardArgs, title } = parseAddPositionalArgs(args);
const { dashboardRef, targetArg } =
parseDashboardPositionalArgs(dashboardArgs);
const parsed = parseOrgProjectArg(targetArg);
const orgSlug = await resolveOrgFromTarget(
parsed,
cwd,
"sentry dashboard widget add <org>/ <dashboard> <title> --display <type>"
);
const dashboardId = await resolveDashboardId(orgSlug, dashboardRef);

validateWidgetEnums(flags.display, flags.dataset);

const aggregates = (flags.query ?? ["count"]).map(parseAggregate);
const columns = flags["group-by"] ?? [];
const orderby = flags.sort ? parseSortExpression(flags.sort) : undefined;

const raw = {
title,
displayType: flags.display,
...(flags.dataset && { widgetType: flags.dataset }),
queries: [
{
aggregates,
columns,
conditions: flags.where ?? "",
...(orderby && { orderby }),
name: "",
},
],
...(flags.limit !== undefined && { limit: flags.limit }),
};
let newWidget = prepareWidgetQueries(parseWidgetInput(raw));

// GET current dashboard → append widget with auto-layout → PUT
const current = await getDashboard(orgSlug, dashboardId);
const updateBody = prepareDashboardForUpdate(current);
newWidget = assignDefaultLayout(newWidget, updateBody.widgets);
updateBody.widgets.push(newWidget);

const updated = await updateDashboard(orgSlug, dashboardId, updateBody);
const url = buildDashboardUrl(orgSlug, dashboardId);

return {
data: { dashboard: updated, widget: newWidget, url } as AddResult,
};
},
});
Loading
Loading