Skip to content
Open
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
56 changes: 56 additions & 0 deletions plugins/sentry-cli/skills/sentry-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,47 @@ Update the Sentry CLI to the latest version
- `--json - Output as JSON`
- `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)`

### Dashboard

Manage Sentry dashboards

#### `sentry dashboard list <org/project>`

List dashboards

**Flags:**
- `-w, --web - Open in browser`
- `-n, --limit <value> - Maximum number of dashboards to list - (default: "30")`
- `-f, --fresh - Bypass cache and fetch fresh data`
- `--json - Output as JSON`
- `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)`

#### `sentry dashboard view <args...>`

View a dashboard

**Flags:**
- `-w, --web - Open in browser`
- `-f, --fresh - Bypass cache and fetch fresh data`
- `--json - Output as JSON`
- `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)`

#### `sentry dashboard create <args...>`

Create a dashboard

**Flags:**
- `--widget-title <value> - Inline widget title`
- `--widget-display <value> - Inline widget display type (line, bar, table, big_number, ...)`
- `--widget-dataset <value> - Inline widget dataset (default: spans)`
- `--widget-query <value>... - Inline widget aggregate (e.g. count, p95:span.duration)`
- `--widget-where <value> - Inline widget search conditions filter`
- `--widget-group-by <value>... - Inline widget group-by column (repeatable)`
- `--widget-sort <value> - Inline widget order by (prefix - for desc)`
- `--widget-limit <value> - Inline widget result limit`
- `--json - Output as JSON`
- `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)`

### Repo

Work with Sentry repositories
Expand Down Expand Up @@ -701,6 +742,21 @@ Initialize Sentry in your project
- `--features <value>... - Features to enable: errors,tracing,logs,replay,metrics`
- `-t, --team <value> - Team slug to create the project under`

### Dashboards

List dashboards

#### `sentry dashboards <org/project>`

List dashboards

**Flags:**
- `-w, --web - Open in browser`
- `-n, --limit <value> - Maximum number of dashboards to list - (default: "30")`
- `-f, --fresh - Bypass cache and fetch fresh data`
- `--json - Output as JSON`
- `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)`

### Issues

