diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 9df205c9..0e88e10a 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -496,6 +496,37 @@ Update the Sentry CLI to the latest version - `--json - Output as JSON` - `--fields - Comma-separated fields to include in JSON output (dot.notation supported)` +### Dashboard + +Manage Sentry dashboards + +#### `sentry dashboard list ` + +List dashboards + +**Flags:** +- `-w, --web - Open in browser` +- `--json - Output as JSON` +- `--fields - Comma-separated fields to include in JSON output (dot.notation supported)` + +#### `sentry dashboard view ` + +View a dashboard + +**Flags:** +- `-w, --web - Open in browser` +- `--json - Output as JSON` +- `--fields - Comma-separated fields to include in JSON output (dot.notation supported)` + +#### `sentry dashboard create ` + +Create a dashboard + +**Flags:** +- `--widget-json - Path to JSON file containing widget definitions` +- `--json - Output as JSON` +- `--fields - Comma-separated fields to include in JSON output (dot.notation supported)` + ### Repo Work with Sentry repositories @@ -680,6 +711,19 @@ Initialize Sentry in your project - `--dry-run - Preview changes without applying them` - `--features ... - Features to enable: errors,tracing,logs,replay,metrics` +### Dashboards + +List dashboards + +#### `sentry dashboards ` + +List dashboards + +**Flags:** +- `-w, --web - Open in browser` +- `--json - Output as JSON` +- `--fields - Comma-separated fields to include in JSON output (dot.notation supported)` + ### Issues List issues in a project diff --git a/src/app.ts b/src/app.ts index d3202987..be2bed40 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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"; @@ -42,6 +44,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 = { + dashboards: "dashboard", issues: "issue", orgs: "org", projects: "project", @@ -57,6 +60,7 @@ export const routes = buildRouteMap({ help: helpCommand, auth: authRoute, cli: cliRoute, + dashboard: dashboardRoute, org: orgRoute, project: projectRoute, repo: repoRoute, @@ -67,6 +71,7 @@ export const routes = buildRouteMap({ trace: traceRoute, init: initCommand, api: apiCommand, + dashboards: dashboardListCommand, issues: issueListCommand, orgs: orgListCommand, projects: projectListCommand, diff --git a/src/commands/dashboard/create.ts b/src/commands/dashboard/create.ts new file mode 100644 index 00000000..37571bbb --- /dev/null +++ b/src/commands/dashboard/create.ts @@ -0,0 +1,223 @@ +/** + * 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 } 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, + DashboardWidgetSchema, +} from "../../types/dashboard.js"; + +type CreateFlags = { + readonly "widget-json"?: string; + readonly json: boolean; + readonly fields?: string[]; +}; + +type CreateResult = DashboardDetail & { url: string }; + +/** + * Parse array positional args for `dashboard create`. + * + * Handles: + * - `` — 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}` + ); + } + } +} + +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" + + " sentry dashboard create 'My Dashboard' --widget-json widgets.json", + }, + output: { + json: true, + human: formatDashboardCreated, + }, + parameters: { + positional: { + kind: "array", + parameter: { + brief: "[<org/project>] <title>", + parse: String, + }, + }, + flags: { + "widget-json": { + kind: "parsed", + parse: String, + brief: "Path to JSON file containing widget definitions", + 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[] = []; + const widgetJsonPath = flags["widget-json"]; + if (widgetJsonPath) { + const file = Bun.file(widgetJsonPath); + if (!(await file.exists())) { + throw new ValidationError( + `Widget JSON file not found: ${widgetJsonPath}`, + "widget-json" + ); + } + const raw = await file.text(); + let widgetParsed: unknown; + try { + widgetParsed = JSON.parse(raw); + } catch { + throw new ValidationError( + `Invalid JSON in widget file: ${widgetJsonPath}`, + "widget-json" + ); + } + + const arr = Array.isArray(widgetParsed) ? widgetParsed : [widgetParsed]; + for (const item of arr) { + const result = DashboardWidgetSchema.safeParse(item); + if (!result.success) { + throw new ValidationError( + `Invalid widget definition: ${result.error.message}`, + "widget-json" + ); + } + // Assign layout sequentially so each widget stacks below the previous + widgets.push(assignDefaultLayout(result.data, widgets)); + } + } + + 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, + }; + }, +}); diff --git a/src/commands/dashboard/index.ts b/src/commands/dashboard/index.ts new file mode 100644 index 00000000..a47d1f6c --- /dev/null +++ b/src/commands/dashboard/index.ts @@ -0,0 +1,25 @@ +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", + fullDescription: + "View and manage dashboards in your Sentry organization.\n\n" + + "Commands:\n" + + " list List dashboards\n" + + " view View a dashboard\n" + + " create Create a dashboard\n" + + " widget Manage dashboard widgets (add, edit, delete)", + hideRoute: {}, + }, +}); diff --git a/src/commands/dashboard/list.ts b/src/commands/dashboard/list.ts new file mode 100644 index 00000000..516eb12c --- /dev/null +++ b/src/commands/dashboard/list.ts @@ -0,0 +1,128 @@ +/** + * sentry dashboard list + * + * List dashboards in a Sentry organization. + */ + +import type { SentryContext } from "../../context.js"; +import { listDashboards } from "../../lib/api-client.js"; +import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; +import { openInBrowser } from "../../lib/browser.js"; +import { ContextError } from "../../lib/errors.js"; +import { writeFooter, writeJson } from "../../lib/formatters/index.js"; +import { escapeMarkdownCell } from "../../lib/formatters/markdown.js"; +import { type Column, writeTable } from "../../lib/formatters/table.js"; +import { + buildListCommand, + LIST_TARGET_POSITIONAL, +} from "../../lib/list-command.js"; +import { resolveOrg } from "../../lib/resolve-target.js"; +import { buildDashboardsListUrl } from "../../lib/sentry-urls.js"; +import type { DashboardListItem } from "../../types/dashboard.js"; + +type ListFlags = { + readonly web: boolean; + readonly json: boolean; + readonly fields?: string[]; +}; + +/** Resolve org slug from parsed target argument */ +async function resolveOrgFromTarget( + parsed: ReturnType<typeof parseOrgProjectArg>, + cwd: string +): Promise<string> { + switch (parsed.type) { + case "explicit": + case "org-all": + return parsed.org; + case "project-search": + case "auto-detect": { + const resolved = await resolveOrg({ cwd }); + if (!resolved) { + throw new ContextError("Organization", "sentry dashboard list <org>/"); + } + return resolved.org; + } + default: { + const _exhaustive: never = parsed; + throw new Error( + `Unexpected parsed type: ${(_exhaustive as { type: string }).type}` + ); + } + } +} + +export const listCommand = buildListCommand("dashboard", { + docs: { + brief: "List dashboards", + fullDescription: + "List dashboards in a Sentry organization.\n\n" + + "Examples:\n" + + " sentry dashboard list\n" + + " sentry dashboard list my-org/\n" + + " sentry dashboard list --json\n" + + " sentry dashboard list --web", + }, + output: "json", + parameters: { + positional: LIST_TARGET_POSITIONAL, + flags: { + web: { + kind: "boolean", + brief: "Open in browser", + default: false, + }, + }, + aliases: { w: "web" }, + }, + async func( + this: SentryContext, + flags: ListFlags, + target?: string + ): Promise<void> { + const { stdout, cwd } = this; + + const parsed = parseOrgProjectArg(target); + const orgSlug = await resolveOrgFromTarget(parsed, cwd); + + if (flags.web) { + await openInBrowser(buildDashboardsListUrl(orgSlug), "dashboards"); + return; + } + + const dashboards = await listDashboards(orgSlug); + + if (flags.json) { + writeJson(stdout, dashboards, flags.fields); + return; + } + + if (dashboards.length === 0) { + stdout.write("No dashboards found.\n"); + return; + } + + type DashboardRow = { + id: string; + title: string; + widgets: string; + }; + + const rows: DashboardRow[] = dashboards.map((d: DashboardListItem) => ({ + id: d.id, + title: escapeMarkdownCell(d.title), + widgets: String(d.widgetDisplay?.length ?? 0), + })); + + const columns: Column<DashboardRow>[] = [ + { header: "ID", value: (r) => r.id }, + { header: "TITLE", value: (r) => r.title }, + { header: "WIDGETS", value: (r) => r.widgets }, + ]; + + writeTable(stdout, rows, columns); + + const url = buildDashboardsListUrl(orgSlug); + writeFooter(stdout, `Dashboards: ${url}`); + }, +}); diff --git a/src/commands/dashboard/resolve.ts b/src/commands/dashboard/resolve.ts new file mode 100644 index 00000000..f0615c19 --- /dev/null +++ b/src/commands/dashboard/resolve.ts @@ -0,0 +1,121 @@ +/** + * Shared dashboard resolution utilities + * + * Provides org resolution from parsed target arguments and dashboard + * ID resolution from numeric IDs or title strings. + */ + +import { listDashboards } from "../../lib/api-client.js"; +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"; + +/** + * Resolve org slug from a parsed org/project target argument. + * + * Dashboard commands only need the org (dashboards are org-scoped), so + * explicit, org-all, project-search, and auto-detect all resolve to just + * the org slug. + * + * @param parsed - Parsed org/project argument + * @param cwd - Current working directory for auto-detection + * @param usageHint - Usage example for error messages + * @returns Organization slug + */ +export async function resolveOrgFromTarget( + parsed: ReturnType<typeof parseOrgProjectArg>, + cwd: string, + usageHint: string +): Promise<string> { + switch (parsed.type) { + case "explicit": + case "org-all": + return parsed.org; + case "project-search": + case "auto-detect": { + const resolved = await resolveOrg({ cwd }); + if (!resolved) { + throw new ContextError("Organization", usageHint); + } + return resolved.org; + } + default: { + const _exhaustive: never = parsed; + throw new Error( + `Unexpected parsed type: ${(_exhaustive as { type: string }).type}` + ); + } + } +} + +/** + * Parse a dashboard reference and optional target from array positional args. + * + * Handles: + * - `<id-or-title>` — single arg (auto-detect org) + * - `<target> <id-or-title>` — explicit target + dashboard ref + * + * @param args - Raw positional arguments + * @param usageHint - Error message label (e.g. "Dashboard ID or title") + * @returns Dashboard reference string and optional target arg + */ +export function parseDashboardPositionalArgs(args: string[]): { + dashboardRef: string; + targetArg: string | undefined; +} { + if (args.length === 0) { + throw new ValidationError( + "Dashboard ID or title is required.", + "dashboard" + ); + } + if (args.length === 1) { + return { + dashboardRef: args[0] as string, + targetArg: undefined, + }; + } + return { + dashboardRef: args.at(-1) as string, + targetArg: args[0] as string, + }; +} + +/** + * Resolve a dashboard reference (numeric ID or title) to a numeric ID string. + * + * If the reference is all digits, returns it directly. Otherwise, lists + * dashboards in the org and finds a case-insensitive title match. + * + * @param orgSlug - Organization slug + * @param ref - Dashboard reference (numeric ID or title) + * @returns Numeric dashboard ID as a string + */ +export async function resolveDashboardId( + orgSlug: string, + ref: string +): Promise<string> { + if (isAllDigits(ref)) { + return ref; + } + + const dashboards = await listDashboards(orgSlug); + const lowerRef = ref.toLowerCase(); + const match = dashboards.find((d) => d.title.toLowerCase() === lowerRef); + + if (!match) { + const available = dashboards + .slice(0, 5) + .map((d) => ` ${d.id} ${d.title}`) + .join("\n"); + const suffix = + dashboards.length > 5 ? `\n ... and ${dashboards.length - 5} more` : ""; + throw new ValidationError( + `No dashboard with title '${ref}' found in '${orgSlug}'.\n\n` + + `Available dashboards:\n${available}${suffix}` + ); + } + + return match.id; +} diff --git a/src/commands/dashboard/view.ts b/src/commands/dashboard/view.ts new file mode 100644 index 00000000..6a4f53e1 --- /dev/null +++ b/src/commands/dashboard/view.ts @@ -0,0 +1,88 @@ +/** + * sentry dashboard view + * + * View details of a specific dashboard. + */ + +import type { SentryContext } from "../../context.js"; +import { getDashboard } from "../../lib/api-client.js"; +import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; +import { openInBrowser } from "../../lib/browser.js"; +import { buildCommand } from "../../lib/command.js"; +import { formatDashboardView } from "../../lib/formatters/human.js"; +import { buildDashboardUrl } from "../../lib/sentry-urls.js"; +import type { DashboardDetail } from "../../types/dashboard.js"; +import { + parseDashboardPositionalArgs, + resolveDashboardId, + resolveOrgFromTarget, +} from "./resolve.js"; + +type ViewFlags = { + readonly web: boolean; + readonly json: boolean; + readonly fields?: string[]; +}; + +type ViewResult = DashboardDetail & { url: string }; + +export const viewCommand = buildCommand({ + docs: { + brief: "View a dashboard", + fullDescription: + "View details of a specific Sentry dashboard.\n\n" + + "The dashboard can be specified by numeric ID or title.\n\n" + + "Examples:\n" + + " sentry dashboard view 12345\n" + + " sentry dashboard view 'My Dashboard'\n" + + " sentry dashboard view my-org/ 12345\n" + + " sentry dashboard view 12345 --json\n" + + " sentry dashboard view 12345 --web", + }, + output: { + json: true, + human: formatDashboardView, + }, + parameters: { + positional: { + kind: "array", + parameter: { + brief: "[<org/project>] <dashboard-id-or-title>", + parse: String, + }, + }, + flags: { + web: { + kind: "boolean", + brief: "Open in browser", + default: false, + }, + }, + aliases: { w: "web" }, + }, + async func(this: SentryContext, flags: ViewFlags, ...args: string[]) { + const { cwd } = this; + + const { dashboardRef, targetArg } = parseDashboardPositionalArgs(args); + const parsed = parseOrgProjectArg(targetArg); + const orgSlug = await resolveOrgFromTarget( + parsed, + cwd, + "sentry dashboard view <org>/ <id>" + ); + const dashboardId = await resolveDashboardId(orgSlug, dashboardRef); + + const url = buildDashboardUrl(orgSlug, dashboardId); + + if (flags.web) { + await openInBrowser(url, "dashboard"); + return; + } + + const dashboard = await getDashboard(orgSlug, dashboardId); + + return { + data: { ...dashboard, url } as ViewResult, + }; + }, +}); diff --git a/src/commands/dashboard/widget/add.ts b/src/commands/dashboard/widget/add.ts new file mode 100644 index 00000000..6ff02faa --- /dev/null +++ b/src/commands/dashboard/widget/add.ts @@ -0,0 +1,124 @@ +/** + * sentry dashboard widget add + * + * Add a widget to an existing dashboard. + */ + +import type { SentryContext } from "../../../context.js"; +import { getDashboard, updateDashboard } from "../../../lib/api-client.js"; +import { parseOrgProjectArg } from "../../../lib/arg-parsing.js"; +import { buildCommand } 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, + DashboardWidgetSchema, + prepareDashboardForUpdate, +} from "../../../types/dashboard.js"; +import { + parseDashboardPositionalArgs, + resolveDashboardId, + resolveOrgFromTarget, +} from "../resolve.js"; + +type AddFlags = { + readonly "from-json": string; + readonly json: boolean; + readonly fields?: string[]; +}; + +type AddResult = { + dashboard: DashboardDetail; + widget: DashboardWidget; + url: string; +}; + +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" + + "The widget definition is read from a JSON file.\n\n" + + "Examples:\n" + + " sentry dashboard widget add 12345 --from-json widget.json\n" + + " sentry dashboard widget add 'My Dashboard' --from-json widget.json\n" + + " sentry dashboard widget add my-org/ 12345 --from-json widget.json", + }, + output: { + json: true, + human: formatWidgetAdded, + }, + parameters: { + positional: { + kind: "array", + parameter: { + brief: "[<org/project>] <dashboard-id-or-title>", + parse: String, + }, + }, + flags: { + "from-json": { + kind: "parsed", + parse: String, + brief: "Path to JSON file containing widget definition", + }, + }, + }, + async func(this: SentryContext, flags: AddFlags, ...args: string[]) { + const { cwd } = this; + + const { dashboardRef, targetArg } = parseDashboardPositionalArgs(args); + const parsed = parseOrgProjectArg(targetArg); + const orgSlug = await resolveOrgFromTarget( + parsed, + cwd, + "sentry dashboard widget add <org>/ <id> --from-json <path>" + ); + const dashboardId = await resolveDashboardId(orgSlug, dashboardRef); + + // Read and validate widget JSON + const jsonPath = flags["from-json"]; + const file = Bun.file(jsonPath); + if (!(await file.exists())) { + throw new ValidationError( + `Widget JSON file not found: ${jsonPath}`, + "from-json" + ); + } + const raw = await file.text(); + let widgetParsed: unknown; + try { + widgetParsed = JSON.parse(raw); + } catch { + throw new ValidationError( + `Invalid JSON in widget file: ${jsonPath}`, + "from-json" + ); + } + + const widgetResult = DashboardWidgetSchema.safeParse(widgetParsed); + if (!widgetResult.success) { + throw new ValidationError( + `Invalid widget definition: ${widgetResult.error.message}`, + "from-json" + ); + } + const newWidget = widgetResult.data; + + // GET current dashboard → append widget with auto-layout → PUT + const current = await getDashboard(orgSlug, dashboardId); + const updateBody = prepareDashboardForUpdate(current); + updateBody.widgets.push(assignDefaultLayout(newWidget, updateBody.widgets)); + + const updated = await updateDashboard(orgSlug, dashboardId, updateBody); + const url = buildDashboardUrl(orgSlug, dashboardId); + + return { + data: { dashboard: updated, widget: newWidget, url } as AddResult, + }; + }, +}); diff --git a/src/commands/dashboard/widget/delete.ts b/src/commands/dashboard/widget/delete.ts new file mode 100644 index 00000000..0b0b50f9 --- /dev/null +++ b/src/commands/dashboard/widget/delete.ts @@ -0,0 +1,130 @@ +/** + * sentry dashboard widget delete + * + * Remove a widget from an existing dashboard. + */ + +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 { formatWidgetDeleted } from "../../../lib/formatters/human.js"; +import { buildDashboardUrl } from "../../../lib/sentry-urls.js"; +import { + type DashboardDetail, + prepareDashboardForUpdate, +} from "../../../types/dashboard.js"; +import { + parseDashboardPositionalArgs, + resolveDashboardId, + resolveOrgFromTarget, +} from "../resolve.js"; + +type DeleteFlags = { + readonly index?: number; + readonly title?: string; + readonly json: boolean; + readonly fields?: string[]; +}; + +type DeleteResult = { + dashboard: DashboardDetail; + widgetTitle: string; + url: string; +}; + +export const deleteCommand = buildCommand({ + docs: { + brief: "Delete a widget from a dashboard", + fullDescription: + "Remove a widget from an existing Sentry dashboard.\n\n" + + "The dashboard can be specified by numeric ID or title.\n" + + "Identify the widget by --index (0-based) or --title.\n\n" + + "Examples:\n" + + " sentry dashboard widget delete 12345 --index 0\n" + + " sentry dashboard widget delete 'My Dashboard' --title 'Error Rate'", + }, + output: { + json: true, + human: formatWidgetDeleted, + }, + parameters: { + positional: { + kind: "array", + parameter: { + brief: "[<org/project>] <dashboard-id-or-title>", + parse: String, + }, + }, + flags: { + index: { + kind: "parsed", + parse: numberParser, + brief: "Widget index (0-based)", + optional: true, + }, + title: { + kind: "parsed", + parse: String, + brief: "Widget title to match", + optional: true, + }, + }, + aliases: { i: "index", t: "title" }, + }, + async func(this: SentryContext, flags: DeleteFlags, ...args: string[]) { + const { cwd } = this; + + if (flags.index === undefined && !flags.title) { + throw new ValidationError( + "Specify --index or --title to identify the widget to delete.", + "index" + ); + } + + const { dashboardRef, targetArg } = parseDashboardPositionalArgs(args); + const parsed = parseOrgProjectArg(targetArg); + const orgSlug = await resolveOrgFromTarget( + parsed, + cwd, + "sentry dashboard widget delete <org>/ <id> (--index <n> | --title <name>)" + ); + const dashboardId = await resolveDashboardId(orgSlug, dashboardRef); + + // GET current dashboard → find widget → splice → PUT + const current = await getDashboard(orgSlug, dashboardId); + const widgets = current.widgets ?? []; + + let widgetIndex: number; + if (flags.index !== undefined) { + if (flags.index < 0 || flags.index >= widgets.length) { + throw new ValidationError( + `Widget index ${flags.index} out of range (dashboard has ${widgets.length} widgets).`, + "index" + ); + } + widgetIndex = flags.index; + } else { + const matchIndex = widgets.findIndex((w) => w.title === flags.title); + if (matchIndex === -1) { + throw new ValidationError( + `No widget with title '${flags.title}' found in dashboard.`, + "title" + ); + } + widgetIndex = matchIndex; + } + + const widgetTitle = widgets[widgetIndex]?.title; + const updateBody = prepareDashboardForUpdate(current); + updateBody.widgets.splice(widgetIndex, 1); + + const updated = await updateDashboard(orgSlug, dashboardId, updateBody); + const url = buildDashboardUrl(orgSlug, dashboardId); + + return { + data: { dashboard: updated, widgetTitle, url } as DeleteResult, + }; + }, +}); diff --git a/src/commands/dashboard/widget/edit.ts b/src/commands/dashboard/widget/edit.ts new file mode 100644 index 00000000..743edfe5 --- /dev/null +++ b/src/commands/dashboard/widget/edit.ts @@ -0,0 +1,178 @@ +/** + * sentry dashboard widget edit + * + * Edit a widget in an existing dashboard. + */ + +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 { formatWidgetEdited } from "../../../lib/formatters/human.js"; +import { buildDashboardUrl } from "../../../lib/sentry-urls.js"; +import { + type DashboardDetail, + type DashboardWidget, + DashboardWidgetSchema, + prepareDashboardForUpdate, +} from "../../../types/dashboard.js"; +import { + parseDashboardPositionalArgs, + resolveDashboardId, + resolveOrgFromTarget, +} from "../resolve.js"; + +type EditFlags = { + readonly "from-json": string; + readonly index?: number; + readonly title?: string; + readonly json: boolean; + readonly fields?: string[]; +}; + +type EditResult = { + dashboard: DashboardDetail; + widget: DashboardWidget; + url: string; +}; + +/** Resolve widget index from --index or --title flags */ +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; +} + +export const editCommand = buildCommand({ + docs: { + brief: "Edit a widget in a dashboard", + fullDescription: + "Edit a widget in an existing Sentry dashboard.\n\n" + + "The dashboard can be specified by numeric ID or title.\n" + + "Identify the widget by --index (0-based) or --title.\n\n" + + "Examples:\n" + + " sentry dashboard widget edit 12345 --index 0 --from-json widget.json\n" + + " sentry dashboard widget edit 'My Dashboard' --title 'Error Rate' --from-json widget.json", + }, + output: { + json: true, + human: formatWidgetEdited, + }, + parameters: { + positional: { + kind: "array", + parameter: { + brief: "[<org/project>] <dashboard-id-or-title>", + parse: String, + }, + }, + flags: { + "from-json": { + kind: "parsed", + parse: String, + brief: "Path to JSON file containing widget definition", + }, + index: { + kind: "parsed", + parse: numberParser, + brief: "Widget index (0-based)", + optional: true, + }, + title: { + kind: "parsed", + parse: String, + brief: "Widget title to match", + optional: true, + }, + }, + aliases: { i: "index", t: "title" }, + }, + async func(this: SentryContext, flags: EditFlags, ...args: string[]) { + const { cwd } = this; + + if (flags.index === undefined && !flags.title) { + throw new ValidationError( + "Specify --index or --title to identify the widget to edit.", + "index" + ); + } + + const { dashboardRef, targetArg } = parseDashboardPositionalArgs(args); + const parsed = parseOrgProjectArg(targetArg); + const orgSlug = await resolveOrgFromTarget( + parsed, + cwd, + "sentry dashboard widget edit <org>/ <id> --from-json <path>" + ); + const dashboardId = await resolveDashboardId(orgSlug, dashboardRef); + + // Read and validate widget JSON + const jsonPath = flags["from-json"]; + const file = Bun.file(jsonPath); + if (!(await file.exists())) { + throw new ValidationError( + `Widget JSON file not found: ${jsonPath}`, + "from-json" + ); + } + const raw = await file.text(); + let widgetParsed: unknown; + try { + widgetParsed = JSON.parse(raw); + } catch { + throw new ValidationError( + `Invalid JSON in widget file: ${jsonPath}`, + "from-json" + ); + } + + const widgetResult = DashboardWidgetSchema.safeParse(widgetParsed); + if (!widgetResult.success) { + throw new ValidationError( + `Invalid widget definition: ${widgetResult.error.message}`, + "from-json" + ); + } + let replacement = widgetResult.data; + + // GET current dashboard → find widget → replace → PUT + const current = await getDashboard(orgSlug, dashboardId); + const widgets = current.widgets ?? []; + + const widgetIndex = resolveWidgetIndex(widgets, flags.index, flags.title); + + const updateBody = prepareDashboardForUpdate(current); + // Preserve existing layout when replacement doesn't specify one + const existingWidget = updateBody.widgets[widgetIndex]; + if (!replacement.layout && existingWidget?.layout) { + replacement = { ...replacement, layout: existingWidget.layout }; + } + updateBody.widgets[widgetIndex] = replacement; + + const updated = await updateDashboard(orgSlug, dashboardId, updateBody); + const url = buildDashboardUrl(orgSlug, dashboardId); + + return { + data: { dashboard: updated, widget: replacement, url } as EditResult, + }; + }, +}); diff --git a/src/commands/dashboard/widget/index.ts b/src/commands/dashboard/widget/index.ts new file mode 100644 index 00000000..a3e4144c --- /dev/null +++ b/src/commands/dashboard/widget/index.ts @@ -0,0 +1,22 @@ +import { buildRouteMap } from "@stricli/core"; +import { addCommand } from "./add.js"; +import { deleteCommand } from "./delete.js"; +import { editCommand } from "./edit.js"; + +export const widgetRoute = buildRouteMap({ + routes: { + add: addCommand, + edit: editCommand, + delete: deleteCommand, + }, + docs: { + brief: "Manage dashboard widgets", + fullDescription: + "Add, edit, or delete widgets in a Sentry dashboard.\n\n" + + "Commands:\n" + + " add Add a widget to a dashboard\n" + + " edit Edit a widget in a dashboard\n" + + " delete Delete a widget from a dashboard", + hideRoute: {}, + }, +}); diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index aa438a22..8d1e9534 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -34,7 +34,11 @@ import { // biome-ignore lint/performance/noNamespaceImport: Sentry SDK recommends namespace import import * as Sentry from "@sentry/bun"; import type { z } from "zod"; - +import type { + DashboardDetail, + DashboardListItem, + DashboardWidget, +} from "../types/dashboard.js"; import { DetailedLogsResponseSchema, type DetailedSentryLog, @@ -1920,3 +1924,84 @@ export async function listTraceLogs( return response.data; } + +// Dashboard functions + +/** + * List dashboards in an organization. + * + * @param orgSlug - Organization slug + * @returns Array of dashboard list items + */ +export async function listDashboards( + orgSlug: string +): Promise<DashboardListItem[]> { + const regionUrl = await resolveOrgRegion(orgSlug); + const { data } = await apiRequestToRegion<DashboardListItem[]>( + regionUrl, + `/organizations/${orgSlug}/dashboards/` + ); + return data; +} + +/** + * Get a dashboard by ID. + * + * @param orgSlug - Organization slug + * @param dashboardId - Dashboard ID + * @returns Full dashboard detail with widgets + */ +export async function getDashboard( + orgSlug: string, + dashboardId: string +): Promise<DashboardDetail> { + const regionUrl = await resolveOrgRegion(orgSlug); + const { data } = await apiRequestToRegion<DashboardDetail>( + regionUrl, + `/organizations/${orgSlug}/dashboards/${dashboardId}/` + ); + return data; +} + +/** + * Create a new dashboard. + * + * @param orgSlug - Organization slug + * @param body - Dashboard creation body (title, optional widgets) + * @returns Created dashboard detail + */ +export async function createDashboard( + orgSlug: string, + body: { title: string; widgets?: DashboardWidget[]; projects?: number[] } +): Promise<DashboardDetail> { + const regionUrl = await resolveOrgRegion(orgSlug); + const { data } = await apiRequestToRegion<DashboardDetail>( + regionUrl, + `/organizations/${orgSlug}/dashboards/`, + { method: "POST", body } + ); + return data; +} + +/** + * Update a dashboard (full PUT — replaces all widgets). + * Always GET first, modify, then PUT the full widget list. + * + * @param orgSlug - Organization slug + * @param dashboardId - Dashboard ID + * @param body - Dashboard update body (title, widgets) + * @returns Updated dashboard detail + */ +export async function updateDashboard( + orgSlug: string, + dashboardId: string, + body: { title: string; widgets: DashboardWidget[]; projects?: number[] } +): Promise<DashboardDetail> { + const regionUrl = await resolveOrgRegion(orgSlug); + const { data } = await apiRequestToRegion<DashboardDetail>( + regionUrl, + `/organizations/${orgSlug}/dashboards/${dashboardId}/`, + { method: "PUT", body } + ); + return data; +} diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 765bd6e8..9f2dd0c8 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -2122,3 +2122,134 @@ export function formatUpgradeResult(data: UpgradeResult): string { return renderMarkdown(lines.join("\n")); } + +// Dashboard formatters + +/** + * Format a created dashboard for human-readable output. + */ +export function formatDashboardCreated(result: { + id: string; + title: string; + url: string; +}): string { + const lines: string[] = [ + `Created dashboard '${escapeMarkdownInline(result.title)}' (ID: ${result.id})`, + "", + `URL: ${result.url}`, + ]; + return renderMarkdown(lines.join("\n")); +} + +/** + * Format a dashboard view for human-readable output. + */ +export function formatDashboardView(result: { + id: string; + title: string; + widgets?: Array<{ + title: string; + displayType: string; + widgetType?: string; + layout?: { x: number; y: number; w: number; h: number }; + }>; + dateCreated?: string; + url: string; +}): string { + const lines: string[] = []; + + const kvRows: [string, string][] = [ + ["Title", escapeMarkdownInline(result.title)], + ["ID", result.id], + ]; + if (result.dateCreated) { + kvRows.push(["Created", result.dateCreated]); + } + kvRows.push(["URL", result.url]); + + lines.push(mdKvTable(kvRows)); + + const widgets = result.widgets ?? []; + if (widgets.length > 0) { + lines.push(""); + lines.push(`**Widgets** (${widgets.length}):`); + lines.push(""); + + type WidgetRow = { + title: string; + displayType: string; + widgetType: string; + layout: string; + }; + const widgetRows: WidgetRow[] = widgets.map((w) => ({ + title: escapeMarkdownCell(w.title), + displayType: w.displayType, + widgetType: w.widgetType ?? "", + layout: w.layout + ? `(${w.layout.x},${w.layout.y}) ${w.layout.w}×${w.layout.h}` + : "", + })); + + lines.push(mdTableHeader(["TITLE", "DISPLAY", "TYPE", "LAYOUT"])); + for (const row of widgetRows) { + lines.push( + mdRow([row.title, row.displayType, row.widgetType, row.layout]) + ); + } + } else { + lines.push(""); + lines.push("No widgets."); + } + + return renderMarkdown(lines.join("\n")); +} + +/** + * Format a widget add result for human-readable output. + */ +export function formatWidgetAdded(result: { + dashboard: { id: string; widgets?: unknown[] }; + widget: { title: string }; + url: string; +}): string { + const widgetCount = result.dashboard.widgets?.length ?? 0; + const lines: string[] = [ + `Added widget '${escapeMarkdownInline(result.widget.title)}' to dashboard (now ${widgetCount} widgets)`, + "", + `URL: ${result.url}`, + ]; + return renderMarkdown(lines.join("\n")); +} + +/** + * Format a widget deletion result for human-readable output. + */ +export function formatWidgetDeleted(result: { + dashboard: { id: string; widgets?: unknown[] }; + widgetTitle: string; + url: string; +}): string { + const widgetCount = result.dashboard.widgets?.length ?? 0; + const lines: string[] = [ + `Removed widget '${escapeMarkdownInline(result.widgetTitle)}' from dashboard (now ${widgetCount} widgets)`, + "", + `URL: ${result.url}`, + ]; + return renderMarkdown(lines.join("\n")); +} + +/** + * Format a widget edit result for human-readable output. + */ +export function formatWidgetEdited(result: { + dashboard: { id: string }; + widget: { title: string }; + url: string; +}): string { + const lines: string[] = [ + `Updated widget '${escapeMarkdownInline(result.widget.title)}' in dashboard ${result.dashboard.id}`, + "", + `URL: ${result.url}`, + ]; + return renderMarkdown(lines.join("\n")); +} diff --git a/src/lib/sentry-urls.ts b/src/lib/sentry-urls.ts index 07b67715..dce25403 100644 --- a/src/lib/sentry-urls.ts +++ b/src/lib/sentry-urls.ts @@ -158,6 +158,38 @@ export function buildLogsUrl(orgSlug: string, logId?: string): string { return logId ? `${base}?query=sentry.item_id:${logId}` : base; } +// Dashboard URLs + +/** + * Build URL to the dashboards list page. + * + * @param orgSlug - Organization slug + * @returns Full URL to the dashboards list page + */ +export function buildDashboardsListUrl(orgSlug: string): string { + if (isSaaS()) { + return `${getOrgBaseUrl(orgSlug)}/dashboards/`; + } + return `${getSentryBaseUrl()}/organizations/${orgSlug}/dashboards/`; +} + +/** + * Build URL to view a specific dashboard. + * + * @param orgSlug - Organization slug + * @param dashboardId - Dashboard ID + * @returns Full URL to the dashboard view page + */ +export function buildDashboardUrl( + orgSlug: string, + dashboardId: string +): string { + if (isSaaS()) { + return `${getOrgBaseUrl(orgSlug)}/dashboard/${dashboardId}/`; + } + return `${getSentryBaseUrl()}/organizations/${orgSlug}/dashboard/${dashboardId}/`; +} + /** * Build URL to view a trace in Sentry. * diff --git a/src/types/dashboard.ts b/src/types/dashboard.ts new file mode 100644 index 00000000..8dfc634d --- /dev/null +++ b/src/types/dashboard.ts @@ -0,0 +1,279 @@ +/** + * Dashboard types and schemas + * + * Zod schemas and TypeScript types for Sentry Dashboard API responses. + * Includes utility functions for stripping server-generated fields + * before PUT requests. + */ + +import { z } from "zod"; + +// --------------------------------------------------------------------------- +// Schemas +// --------------------------------------------------------------------------- + +/** Schema for a single query within a dashboard widget */ +export const DashboardWidgetQuerySchema = z + .object({ + id: z.string().optional(), + name: z.string().optional(), + conditions: z.string().optional(), + columns: z.array(z.string()).optional(), + aggregates: z.array(z.string()).optional(), + fieldAliases: z.array(z.string()).optional(), + orderby: z.string().optional(), + fields: z.array(z.string()).optional(), + widgetId: z.string().optional(), + dateCreated: z.string().optional(), + }) + .passthrough(); + +/** Schema for widget layout position */ +export const DashboardWidgetLayoutSchema = z + .object({ + x: z.number(), + y: z.number(), + w: z.number(), + h: z.number(), + minH: z.number().optional(), + isResizable: z.boolean().optional(), + }) + .passthrough(); + +/** Schema for a single dashboard widget */ +export const DashboardWidgetSchema = z + .object({ + id: z.string().optional(), + title: z.string(), + displayType: z.string(), + widgetType: z.string().optional(), + interval: z.string().optional(), + queries: z.array(DashboardWidgetQuerySchema).optional(), + layout: DashboardWidgetLayoutSchema.optional(), + thresholds: z.unknown().optional(), + limit: z.number().nullable().optional(), + dashboardId: z.string().optional(), + dateCreated: z.string().optional(), + }) + .passthrough(); + +/** Schema for dashboard list items (lightweight, from GET /dashboards/) */ +export const DashboardListItemSchema = z + .object({ + id: z.string(), + title: z.string(), + dateCreated: z.string().optional(), + createdBy: z + .object({ + name: z.string().optional(), + email: z.string().optional(), + }) + .optional(), + widgetDisplay: z.array(z.string()).optional(), + }) + .passthrough(); + +/** Schema for full dashboard detail (from GET /dashboards/{id}/) */ +export const DashboardDetailSchema = z + .object({ + id: z.string(), + title: z.string(), + widgets: z.array(DashboardWidgetSchema).optional(), + dateCreated: z.string().optional(), + createdBy: z + .object({ + name: z.string().optional(), + email: z.string().optional(), + }) + .optional(), + projects: z.array(z.number()).optional(), + environment: z.array(z.string()).optional(), + period: z.string().nullable().optional(), + }) + .passthrough(); + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type DashboardWidgetQuery = z.infer<typeof DashboardWidgetQuerySchema>; +export type DashboardWidgetLayout = z.infer<typeof DashboardWidgetLayoutSchema>; +export type DashboardWidget = z.infer<typeof DashboardWidgetSchema>; +export type DashboardListItem = z.infer<typeof DashboardListItemSchema>; +export type DashboardDetail = z.infer<typeof DashboardDetailSchema>; + +// --------------------------------------------------------------------------- +// Auto-layout utilities +// --------------------------------------------------------------------------- + +/** Sentry dashboard grid column count */ +const GRID_COLUMNS = 6; + +/** Default widget dimensions by displayType */ +const DEFAULT_WIDGET_SIZE: Record< + string, + { w: number; h: number; minH: number } +> = { + big_number: { w: 2, h: 1, minH: 1 }, + line: { w: 3, h: 2, minH: 2 }, + area: { w: 3, h: 2, minH: 2 }, + bar: { w: 3, h: 2, minH: 2 }, + table: { w: 6, h: 2, minH: 2 }, + world_map: { w: 4, h: 2, minH: 2 }, +}; +const FALLBACK_SIZE = { w: 3, h: 2, minH: 2 }; + +/** Build a set of occupied grid cells and the max bottom edge from existing layouts. */ +function buildOccupiedGrid(widgets: DashboardWidget[]): { + occupied: Set<string>; + maxY: number; +} { + const occupied = new Set<string>(); + let maxY = 0; + for (const w of widgets) { + if (!w.layout) { + continue; + } + const bottom = w.layout.y + w.layout.h; + if (bottom > maxY) { + maxY = bottom; + } + for (let y = w.layout.y; y < bottom; y++) { + for (let x = w.layout.x; x < w.layout.x + w.layout.w; x++) { + occupied.add(`${x},${y}`); + } + } + } + return { occupied, maxY }; +} + +/** Check whether a rectangle fits at a position without overlapping occupied cells. */ +function regionFits( + occupied: Set<string>, + rect: { px: number; py: number; w: number; h: number } +): boolean { + for (let dy = 0; dy < rect.h; dy++) { + for (let dx = 0; dx < rect.w; dx++) { + if (occupied.has(`${rect.px + dx},${rect.py + dy}`)) { + return false; + } + } + } + return true; +} + +/** + * Assign a default layout to a widget if it doesn't already have one. + * Packs the widget into the first available space in a 6-column grid, + * scanning rows top-to-bottom and left-to-right. + * + * @param widget - Widget that may be missing a layout + * @param existingWidgets - Widgets already in the dashboard (used to compute placement) + * @returns Widget with layout guaranteed + */ +export function assignDefaultLayout( + widget: DashboardWidget, + existingWidgets: DashboardWidget[] +): DashboardWidget { + if (widget.layout) { + return widget; + } + + const { w, h, minH } = + DEFAULT_WIDGET_SIZE[widget.displayType] ?? FALLBACK_SIZE; + + const { occupied, maxY } = buildOccupiedGrid(existingWidgets); + + // Scan rows to find the first position where the widget fits + for (let y = 0; y <= maxY; y++) { + for (let x = 0; x <= GRID_COLUMNS - w; x++) { + if (regionFits(occupied, { px: x, py: y, w, h })) { + return { ...widget, layout: { x, y, w, h, minH } }; + } + } + } + + // No gap found — place below everything + return { ...widget, layout: { x: 0, y: maxY, w, h, minH } }; +} + +// --------------------------------------------------------------------------- +// Server field stripping utilities +// --------------------------------------------------------------------------- + +/** + * Server-generated fields on widget queries that must be stripped before PUT. + * NEVER strip user-controlled fields like conditions, columns, aggregates. + */ +const QUERY_SERVER_FIELDS = ["id", "widgetId", "dateCreated"] as const; + +/** + * Server-generated fields on widgets that must be stripped before PUT. + * CRITICAL: Never strip widgetType, displayType, or layout — these are + * user-controlled and stripping them causes widgets to reset to defaults. + */ +const WIDGET_SERVER_FIELDS = ["id", "dashboardId", "dateCreated"] as const; + +/** + * Server-generated fields on widget layout that must be stripped before PUT. + */ +const LAYOUT_SERVER_FIELDS = ["isResizable"] as const; + +/** + * Strip server-generated fields from a single widget for PUT requests. + * + * @param widget - Widget object from GET response + * @returns Widget safe for PUT (widgetType, displayType, layout preserved) + */ +export function stripWidgetServerFields( + widget: DashboardWidget +): DashboardWidget { + const cleaned = { ...widget }; + + // Strip widget-level server fields + for (const field of WIDGET_SERVER_FIELDS) { + delete (cleaned as Record<string, unknown>)[field]; + } + + // Strip query-level server fields + if (cleaned.queries) { + cleaned.queries = cleaned.queries.map((q) => { + const cleanedQuery = { ...q }; + for (const field of QUERY_SERVER_FIELDS) { + delete (cleanedQuery as Record<string, unknown>)[field]; + } + return cleanedQuery; + }); + } + + // Strip layout server fields + if (cleaned.layout) { + const cleanedLayout = { ...cleaned.layout }; + for (const field of LAYOUT_SERVER_FIELDS) { + delete (cleanedLayout as Record<string, unknown>)[field]; + } + cleaned.layout = cleanedLayout; + } + + return cleaned; +} + +/** + * Prepare a full dashboard for PUT update. + * Strips server-generated fields from all widgets while preserving + * widgetType, displayType, and layout. + * + * @param dashboard - Dashboard detail from GET response + * @returns Object with title and cleaned widgets, ready for PUT body + */ +export function prepareDashboardForUpdate(dashboard: DashboardDetail): { + title: string; + widgets: DashboardWidget[]; + projects?: number[]; +} { + return { + title: dashboard.title, + widgets: (dashboard.widgets ?? []).map(stripWidgetServerFields), + projects: dashboard.projects, + }; +} diff --git a/src/types/index.ts b/src/types/index.ts index ee7ac738..37e99203 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -19,6 +19,23 @@ export { ProjectAliasesSchema, SentryConfigSchema, } from "./config.js"; +// Dashboard types +export type { + DashboardDetail, + DashboardListItem, + DashboardWidget, + DashboardWidgetLayout, + DashboardWidgetQuery, +} from "./dashboard.js"; +export { + DashboardDetailSchema, + DashboardListItemSchema, + DashboardWidgetLayoutSchema, + DashboardWidgetQuerySchema, + DashboardWidgetSchema, + prepareDashboardForUpdate, + stripWidgetServerFields, +} from "./dashboard.js"; // OAuth types and schemas export type { DeviceCodeResponse,