List issues in a project
Expand Down
5 changes: 5 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { apiCommand } from "./commands/api.js";
import { authRoute } from "./commands/auth/index.js";
import { whoamiCommand } from "./commands/auth/whoami.js";
import { cliRoute } from "./commands/cli/index.js";
import { dashboardRoute } from "./commands/dashboard/index.js";
import { listCommand as dashboardListCommand } from "./commands/dashboard/list.js";
import { eventRoute } from "./commands/event/index.js";
import { helpCommand } from "./commands/help.js";
import { initCommand } from "./commands/init.js";
Expand Down Expand Up @@ -44,6 +46,7 @@ import { error as errorColor, warning } from "./lib/formatters/colors.js";
* Used to suggest the correct command when users type e.g. `sentry projects view cli`.
*/
const PLURAL_TO_SINGULAR: Record<string, string> = {
dashboards: "dashboard",
issues: "issue",
orgs: "org",
projects: "project",
Expand All @@ -60,6 +63,7 @@ export const routes = buildRouteMap({
help: helpCommand,
auth: authRoute,
cli: cliRoute,
dashboard: dashboardRoute,
org: orgRoute,
project: projectRoute,
repo: repoRoute,
Expand All @@ -71,6 +75,7 @@ export const routes = buildRouteMap({
trial: trialRoute,
init: initCommand,
api: apiCommand,
dashboards: dashboardListCommand,
issues: issueListCommand,
orgs: orgListCommand,
projects: projectListCommand,
Expand Down
296 changes: 296 additions & 0 deletions src/commands/dashboard/create.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,296 @@
/**
* sentry dashboard create
*
* Create a new dashboard in a Sentry organization.
*/

import type { SentryContext } from "../../context.js";
import { createDashboard, getProject } from "../../lib/api-client.js";
import {
type ParsedOrgProject,
parseOrgProjectArg,
} from "../../lib/arg-parsing.js";
import { buildCommand, numberParser } from "../../lib/command.js";
import { ContextError, ValidationError } from "../../lib/errors.js";
import { formatDashboardCreated } from "../../lib/formatters/human.js";
import {
fetchProjectId,
resolveAllTargets,
resolveOrg,
resolveProjectBySlug,
toNumericId,
} from "../../lib/resolve-target.js";
import { buildDashboardUrl } from "../../lib/sentry-urls.js";
import {
assignDefaultLayout,
type DashboardDetail,
type DashboardWidget,
DISPLAY_TYPES,
parseAggregate,
parseSortExpression,
parseWidgetInput,
prepareWidgetQueries,
} from "../../types/dashboard.js";

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

type CreateResult = DashboardDetail & { url: string };

/**
* Parse array positional args for `dashboard create`.
*
* Handles:
* - `<title>` — title only (auto-detect org/project)
* - `<target> <title>` — explicit target + title
*/
function parsePositionalArgs(args: string[]): {
title: string;
targetArg: string | undefined;
} {
if (args.length === 0) {
throw new ValidationError("Dashboard title is required.", "title");
}
if (args.length === 1) {
return { title: args[0] as string, targetArg: undefined };
}
// Two args: first is target, second is title
return { title: args[1] as string, targetArg: args[0] as string };
}

/** Result of resolving org + project IDs from the parsed target */
type ResolvedDashboardTarget = {
orgSlug: string;
projectIds: number[];
};

/** Enrich targets that lack a projectId by calling the project API */
async function enrichTargetProjectIds(
targets: { org: string; project: string; projectId?: number }[]
): Promise<number[]> {
const enriched = await Promise.all(
targets.map(async (t) => {
if (t.projectId !== undefined) {
return t.projectId;
}
try {
const info = await getProject(t.org, t.project);
return toNumericId(info.id);
} catch {
return;
}
})
);
return enriched.filter((id): id is number => id !== undefined);
}

/** Resolve org and project IDs from the parsed target argument */
async function resolveDashboardTarget(
parsed: ParsedOrgProject,
cwd: string
): Promise<ResolvedDashboardTarget> {
switch (parsed.type) {
case "explicit": {
const pid = await fetchProjectId(parsed.org, parsed.project);
return {
orgSlug: parsed.org,
projectIds: pid !== undefined ? [pid] : [],
};
}
case "org-all":
return { orgSlug: parsed.org, projectIds: [] };

case "project-search": {
const found = await resolveProjectBySlug(
parsed.projectSlug,
"sentry dashboard create <org>/<project> <title>"
);
const pid = await fetchProjectId(found.org, found.project);
return {
orgSlug: found.org,
projectIds: pid !== undefined ? [pid] : [],
};
}
case "auto-detect": {
const result = await resolveAllTargets({ cwd });
if (result.targets.length === 0) {
const resolved = await resolveOrg({ cwd });
if (!resolved) {
throw new ContextError(
"Organization",
"sentry dashboard create <org>/ <title>"
);
}
return { orgSlug: resolved.org, projectIds: [] };
}
const orgSlug = (result.targets[0] as (typeof result.targets)[0]).org;
const projectIds = await enrichTargetProjectIds(result.targets);
return { orgSlug, projectIds };
}
default: {
const _exhaustive: never = parsed;
throw new Error(
`Unexpected parsed type: ${(_exhaustive as { type: string }).type}`
);
}
}
}

/** Build an inline widget from --widget-* flags */
function buildInlineWidget(flags: CreateFlags): DashboardWidget {
if (!flags["widget-title"]) {
throw new ValidationError(
"Missing --widget-title. Both --widget-title and --widget-display are required for inline widgets.\n\n" +
"Example:\n" +
" sentry dashboard create 'My Dashboard' --widget-title \"Error Count\" --widget-display big_number --widget-query count",
"widget-title"
);
}

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

const rawWidget = {
title: flags["widget-title"],
displayType: flags["widget-display"] as string,
...(flags["widget-dataset"] && { widgetType: flags["widget-dataset"] }),
queries: [
{
aggregates,
columns,
conditions: flags["widget-where"] ?? "",
...(orderby && { orderby }),
name: "",
},
],
...(flags["widget-limit"] !== undefined && {
limit: flags["widget-limit"],
}),
};
return prepareWidgetQueries(parseWidgetInput(rawWidget));
}

export const createCommand = buildCommand({
docs: {
brief: "Create a dashboard",
fullDescription:
"Create a new Sentry dashboard.\n\n" +
"Examples:\n" +
" sentry dashboard create 'My Dashboard'\n" +
" sentry dashboard create my-org/ 'My Dashboard'\n" +
" sentry dashboard create my-org/my-project 'My Dashboard'\n\n" +
"With an inline widget:\n" +
" sentry dashboard create 'My Dashboard' \\\n" +
' --widget-title "Error Count" --widget-display big_number --widget-query count',
},
output: {
json: true,
human: formatDashboardCreated,
},
parameters: {
positional: {
kind: "array",
parameter: {
brief: "[<org/project>] <title>",
parse: String,
},
},
flags: {
"widget-title": {
kind: "parsed",
parse: String,
brief: "Inline widget title",
optional: true,
},
"widget-display": {
kind: "parsed",
parse: String,
brief: "Inline widget display type (line, bar, table, big_number, ...)",
optional: true,
},
"widget-dataset": {
kind: "parsed",
parse: String,
brief: "Inline widget dataset (default: spans)",
optional: true,
},
"widget-query": {
kind: "parsed",
parse: String,
brief: "Inline widget aggregate (e.g. count, p95:span.duration)",
variadic: true,
optional: true,
},
"widget-where": {
kind: "parsed",
parse: String,
brief: "Inline widget search conditions filter",
optional: true,
},
"widget-group-by": {
kind: "parsed",
parse: String,
brief: "Inline widget group-by column (repeatable)",
variadic: true,
optional: true,
},
"widget-sort": {
kind: "parsed",
parse: String,
brief: "Inline widget order by (prefix - for desc)",
optional: true,
},
"widget-limit": {
kind: "parsed",
parse: numberParser,
brief: "Inline widget result limit",
optional: true,
},
},
},
async func(this: SentryContext, flags: CreateFlags, ...args: string[]) {
const { cwd } = this;

const { title, targetArg } = parsePositionalArgs(args);
const parsed = parseOrgProjectArg(targetArg);
const { orgSlug, projectIds } = await resolveDashboardTarget(parsed, cwd);

const widgets: DashboardWidget[] = [];
if (flags["widget-display"]) {
const validated = buildInlineWidget(flags);
widgets.push(assignDefaultLayout(validated, widgets));
} else if (flags["widget-title"]) {
throw new ValidationError(
"Missing --widget-display. Both --widget-title and --widget-display are required for inline widgets.\n\n" +
"Example:\n" +
" sentry dashboard create 'My Dashboard' --widget-title \"Error Count\" --widget-display big_number --widget-query count\n\n" +
`Valid display types: ${DISPLAY_TYPES.join(", ")}`,
"widget-display"
);
}

const dashboard = await createDashboard(orgSlug, {
title,
widgets,
projects: projectIds.length > 0 ? projectIds : undefined,
});
const url = buildDashboardUrl(orgSlug, dashboard.id);

return {
data: { ...dashboard, url } as CreateResult,
};
},
});
Loading
Loading