diff --git a/AGENTS.md b/AGENTS.md index 32b6f6c..fcc1476 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,8 @@ src/ ├── context.ts # CLI context (process, env) ├── commands/ # CLI commands organized by resource │ ├── alert/ # Alert commands (list, view) +│ ├── monitor/ # Monitor commands +│ │ └── mute/ # Monitor mute commands (list, view, create, update, delete) │ ├── auth/ # Auth commands (configure, login, logout, status) │ ├── cli/ # CLI management (install, uninstall, upgrade) │ ├── content/ # Content pack management (experimental: gated + hidden) diff --git a/README.md b/README.md index 50809e6..cd858aa 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,10 @@ Command line interface for [Observe Inc](https://www.observeinc.com). - **OPAL Query Execution** - Run OPAL queries directly from your terminal with schema-aware table output. - **AI Agent Skills** - List and view reusable AI-agent instruction documents stored in Observe. - **Alert Monitoring** - List and view alerts with severity filtering and active-only views. +- **Monitor Mutes** - Full CRUD for monitor mute rules (snoozes), targeting all monitors or a specific set. +- **Content Packs** - Install and view Host Explorer, Kubernetes Explorer, and Trace Explorer content. +- **Ingest Token Management** - Full CRUD for ingest tokens with datastream association. +- **Data Integrations** - Create data connections and datasources (AWS, Kubernetes, host) and generate CloudFormation quick-create URLs for AWS filedrop deployments. - **Datastream Management** - Create, list, view, and update datastreams. - **Multiple Output Formats** - All commands support `--format json` and `--format csv` for scripting and pipelines. - **Responsive Tables** - Terminal-aware column widths with automatic text wrapping. @@ -50,6 +54,27 @@ To update installed skills after edits in this repo, run `npx skills update`. | `observe skill view` | View skill details and content | | `observe alert list` | List alerts with severity and status filtering | | `observe alert view` | View full alert details | +| `observe monitor mute list` | List and search monitor mute rules | +| `observe monitor mute view` | View full monitor mute rule details | +| `observe monitor mute create` | Create a monitor mute rule | +| `observe monitor mute update` | Update a monitor mute rule | +| `observe monitor mute delete` | Delete a monitor mute rule | +| `observe content host install` | Install Host Explorer content | +| `observe content host view` | View Host Explorer content | +| `observe content kubernetes install` | Install Kubernetes Explorer content | +| `observe content kubernetes view` | View Kubernetes Explorer content | +| `observe content tracing install` | Install Trace Explorer content | +| `observe content tracing view` | View Trace Explorer content | +| `observe ingest-token create` | Create a new ingest token | +| `observe ingest-token list` | List and search ingest tokens | +| `observe ingest-token view` | View an ingest token by ID | +| `observe ingest-token update` | Update an ingest token | +| `observe datasource create` | Create a datasource attached to a data connection | +| `observe datasource update` | Update an existing datasource's config | +| `observe datasource generate-stack-url` | Build a CloudFormation quick-create URL for a filedrop | +| `observe data-connection create` | Create a data connection (AWS, kubernetes, host, etc.) | +| `observe data-connection list` | List data connections | +| `observe data-connection view` | View a data connection by ID (with its datasources) | | `observe datastream create` | Create a new datastream | | `observe datastream list` | List datastreams | | `observe datastream view` | View a datastream by ID | diff --git a/src/app.ts b/src/app.ts index fe6d89c..d13f70f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,6 +1,7 @@ import { buildApplication } from "@stricli/core"; import { name } from "../package.json"; import { alertRoutes } from "./commands/alert/index.js"; +import { monitorRoutes } from "./commands/monitor/index.js"; import { dataConnectionRoutes } from "./commands/data-connection/index.js"; import { datasourceRoutes } from "./commands/datasource/index.js"; import { datastreamTokenRoutes } from "./commands/datastream-token/index.js"; @@ -31,6 +32,7 @@ export const routes = defineRoutes({ query: queryCommand, skill: skillRoutes, alert: alertRoutes, + monitor: monitorRoutes, content: contentRoutes, "ingest-token": ingestTokenRoutes, datasource: datasourceRoutes, diff --git a/src/commands/monitor/index.ts b/src/commands/monitor/index.ts new file mode 100644 index 0000000..c427504 --- /dev/null +++ b/src/commands/monitor/index.ts @@ -0,0 +1,17 @@ +import { buildRouteMap } from "@stricli/core"; +import { monitorMuteRoutes } from "./mute/index"; + +export const monitorRoutes = buildRouteMap({ + routes: { + mute: monitorMuteRoutes, + }, + docs: { + brief: "Manage monitors", + fullDescription: [ + "Manage monitors in Observe.", + "", + "Commands:", + " mute Manage monitor mute rules (snoozes)", + ].join("\n"), + }, +}); diff --git a/src/commands/monitor/mute/create.test.ts b/src/commands/monitor/mute/create.test.ts new file mode 100644 index 0000000..825d3bf --- /dev/null +++ b/src/commands/monitor/mute/create.test.ts @@ -0,0 +1,234 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import { resolve } from "node:path"; +import type { LocalContext } from "../../../context"; +import { createWriter } from "../../../lib/writer"; + +const repoRoot = resolve(import.meta.dir, "../../../.."); +const restModulePath = resolve( + repoRoot, + "src/rest/monitor-mute/create-monitor-mute.ts", +); + +const loadConfigFn = mock(() => ({ + customerId: "test-customer", + token: "test-token", + domain: "observeinc.com", +})); + +const createMonitorMuteFn = mock((_args: unknown) => + Promise.resolve({ id: "mute-1", label: "Snooze checkout" }), +); + +let create: (typeof import("./create"))["create"]; + +let previousNoColor: string | undefined; +let previousForceColor: string | undefined; + +const deps = { + loadConfig: loadConfigFn, +} as Parameters<(typeof import("./create"))["create"]>[1]; + +beforeAll(async () => { + previousNoColor = process.env.NO_COLOR; + previousForceColor = process.env.FORCE_COLOR; + process.env.NO_COLOR = "1"; + process.env.FORCE_COLOR = "0"; + + void mock.module(restModulePath, () => ({ + createMonitorMute: createMonitorMuteFn, + })); + + const mod = await import("./create.ts"); + create = mod.create; +}); + +afterAll(() => { + mock.restore(); + if (previousNoColor === undefined) { + delete process.env.NO_COLOR; + } else { + process.env.NO_COLOR = previousNoColor; + } + if (previousForceColor === undefined) { + delete process.env.FORCE_COLOR; + } else { + process.env.FORCE_COLOR = previousForceColor; + } +}); + +function createMockContext() { + const stdout: string[] = []; + const stderr: string[] = []; + let exitCode: number | undefined; + + const processMock = { + stdout: { + write: (msg: string) => { + stdout.push(msg); + return true; + }, + }, + stderr: { + write: (msg: string) => { + stderr.push(msg); + return true; + }, + }, + exit: (code?: number) => { + exitCode = code ?? 0; + throw new Error("process.exit"); + }, + }; + + const context = { + process: processMock, + writer: createWriter({ process: processMock }), + } as unknown as LocalContext; + + return { context, stdout, stderr, getExitCode: () => exitCode }; +} + +describe("monitor mute create", () => { + beforeEach(() => { + loadConfigFn.mockClear(); + createMonitorMuteFn.mockClear(); + }); + + test("builds the request body from flags and forwards it", async () => { + const { context, stdout } = createMockContext(); + await create.call( + context, + { + label: "Snooze checkout", + monitors: ["42", "43"], + start: "2026-06-23T18:00:00Z", + end: "2026-06-23T20:00:00Z", + json: true, + }, + deps, + ); + + expect(createMonitorMuteFn).toHaveBeenCalledTimes(1); + const [args] = createMonitorMuteFn.mock.calls[0]!; + expect((args as { body: unknown }).body).toMatchObject({ + label: "Snooze checkout", + target: { kind: "Monitors", monitors: [{ id: "42" }, { id: "43" }] }, + schedule: { + kind: "OneTime", + oneTime: { + startTime: "2026-06-23T18:00:00Z", + endTime: "2026-06-23T20:00:00Z", + }, + }, + }); + const output = JSON.parse(stdout.join("")); + expect(output.id).toBe("mute-1"); + }); + + test("builds a recurring + global body (with filter)", async () => { + const { context } = createMockContext(); + await create.call( + context, + { + label: "Weekday business hours", + global: true, + filter: 'level == "Critical"', + cron: "0 9 * * 1-5", + timezone: "America/Los_Angeles", + duration: 3600, + json: true, + }, + deps, + ); + + const [args] = createMonitorMuteFn.mock.calls[0]!; + expect((args as { body: unknown }).body).toMatchObject({ + target: { kind: "Global" }, + filter: 'level == "Critical"', + schedule: { + kind: "Recurring", + recurring: { + cronSchedule: { + rawCron: "0 9 * * 1-5", + timezone: "America/Los_Angeles", + }, + durationSeconds: 3600, + }, + }, + }); + }); + + test("exits 1 when no target is given", async () => { + const { context, getExitCode, stderr } = createMockContext(); + try { + await create.call( + context, + { label: "x", start: "2026-06-23T18:00:00Z", json: true }, + deps, + ); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("target"); + expect(createMonitorMuteFn).not.toHaveBeenCalled(); + }); + + test("exits 1 when no schedule is given", async () => { + const { context, getExitCode, stderr } = createMockContext(); + try { + await create.call(context, { label: "x", monitors: ["42"] }, deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("schedule"); + expect(createMonitorMuteFn).not.toHaveBeenCalled(); + }); + + test("exits 1 when --global is used without --filter", async () => { + const { context, getExitCode, stderr } = createMockContext(); + try { + await create.call( + context, + { label: "x", global: true, start: "2026-06-23T18:00:00Z" }, + deps, + ); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("--filter"); + expect(createMonitorMuteFn).not.toHaveBeenCalled(); + }); + + test("exits 1 on API error", async () => { + createMonitorMuteFn.mockImplementationOnce(() => { + throw new Error("bad target"); + }); + const { context, stderr, getExitCode } = createMockContext(); + try { + await create.call( + context, + { label: "x", monitors: ["42"], start: "2026-06-23T18:00:00Z" }, + deps, + ); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Error"); + }); +}); diff --git a/src/commands/monitor/mute/create.ts b/src/commands/monitor/mute/create.ts new file mode 100644 index 0000000..402b3cc --- /dev/null +++ b/src/commands/monitor/mute/create.ts @@ -0,0 +1,192 @@ +import { buildCommand } from "@stricli/core"; +import chalk from "chalk"; +import type { LocalContext } from "../../../context"; +import { createMonitorMute } from "../../../rest/monitor-mute/create-monitor-mute"; +import { + MonitorMuteTargetKind, + type MonitorMuteCreateRequest, +} from "../../../rest/generated"; +import { loadConfig } from "../../../lib/config"; +import { formatApiError } from "../../../lib/format-error"; +import { muteStatusWriter } from "../../../lib/writer"; +import { parseNonNegativeInt } from "../../../lib/parsers"; +import { buildSchedule, buildTarget, parseMonitorIds } from "./parse"; + +interface CreateMonitorMuteFlags { + label?: string; + description?: string; + monitors?: string[]; + global?: boolean; + filter?: string; + start?: string; + end?: string; + cron?: string; + timezone?: string; + duration?: number; + json?: boolean; +} + +export interface CreateMonitorMuteDeps { + loadConfig?: typeof loadConfig; +} + +export async function create( + this: LocalContext, + flags: CreateMonitorMuteFlags, + deps: CreateMonitorMuteDeps = {}, +): Promise { + const { loadConfig: loadConfigImpl = loadConfig } = deps; + const { process, writer: _writer } = this; + const writer = muteStatusWriter(_writer, { muted: flags.json }); + + let body: MonitorMuteCreateRequest; + try { + if (!flags.label) { + throw new Error("--label is required."); + } + const target = buildTarget(flags); + if (!target) { + throw new Error( + "Specify a target: --monitors for specific monitors, or --global for all monitors.", + ); + } + const schedule = buildSchedule(flags); + if (!schedule) { + throw new Error( + "Specify a schedule: --start [--end ] for a one-time window, or --cron/--timezone/--duration for a recurring one.", + ); + } + if (target.kind === MonitorMuteTargetKind.Global && !flags.filter) { + throw new Error("--filter is required when muting --global."); + } + body = { + label: flags.label, + description: flags.description, + target, + schedule, + filter: flags.filter, + }; + } catch (e) { + writer.error(e instanceof Error ? e.message : String(e)); + process.exit(1); + return; + } + + try { + const config = loadConfigImpl(); + + writer.info("Creating monitor mute..."); + + const mute = await createMonitorMute({ config, body }); + + if (flags.json) { + writer.write(JSON.stringify(mute, null, 2)); + return; + } + + writer.write( + chalk.green(`Created monitor mute `) + + chalk.bold(mute.id) + + chalk.green(` — "${mute.label}"`), + ); + } catch (error) { + writer.error(`Error: ${await formatApiError(error)}`); + process.exit(1); + } +} + +export const createCommand = buildCommand({ + loader: async () => create, + parameters: { + positional: { + kind: "tuple", + parameters: [], + }, + flags: { + label: { + kind: "parsed", + parse: String, + brief: "Human-readable name for the mute rule", + optional: true, + }, + description: { + kind: "parsed", + parse: String, + brief: "Optional free-form description", + optional: true, + }, + monitors: { + kind: "parsed", + parse: parseMonitorIds, + brief: "Comma-separated monitor IDs to mute (target kind: Monitors)", + optional: true, + }, + global: { + kind: "boolean", + brief: "Mute all monitors (target kind: Global); requires --filter", + optional: true, + }, + filter: { + kind: "parsed", + parse: String, + brief: + 'CEL expression evaluated per fired alarm; required with --global (e.g. level == "Critical")', + optional: true, + }, + start: { + kind: "parsed", + parse: String, + brief: "One-time window start (ISO-8601, e.g. 2026-06-23T18:00:00Z)", + optional: true, + }, + end: { + kind: "parsed", + parse: String, + brief: "One-time window end (ISO-8601); omit for open-ended", + optional: true, + }, + cron: { + kind: "parsed", + parse: String, + brief: "Recurring schedule cron expression (e.g. '0 9 * * 1-5')", + optional: true, + }, + timezone: { + kind: "parsed", + parse: String, + brief: "Recurring schedule IANA timezone (e.g. America/Los_Angeles)", + optional: true, + }, + duration: { + kind: "parsed", + parse: parseNonNegativeInt, + brief: "Recurring schedule duration per occurrence, in seconds", + optional: true, + }, + json: { + kind: "boolean", + brief: "Output the created mute rule as JSON", + optional: true, + }, + }, + aliases: {}, + }, + docs: { + brief: "Create a monitor mute rule", + fullDescription: [ + "Create a monitor mute rule (snooze) via the /v1/monitor-mutes REST API.", + "", + "Target (one required):", + " --monitors mute specific monitors", + " --global mute all monitors (requires --filter)", + "", + "Schedule (one required):", + " --start [--end ] one-time window", + " --cron --timezone --duration recurring", + "", + "Example:", + " observe monitor mute create --label 'Snooze checkout' \\", + " --monitors 41000001 --start 2026-06-23T18:00:00Z --end 2026-06-23T20:00:00Z", + ].join("\n"), + }, +}); diff --git a/src/commands/monitor/mute/delete.test.ts b/src/commands/monitor/mute/delete.test.ts new file mode 100644 index 0000000..6122fe2 --- /dev/null +++ b/src/commands/monitor/mute/delete.test.ts @@ -0,0 +1,158 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import { resolve } from "node:path"; +import type { LocalContext } from "../../../context"; +import { createWriter } from "../../../lib/writer"; + +const repoRoot = resolve(import.meta.dir, "../../../.."); +const getModulePath = resolve( + repoRoot, + "src/rest/monitor-mute/get-monitor-mute.ts", +); +const deleteModulePath = resolve( + repoRoot, + "src/rest/monitor-mute/delete-monitor-mute.ts", +); + +const loadConfigFn = mock(() => ({ + customerId: "test-customer", + token: "test-token", + domain: "observeinc.com", +})); + +const getMonitorMuteFn = mock((_args: unknown) => + Promise.resolve({ + id: "mute-1", + label: "Snooze checkout", + target: { kind: "Monitors", monitors: [{ id: "42" }, { id: "43" }] }, + }), +); +const deleteMonitorMuteFn = mock((_args: unknown) => Promise.resolve()); + +let remove: (typeof import("./delete"))["remove"]; + +let previousNoColor: string | undefined; +let previousForceColor: string | undefined; + +const deps = { + loadConfig: loadConfigFn, +} as Parameters<(typeof import("./delete"))["remove"]>[2]; + +beforeAll(async () => { + previousNoColor = process.env.NO_COLOR; + previousForceColor = process.env.FORCE_COLOR; + process.env.NO_COLOR = "1"; + process.env.FORCE_COLOR = "0"; + + void mock.module(getModulePath, () => ({ + getMonitorMute: getMonitorMuteFn, + })); + void mock.module(deleteModulePath, () => ({ + deleteMonitorMute: deleteMonitorMuteFn, + })); + + const mod = await import("./delete.ts"); + remove = mod.remove; +}); + +afterAll(() => { + mock.restore(); + if (previousNoColor === undefined) { + delete process.env.NO_COLOR; + } else { + process.env.NO_COLOR = previousNoColor; + } + if (previousForceColor === undefined) { + delete process.env.FORCE_COLOR; + } else { + process.env.FORCE_COLOR = previousForceColor; + } +}); + +function createMockContext() { + const stdout: string[] = []; + const stderr: string[] = []; + let exitCode: number | undefined; + + const processMock = { + // Non-interactive: the confirm() helper bails out unless --yes is passed. + stdin: { isTTY: false }, + stdout: { + write: (msg: string) => { + stdout.push(msg); + return true; + }, + }, + stderr: { + write: (msg: string) => { + stderr.push(msg); + return true; + }, + }, + exit: (code?: number) => { + exitCode = code ?? 0; + throw new Error("process.exit"); + }, + }; + + const context = { + process: processMock, + writer: createWriter({ process: processMock }), + } as unknown as LocalContext; + + return { context, stdout, stderr, getExitCode: () => exitCode }; +} + +describe("monitor-mute delete", () => { + beforeEach(() => { + loadConfigFn.mockClear(); + getMonitorMuteFn.mockClear(); + deleteMonitorMuteFn.mockClear(); + }); + + test("with --yes, deletes without fetching or prompting", async () => { + const { context, stdout } = createMockContext(); + await remove.call(context, { yes: true }, "mute-1", deps); + + expect(getMonitorMuteFn).not.toHaveBeenCalled(); + expect(deleteMonitorMuteFn).toHaveBeenCalledTimes(1); + const [args] = deleteMonitorMuteFn.mock.calls[0]!; + expect((args as { id: string }).id).toBe("mute-1"); + expect(stdout.join("")).toContain("Deleted monitor mute"); + }); + + test("without --yes on a non-TTY, aborts (exit 1) and does not delete", async () => { + const { context, getExitCode } = createMockContext(); + try { + await remove.call(context, {}, "mute-1", deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getMonitorMuteFn).toHaveBeenCalledTimes(1); + expect(deleteMonitorMuteFn).not.toHaveBeenCalled(); + expect(getExitCode()).toBe(1); + }); + + test("exits 1 on API error", async () => { + deleteMonitorMuteFn.mockImplementationOnce(() => { + throw new Error("not found"); + }); + const { context, stderr, getExitCode } = createMockContext(); + try { + await remove.call(context, { yes: true }, "missing", deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Error"); + }); +}); diff --git a/src/commands/monitor/mute/delete.ts b/src/commands/monitor/mute/delete.ts new file mode 100644 index 0000000..17b4aaa --- /dev/null +++ b/src/commands/monitor/mute/delete.ts @@ -0,0 +1,122 @@ +import { buildCommand } from "@stricli/core"; +import chalk from "chalk"; +import { createInterface } from "node:readline/promises"; +import type { LocalContext } from "../../../context"; +import { getMonitorMute } from "../../../rest/monitor-mute/get-monitor-mute"; +import { deleteMonitorMute } from "../../../rest/monitor-mute/delete-monitor-mute"; +import { MonitorMuteTargetKind } from "../../../rest/generated"; +import { loadConfig } from "../../../lib/config"; +import { formatApiError } from "../../../lib/format-error"; +import { muteStatusWriter } from "../../../lib/writer"; + +interface DeleteMonitorMuteFlags { + yes?: boolean; + json?: boolean; +} + +async function confirm(this: LocalContext, question: string): Promise { + const { process } = this; + if (!process.stdin.isTTY) { + return false; + } + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }); + try { + const answer = await rl.question(`${question} [y/N] `); + return /^y(es)?$/i.test(answer.trim()); + } finally { + rl.close(); + } +} + +export interface DeleteMonitorMuteDeps { + loadConfig?: typeof loadConfig; +} + +export async function remove( + this: LocalContext, + flags: DeleteMonitorMuteFlags, + id: string, + deps: DeleteMonitorMuteDeps = {}, +): Promise { + const { loadConfig: loadConfigImpl = loadConfig } = deps; + const { process, writer: _writer } = this; + const writer = muteStatusWriter(_writer, { muted: flags.json }); + + try { + const config = loadConfigImpl(); + + if (!flags.yes) { + // Fetch first so we can warn when deleting a rule that mutes several + // monitors — deleting it resumes notifications for ALL of them. + const mute = await getMonitorMute({ config, id }); + const target = + mute.target.kind === MonitorMuteTargetKind.Monitors + ? `${mute.target.monitors.length} monitor(s)` + : "ALL monitors (Global)"; + writer.warn( + `Deleting mute rule ${chalk.bold(id)} ("${mute.label}") will resume notifications for ${target}.`, + ); + const confirmed = await confirm.call(this, "Delete this mute rule?"); + if (!confirmed) { + writer.info("Aborted. Re-run with --yes to skip this prompt."); + process.exit(1); + return; + } + } + + await deleteMonitorMute({ config, id }); + + if (flags.json) { + writer.write(JSON.stringify({ success: true, id }, null, 2)); + return; + } + + writer.write(chalk.green(`Deleted monitor mute ${chalk.bold(id)}.`)); + } catch (error) { + writer.error(`Error: ${await formatApiError(error)}`); + process.exit(1); + } +} + +export const deleteCommand = buildCommand({ + loader: async () => remove, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + brief: "Monitor mute rule ID", + parse: String, + }, + ], + }, + flags: { + yes: { + kind: "boolean", + brief: "Skip the confirmation prompt", + optional: true, + }, + json: { + kind: "boolean", + brief: "Output the result as JSON", + optional: true, + }, + }, + aliases: { + y: "yes", + }, + }, + docs: { + brief: "Delete a monitor mute rule", + fullDescription: [ + "Delete a monitor mute rule by ID via the /v1/monitor-mutes REST API.", + "", + "Deleting a rule that targets multiple monitors resumes notifications for", + "all of them. To unmute a single monitor from a shared rule, update the", + "rule's target instead with 'observe monitor-mute update '.", + ].join("\n"), + }, +}); diff --git a/src/commands/monitor/mute/index.ts b/src/commands/monitor/mute/index.ts new file mode 100644 index 0000000..5f712ac --- /dev/null +++ b/src/commands/monitor/mute/index.ts @@ -0,0 +1,32 @@ +import { buildRouteMap } from "@stricli/core"; +import { listCommand } from "./list"; +import { viewCommand } from "./view"; +import { createCommand } from "./create"; +import { updateCommand } from "./update"; +import { deleteCommand } from "./delete"; + +export const monitorMuteRoutes = buildRouteMap({ + routes: { + list: listCommand, + view: viewCommand, + create: createCommand, + update: updateCommand, + delete: deleteCommand, + }, + docs: { + brief: "Manage monitor mute rules", + fullDescription: [ + "View and manage monitor mute rules (snoozes) in Observe.", + "", + "A mute rule suppresses alert notifications during a defined window,", + "targeting either all monitors (Global) or a specific set (Monitors).", + "", + "Commands:", + " list Search and list monitor mute rules", + " view View details of a specific mute rule", + " create Create a mute rule", + " update Update a mute rule", + " delete Delete a mute rule", + ].join("\n"), + }, +}); diff --git a/src/commands/monitor/mute/list.test.ts b/src/commands/monitor/mute/list.test.ts new file mode 100644 index 0000000..71136e2 --- /dev/null +++ b/src/commands/monitor/mute/list.test.ts @@ -0,0 +1,196 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import { resolve } from "node:path"; +import type { LocalContext } from "../../../context"; +import { MonitorMuteTargetKind } from "../../../rest/generated"; +import { createWriter } from "../../../lib/writer"; + +const repoRoot = resolve(import.meta.dir, "../../../.."); +const restModulePath = resolve( + repoRoot, + "src/rest/monitor-mute/list-monitor-mutes.ts", +); + +const loadConfigFn = mock(() => ({ + customerId: "test-customer", + token: "test-token", + domain: "observeinc.com", +})); + +const listMonitorMutesFn = mock((_args: unknown) => + Promise.resolve({ + monitorMutes: [ + { + id: "mute-1", + label: "Snooze checkout", + description: null, + target: { kind: "Monitors", monitors: [{ id: "42" }, { id: "43" }] }, + schedule: { + kind: "OneTime", + oneTime: { startTime: "t", endTime: "u" }, + }, + filter: null, + startTime: "t", + endTime: "u", + }, + { + id: "mute-2", + label: "Global maintenance", + description: null, + target: { kind: "Global", monitors: [] }, + schedule: { + kind: "OneTime", + oneTime: { startTime: "t", endTime: null }, + }, + filter: 'level == "Critical"', + startTime: "t", + endTime: null, + }, + ], + meta: { totalCount: 2 }, + }), +); + +let list: (typeof import("./list"))["list"]; + +let previousNoColor: string | undefined; +let previousForceColor: string | undefined; + +const deps = { + loadConfig: loadConfigFn, +} as Parameters<(typeof import("./list"))["list"]>[1]; + +beforeAll(async () => { + previousNoColor = process.env.NO_COLOR; + previousForceColor = process.env.FORCE_COLOR; + process.env.NO_COLOR = "1"; + process.env.FORCE_COLOR = "0"; + + void mock.module(restModulePath, () => ({ + listMonitorMutes: listMonitorMutesFn, + })); + + const mod = await import("./list.ts"); + list = mod.list; +}); + +afterAll(() => { + mock.restore(); + if (previousNoColor === undefined) { + delete process.env.NO_COLOR; + } else { + process.env.NO_COLOR = previousNoColor; + } + if (previousForceColor === undefined) { + delete process.env.FORCE_COLOR; + } else { + process.env.FORCE_COLOR = previousForceColor; + } +}); + +function createMockContext() { + const stdout: string[] = []; + const stderr: string[] = []; + let exitCode: number | undefined; + + const processMock = { + stdout: { + write: (msg: string) => { + stdout.push(msg); + return true; + }, + }, + stderr: { + write: (msg: string) => { + stderr.push(msg); + return true; + }, + }, + exit: (code?: number) => { + exitCode = code ?? 0; + throw new Error("process.exit"); + }, + }; + + const context = { + process: processMock, + writer: createWriter({ process: processMock }), + } as unknown as LocalContext; + + return { context, stdout, stderr, getExitCode: () => exitCode }; +} + +describe("monitor-mute list", () => { + beforeEach(() => { + loadConfigFn.mockClear(); + listMonitorMutesFn.mockClear(); + }); + + test("passes no filter when no flags are set", async () => { + const { context } = createMockContext(); + await list.call(context, { limit: 100, json: true }, deps); + + expect(listMonitorMutesFn).toHaveBeenCalledTimes(1); + const [args] = listMonitorMutesFn.mock.calls[0]!; + expect((args as { filter?: string }).filter).toBeUndefined(); + }); + + test("builds a label match filter from --match", async () => { + const { context } = createMockContext(); + await list.call( + context, + { limit: 100, match: "checkout", json: true }, + deps, + ); + + const [args] = listMonitorMutesFn.mock.calls[0]!; + expect((args as { filter?: string }).filter).toBe( + 'label.matches("(?i)checkout")', + ); + }); + + test("combines --match and --kind with &&", async () => { + const { context } = createMockContext(); + await list.call( + context, + { + limit: 100, + match: "checkout", + kind: MonitorMuteTargetKind.Global, + json: true, + }, + deps, + ); + + const [args] = listMonitorMutesFn.mock.calls[0]!; + expect((args as { filter?: string }).filter).toBe( + 'label.matches("(?i)checkout") && target.kind == "Global"', + ); + }); + + test("emits the resolved mute rules as JSON", async () => { + const { context, stdout } = createMockContext(); + await list.call(context, { limit: 100, json: true }, deps); + + const output = JSON.parse(stdout.join("")); + expect(output).toHaveLength(2); + expect(output[0].id).toBe("mute-1"); + }); + + test("renders an empty-state message when nothing matches", async () => { + listMonitorMutesFn.mockImplementationOnce(() => + Promise.resolve({ monitorMutes: [], meta: { totalCount: 0 } }), + ); + const { context, stdout } = createMockContext(); + await list.call(context, { limit: 100 }, deps); + + expect(stdout.join("")).toContain("No monitor mutes found."); + }); +}); diff --git a/src/commands/monitor/mute/list.ts b/src/commands/monitor/mute/list.ts new file mode 100644 index 0000000..82b3c90 --- /dev/null +++ b/src/commands/monitor/mute/list.ts @@ -0,0 +1,270 @@ +import { buildCommand } from "@stricli/core"; +import chalk from "chalk"; +import type { LocalContext } from "../../../context"; +import { listMonitorMutes } from "../../../rest/monitor-mute/list-monitor-mutes"; +import { + type MonitorMuteResource, + MonitorMuteTargetKind, +} from "../../../rest/generated"; +import { celMatchesInsensitive } from "../../../lib/cel"; +import { loadConfig } from "../../../lib/config"; +import { formatApiError } from "../../../lib/format-error"; +import { muteStatusWriter } from "../../../lib/writer"; +import { parseNonNegativeInt } from "../../../lib/parsers"; +import { + formatTable, + createColumnHelper, + type ColumnDef, +} from "../../../lib/formatters/table"; +import { renderAsCSV } from "../../../lib/formatters/csv"; + +type OutputFormat = "json" | "csv"; +type SortField = "id" | "label" | "createdAt" | "updatedAt"; + +interface ListMonitorMutesFlags { + match?: string; + kind?: MonitorMuteTargetKind; + limit: number; + offset?: number; + sort?: SortField; + format?: OutputFormat; + json?: boolean; + fields?: FieldName[]; +} + +const AVAILABLE_FIELDS = [ + "id", + "label", + "target", + "monitors", + "schedule", + "start", + "end", +] as const; + +type FieldName = (typeof AVAILABLE_FIELDS)[number]; + +const DEFAULT_FIELDS: FieldName[] = [ + "id", + "label", + "target", + "schedule", + "end", +]; + +function describeTarget(mute: MonitorMuteResource): string { + return mute.target.kind === MonitorMuteTargetKind.Monitors + ? `Monitors (${mute.target.monitors.length})` + : "Global"; +} + +const col = createColumnHelper(); + +const FIELD_COLUMNS = { + id: col.accessor((row) => row.id, { + header: "ID", + }), + label: col.accessor((row) => row.label, { + header: "LABEL", + }), + target: col.accessor(describeTarget, { + header: "TARGET", + format: (value) => + value === "Global" ? chalk.yellow(value) : chalk.cyan(value), + }), + monitors: col.accessor( + (row) => + row.target.kind === MonitorMuteTargetKind.Monitors + ? row.target.monitors.map((m) => m.id).join(", ") || "-" + : "-", + { + header: "MONITORS", + }, + ), + schedule: col.accessor((row) => row.schedule.kind, { + header: "SCHEDULE", + }), + start: col.accessor((row) => row.startTime ?? "-", { + header: "START", + }), + end: col.accessor((row) => row.endTime ?? chalk.dim("open-ended"), { + header: "END", + }), +} satisfies Record>; + +export interface ListMonitorMutesDeps { + loadConfig?: typeof loadConfig; +} + +export async function list( + this: LocalContext, + flags: ListMonitorMutesFlags, + deps: ListMonitorMutesDeps = {}, +): Promise { + const { loadConfig: loadConfigImpl = loadConfig } = deps; + const { process, writer: _writer } = this; + + const format = flags.json ? ("json" as const) : flags.format; + const writer = muteStatusWriter(_writer, { + muted: format === "json" || format === "csv", + }); + + try { + const config = loadConfigImpl(); + + writer.info("Searching monitor mutes..."); + + const filters: string[] = []; + if (flags.match) { + filters.push(celMatchesInsensitive("label", flags.match)); + } + if (flags.kind) { + filters.push(`target.kind == "${flags.kind}"`); + } + const filter = filters.length > 0 ? filters.join(" && ") : undefined; + + const result = await listMonitorMutes({ + config, + filter, + limit: flags.limit, + offset: flags.offset, + orderBy: flags.sort, + }); + + const mutes = result.monitorMutes; + const fieldNames = flags.fields ?? DEFAULT_FIELDS; + + if (format === "json") { + writer.write(JSON.stringify(mutes, null, 2)); + return; + } + + if (format === "csv") { + writer.write(renderAsCSV(mutes)); + return; + } + + if (mutes.length === 0) { + writer.warn("No monitor mutes found."); + return; + } + + writer.write(chalk.green(`Found ${mutes.length} monitor mute(s):\n`)); + + const columns = fieldNames.map((field) => FIELD_COLUMNS[field]); + writer.write(formatTable(mutes, columns)); + + if (mutes.length === flags.limit) { + const nextOffset = (flags.offset ?? 0) + flags.limit; + writer.info( + `\nMore results may be available. Use --offset ${nextOffset} to see the next page.`, + ); + } + } catch (error) { + writer.error(`Error: ${await formatApiError(error)}`); + process.exit(1); + } +} + +const MAX_LIMIT = 1000; +const MIN_LIMIT = 1; +const DEFAULT_LIMIT = 100; + +function parseLimit(value: string): number { + const num = Number(value); + if (isNaN(num) || num < MIN_LIMIT || num > MAX_LIMIT) { + throw new Error(`Limit must be between ${MIN_LIMIT} and ${MAX_LIMIT}`); + } + return num; +} + +function parseFields(value: string): FieldName[] { + const fields = value.split(",").map((f) => f.trim()) as FieldName[]; + for (const field of fields) { + if (!AVAILABLE_FIELDS.includes(field)) { + throw new Error( + `Invalid field: "${field}". Available fields: ${AVAILABLE_FIELDS.join(", ")}`, + ); + } + } + return fields; +} + +const VALID_KINDS = Object.values(MonitorMuteTargetKind); + +function parseKind(value: string): MonitorMuteTargetKind { + if (!VALID_KINDS.includes(value as MonitorMuteTargetKind)) { + throw new Error( + `Invalid kind: "${value}". Available kinds: ${VALID_KINDS.join(", ")}`, + ); + } + return value as MonitorMuteTargetKind; +} + +export const listCommand = buildCommand({ + loader: async () => list, + parameters: { + positional: { + kind: "tuple", + parameters: [], + }, + flags: { + match: { + kind: "parsed", + parse: String, + brief: "Search mute rules by label substring", + optional: true, + }, + kind: { + kind: "parsed", + parse: parseKind, + brief: `Filter by target kind: ${VALID_KINDS.join(", ")}`, + optional: true, + }, + limit: { + kind: "parsed", + parse: parseLimit, + brief: `Maximum number of mute rules to return (${MIN_LIMIT}-${MAX_LIMIT})`, + default: String(DEFAULT_LIMIT), + }, + offset: { + kind: "parsed", + parse: parseNonNegativeInt, + brief: "Offset for pagination (skip this many results)", + optional: true, + }, + sort: { + kind: "enum", + values: ["id", "label", "createdAt", "updatedAt"], + brief: + "Sort results by field (prefix with - for descending, e.g. -createdAt)", + optional: true, + }, + format: { + kind: "enum", + values: ["json", "csv"], + brief: "Output format (json, csv)", + optional: true, + }, + json: { + kind: "boolean", + brief: "Output as JSON (shorthand for --format=json)", + optional: true, + }, + fields: { + kind: "parsed", + parse: parseFields, + brief: `Comma-separated list of fields: ${AVAILABLE_FIELDS.join(", ")}`, + optional: true, + }, + }, + aliases: { + m: "match", + l: "limit", + s: "sort", + }, + }, + docs: { + brief: "Search and list monitor mute rules in Observe", + }, +}); diff --git a/src/commands/monitor/mute/parse.ts b/src/commands/monitor/mute/parse.ts new file mode 100644 index 0000000..175df0c --- /dev/null +++ b/src/commands/monitor/mute/parse.ts @@ -0,0 +1,98 @@ +import { + MonitorMuteScheduleKind, + type MonitorMuteScheduleInput, + MonitorMuteTargetKind, + type MonitorMuteTargetInput, +} from "../../../rest/generated"; + +/** + * Flags shared by `create` and `update` that map onto a mute rule's `target` + * and `schedule`. Both commands build these the same way; only their + * required/optional handling differs (create requires them, update is partial). + */ +export interface MonitorMuteFieldFlags { + monitors?: string[]; + global?: boolean; + start?: string; + end?: string; + cron?: string; + timezone?: string; + duration?: number; +} + +/** `--global` → Global; `--monitors a,b` → Monitors; neither → undefined. */ +export function buildTarget( + flags: MonitorMuteFieldFlags, +): MonitorMuteTargetInput | undefined { + if (flags.global && flags.monitors && flags.monitors.length > 0) { + throw new Error("Pass either --global or --monitors, not both."); + } + if (flags.global) { + return { kind: MonitorMuteTargetKind.Global }; + } + if (flags.monitors && flags.monitors.length > 0) { + return { + kind: MonitorMuteTargetKind.Monitors, + monitors: flags.monitors.map((id) => ({ id })), + }; + } + return undefined; +} + +/** + * `--start [--end]` → OneTime; `--cron --timezone --duration` → Recurring; + * neither → undefined. The two schedule shapes are mutually exclusive. + */ +export function buildSchedule( + flags: MonitorMuteFieldFlags, +): MonitorMuteScheduleInput | undefined { + const hasRecurring = + flags.cron !== undefined || + flags.timezone !== undefined || + flags.duration !== undefined; + const hasOneTime = flags.start !== undefined || flags.end !== undefined; + + if (hasRecurring && hasOneTime) { + throw new Error( + "Pass either a one-time window (--start/--end) or a recurring schedule " + + "(--cron/--timezone/--duration), not both.", + ); + } + + if (hasRecurring) { + if (!flags.cron || !flags.timezone || flags.duration === undefined) { + throw new Error( + "A recurring schedule requires --cron, --timezone, and --duration together.", + ); + } + return { + kind: MonitorMuteScheduleKind.Recurring, + recurring: { + cronSchedule: { rawCron: flags.cron, timezone: flags.timezone }, + durationSeconds: flags.duration, + }, + }; + } + + if (hasOneTime) { + if (!flags.start) { + throw new Error( + "A one-time window requires --start (and optionally --end).", + ); + } + return { + kind: MonitorMuteScheduleKind.OneTime, + oneTime: { startTime: flags.start, endTime: flags.end ?? null }, + }; + } + + return undefined; +} + +/** Parse a comma-separated `--monitors` value into a list of ids. */ +export function parseMonitorIds(value: string): string[] { + return value + .split(",") + .map((id) => id.trim()) + .filter(Boolean); +} diff --git a/src/commands/monitor/mute/update.test.ts b/src/commands/monitor/mute/update.test.ts new file mode 100644 index 0000000..e35b85a --- /dev/null +++ b/src/commands/monitor/mute/update.test.ts @@ -0,0 +1,192 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import { resolve } from "node:path"; +import type { LocalContext } from "../../../context"; +import { createWriter } from "../../../lib/writer"; + +const repoRoot = resolve(import.meta.dir, "../../../.."); +const restModulePath = resolve( + repoRoot, + "src/rest/monitor-mute/update-monitor-mute.ts", +); + +const loadConfigFn = mock(() => ({ + customerId: "test-customer", + token: "test-token", + domain: "observeinc.com", +})); + +const updateMonitorMuteFn = mock((_args: unknown) => + Promise.resolve({ id: "mute-1", label: "renamed" }), +); + +let update: (typeof import("./update"))["update"]; + +let previousNoColor: string | undefined; +let previousForceColor: string | undefined; + +const deps = { + loadConfig: loadConfigFn, +} as Parameters<(typeof import("./update"))["update"]>[2]; + +beforeAll(async () => { + previousNoColor = process.env.NO_COLOR; + previousForceColor = process.env.FORCE_COLOR; + process.env.NO_COLOR = "1"; + process.env.FORCE_COLOR = "0"; + + void mock.module(restModulePath, () => ({ + updateMonitorMute: updateMonitorMuteFn, + })); + + const mod = await import("./update.ts"); + update = mod.update; +}); + +afterAll(() => { + mock.restore(); + if (previousNoColor === undefined) { + delete process.env.NO_COLOR; + } else { + process.env.NO_COLOR = previousNoColor; + } + if (previousForceColor === undefined) { + delete process.env.FORCE_COLOR; + } else { + process.env.FORCE_COLOR = previousForceColor; + } +}); + +function createMockContext() { + const stdout: string[] = []; + const stderr: string[] = []; + let exitCode: number | undefined; + + const processMock = { + stdout: { + write: (msg: string) => { + stdout.push(msg); + return true; + }, + }, + stderr: { + write: (msg: string) => { + stderr.push(msg); + return true; + }, + }, + exit: (code?: number) => { + exitCode = code ?? 0; + throw new Error("process.exit"); + }, + }; + + const context = { + process: processMock, + writer: createWriter({ process: processMock }), + } as unknown as LocalContext; + + return { context, stdout, stderr, getExitCode: () => exitCode }; +} + +describe("monitor mute update", () => { + beforeEach(() => { + loadConfigFn.mockClear(); + updateMonitorMuteFn.mockClear(); + }); + + test("sends only the provided fields as a partial body", async () => { + const { context, stdout } = createMockContext(); + await update.call( + context, + { label: "renamed", json: true }, + "mute-1", + deps, + ); + + expect(updateMonitorMuteFn).toHaveBeenCalledTimes(1); + const [args] = updateMonitorMuteFn.mock.calls[0]!; + expect(args).toMatchObject({ id: "mute-1", body: { label: "renamed" } }); + // Only the field supplied as a flag is present — no target/schedule churn. + const body = (args as { body: Record }).body; + expect(body).not.toHaveProperty("target"); + expect(body).not.toHaveProperty("schedule"); + const output = JSON.parse(stdout.join("")); + expect(output.label).toBe("renamed"); + }); + + test("replaces the schedule when a full one-time window is given", async () => { + const { context } = createMockContext(); + await update.call( + context, + { + start: "2026-06-23T18:00:00Z", + end: "2026-06-23T22:00:00Z", + json: true, + }, + "mute-1", + deps, + ); + + const [args] = updateMonitorMuteFn.mock.calls[0]!; + expect((args as { body: unknown }).body).toMatchObject({ + schedule: { + kind: "OneTime", + oneTime: { + startTime: "2026-06-23T18:00:00Z", + endTime: "2026-06-23T22:00:00Z", + }, + }, + }); + }); + + test("retargets to specific monitors when --monitors is given", async () => { + const { context } = createMockContext(); + await update.call( + context, + { monitors: ["42", "43"], json: true }, + "mute-1", + deps, + ); + + const [args] = updateMonitorMuteFn.mock.calls[0]!; + expect((args as { body: unknown }).body).toMatchObject({ + target: { kind: "Monitors", monitors: [{ id: "42" }, { id: "43" }] }, + }); + }); + + test("exits 1 when no fields are provided", async () => { + const { context, getExitCode, stderr } = createMockContext(); + try { + await update.call(context, { json: true }, "mute-1", deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Nothing to update"); + expect(updateMonitorMuteFn).not.toHaveBeenCalled(); + }); + + test("exits 1 on API error", async () => { + updateMonitorMuteFn.mockImplementationOnce(() => { + throw new Error("bad schedule"); + }); + const { context, stderr, getExitCode } = createMockContext(); + try { + await update.call(context, { label: "x" }, "mute-1", deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Error"); + }); +}); diff --git a/src/commands/monitor/mute/update.ts b/src/commands/monitor/mute/update.ts new file mode 100644 index 0000000..b1cde05 --- /dev/null +++ b/src/commands/monitor/mute/update.ts @@ -0,0 +1,188 @@ +import { buildCommand } from "@stricli/core"; +import chalk from "chalk"; +import type { LocalContext } from "../../../context"; +import { updateMonitorMute } from "../../../rest/monitor-mute/update-monitor-mute"; +import { type MonitorMuteUpdateRequest } from "../../../rest/generated"; +import { loadConfig } from "../../../lib/config"; +import { formatApiError } from "../../../lib/format-error"; +import { muteStatusWriter } from "../../../lib/writer"; +import { parseNonNegativeInt } from "../../../lib/parsers"; +import { buildSchedule, buildTarget, parseMonitorIds } from "./parse"; + +interface UpdateMonitorMuteFlags { + label?: string; + description?: string; + monitors?: string[]; + global?: boolean; + filter?: string; + start?: string; + end?: string; + cron?: string; + timezone?: string; + duration?: number; + json?: boolean; +} + +export interface UpdateMonitorMuteDeps { + loadConfig?: typeof loadConfig; +} + +export async function update( + this: LocalContext, + flags: UpdateMonitorMuteFlags, + id: string, + deps: UpdateMonitorMuteDeps = {}, +): Promise { + const { loadConfig: loadConfigImpl = loadConfig } = deps; + const { process, writer: _writer } = this; + const writer = muteStatusWriter(_writer, { muted: flags.json }); + + // JSON Merge Patch: only the fields provided as flags are sent. + let body: MonitorMuteUpdateRequest; + try { + const target = buildTarget(flags); + const schedule = buildSchedule(flags); + body = {}; + if (flags.label !== undefined) { + body.label = flags.label; + } + if (flags.description !== undefined) { + body.description = flags.description; + } + if (target) { + body.target = target; + } + if (schedule) { + body.schedule = schedule; + } + if (flags.filter !== undefined) { + body.filter = flags.filter; + } + if (Object.keys(body).length === 0) { + throw new Error( + "Nothing to update. Provide at least one of --label, --description, --monitors/--global, a schedule (--start/--end or --cron/--timezone/--duration), or --filter.", + ); + } + } catch (e) { + writer.error(e instanceof Error ? e.message : String(e)); + process.exit(1); + return; + } + + try { + const config = loadConfigImpl(); + + writer.info("Updating monitor mute..."); + + const mute = await updateMonitorMute({ config, id, body }); + + if (flags.json) { + writer.write(JSON.stringify(mute, null, 2)); + return; + } + + writer.write( + chalk.green(`Updated monitor mute `) + + chalk.bold(mute.id) + + chalk.green(` — "${mute.label}"`), + ); + } catch (error) { + writer.error(`Error: ${await formatApiError(error)}`); + process.exit(1); + } +} + +export const updateCommand = buildCommand({ + loader: async () => update, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + brief: "Monitor mute rule ID", + parse: String, + }, + ], + }, + flags: { + label: { + kind: "parsed", + parse: String, + brief: "New human-readable name", + optional: true, + }, + description: { + kind: "parsed", + parse: String, + brief: "New description", + optional: true, + }, + monitors: { + kind: "parsed", + parse: parseMonitorIds, + brief: "Replace the target with these comma-separated monitor IDs", + optional: true, + }, + global: { + kind: "boolean", + brief: "Replace the target with Global (all monitors)", + optional: true, + }, + filter: { + kind: "parsed", + parse: String, + brief: "New CEL filter expression", + optional: true, + }, + start: { + kind: "parsed", + parse: String, + brief: "New one-time window start (ISO-8601)", + optional: true, + }, + end: { + kind: "parsed", + parse: String, + brief: "New one-time window end (ISO-8601)", + optional: true, + }, + cron: { + kind: "parsed", + parse: String, + brief: "New recurring schedule cron expression", + optional: true, + }, + timezone: { + kind: "parsed", + parse: String, + brief: "New recurring schedule IANA timezone", + optional: true, + }, + duration: { + kind: "parsed", + parse: parseNonNegativeInt, + brief: "New recurring schedule duration per occurrence, in seconds", + optional: true, + }, + json: { + kind: "boolean", + brief: "Output the updated mute rule as JSON", + optional: true, + }, + }, + aliases: {}, + }, + docs: { + brief: "Update a monitor mute rule", + fullDescription: [ + "Update a monitor mute rule (snooze) by ID via the /v1/monitor-mutes REST API.", + "", + "Only the fields you pass as flags are changed (JSON Merge Patch). Provide a", + "new target with --monitors/--global, or a new schedule with --start/--end", + "or --cron/--timezone/--duration.", + "", + "Example:", + " observe monitor mute update mute-123 --label 'Snooze checkout (extended)'", + ].join("\n"), + }, +}); diff --git a/src/commands/monitor/mute/view.test.ts b/src/commands/monitor/mute/view.test.ts new file mode 100644 index 0000000..f40b726 --- /dev/null +++ b/src/commands/monitor/mute/view.test.ts @@ -0,0 +1,140 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import { resolve } from "node:path"; +import type { LocalContext } from "../../../context"; +import { createWriter } from "../../../lib/writer"; + +const repoRoot = resolve(import.meta.dir, "../../../.."); +const restModulePath = resolve( + repoRoot, + "src/rest/monitor-mute/get-monitor-mute.ts", +); + +const loadConfigFn = mock(() => ({ + customerId: "test-customer", + token: "test-token", + domain: "observeinc.com", +})); + +const getMonitorMuteFn = mock((_args: unknown) => + Promise.resolve({ + id: "mute-1", + label: "Snooze checkout", + description: "noisy deploy", + target: { + kind: "Monitors", + monitors: [{ id: "42", record: { label: "Checkout latency" } }], + }, + schedule: { kind: "OneTime", oneTime: { startTime: "t", endTime: "u" } }, + filter: null, + startTime: "t", + endTime: "u", + createdBy: { id: "u1", label: "Ada" }, + createdAt: "t", + updatedBy: { id: "u1", label: "Ada" }, + updatedAt: "t", + }), +); + +let view: (typeof import("./view"))["view"]; + +let previousNoColor: string | undefined; +let previousForceColor: string | undefined; + +const deps = { + loadConfig: loadConfigFn, +} as Parameters<(typeof import("./view"))["view"]>[2]; + +beforeAll(async () => { + previousNoColor = process.env.NO_COLOR; + previousForceColor = process.env.FORCE_COLOR; + process.env.NO_COLOR = "1"; + process.env.FORCE_COLOR = "0"; + + void mock.module(restModulePath, () => ({ + getMonitorMute: getMonitorMuteFn, + })); + + const mod = await import("./view.ts"); + view = mod.view; +}); + +afterAll(() => { + mock.restore(); + if (previousNoColor === undefined) { + delete process.env.NO_COLOR; + } else { + process.env.NO_COLOR = previousNoColor; + } + if (previousForceColor === undefined) { + delete process.env.FORCE_COLOR; + } else { + process.env.FORCE_COLOR = previousForceColor; + } +}); + +function createMockContext() { + const stdout: string[] = []; + const stderr: string[] = []; + let exitCode: number | undefined; + + const processMock = { + stdout: { + write: (msg: string) => { + stdout.push(msg); + return true; + }, + }, + stderr: { + write: (msg: string) => { + stderr.push(msg); + return true; + }, + }, + exit: (code?: number) => { + exitCode = code ?? 0; + throw new Error("process.exit"); + }, + }; + + const context = { + process: processMock, + writer: createWriter({ process: processMock }), + } as unknown as LocalContext; + + return { context, stdout, stderr, getExitCode: () => exitCode }; +} + +describe("monitor-mute view", () => { + beforeEach(() => { + loadConfigFn.mockClear(); + getMonitorMuteFn.mockClear(); + }); + + test("fetches by id and emits JSON", async () => { + const { context, stdout } = createMockContext(); + await view.call(context, { json: true }, "mute-1", deps); + + expect(getMonitorMuteFn).toHaveBeenCalledTimes(1); + const [args] = getMonitorMuteFn.mock.calls[0]!; + expect((args as { id: string }).id).toBe("mute-1"); + const output = JSON.parse(stdout.join("")); + expect(output.id).toBe("mute-1"); + }); + + test("renders the mute details in the default view", async () => { + const { context, stdout } = createMockContext(); + await view.call(context, {}, "mute-1", deps); + + const out = stdout.join(""); + expect(out).toContain("Monitor mute mute-1"); + expect(out).toContain("Snooze checkout"); + }); +}); diff --git a/src/commands/monitor/mute/view.ts b/src/commands/monitor/mute/view.ts new file mode 100644 index 0000000..108b1ce --- /dev/null +++ b/src/commands/monitor/mute/view.ts @@ -0,0 +1,127 @@ +import { buildCommand } from "@stricli/core"; +import chalk from "chalk"; +import type { LocalContext } from "../../../context"; +import { getMonitorMute } from "../../../rest/monitor-mute/get-monitor-mute"; +import { + type MonitorMuteResource, + MonitorMuteTargetKind, +} from "../../../rest/generated"; +import { loadConfig } from "../../../lib/config"; +import { formatApiError } from "../../../lib/format-error"; +import { muteStatusWriter } from "../../../lib/writer"; +import { renderObject } from "../../../lib/formatters/object"; +import { renderAsCSV } from "../../../lib/formatters/csv"; + +type OutputFormat = "json" | "csv"; + +interface ViewMonitorMuteFlags { + format?: OutputFormat; + json?: boolean; +} + +function buildViewData(mute: MonitorMuteResource) { + return { + id: mute.id, + label: mute.label, + description: mute.description ?? "-", + target: { + kind: mute.target.kind, + monitors: + mute.target.kind === MonitorMuteTargetKind.Monitors + ? mute.target.monitors.map((m) => ({ + id: m.id, + name: m.record?.label ?? "-", + })) + : [], + }, + schedule: mute.schedule, + filter: mute.filter ?? "-", + start: mute.startTime ?? "-", + end: mute.endTime ?? "open-ended", + createdBy: mute.createdBy.label ?? mute.createdBy.id, + createdAt: mute.createdAt, + updatedBy: mute.updatedBy.label ?? mute.updatedBy.id, + updatedAt: mute.updatedAt, + }; +} + +export interface ViewMonitorMuteDeps { + loadConfig?: typeof loadConfig; +} + +export async function view( + this: LocalContext, + flags: ViewMonitorMuteFlags, + id: string, + deps: ViewMonitorMuteDeps = {}, +): Promise { + const { loadConfig: loadConfigImpl = loadConfig } = deps; + const format = flags.json ? ("json" as const) : flags.format; + const { process, writer: _writer } = this; + const writer = muteStatusWriter(_writer, { + muted: format === "json" || format === "csv", + }); + + try { + const config = loadConfigImpl(); + + writer.info("Fetching monitor mute..."); + + const mute = await getMonitorMute({ config, id }); + + if (format === "json") { + writer.write(JSON.stringify(mute, null, 2)); + return; + } + + if (format === "csv") { + writer.write(renderAsCSV(mute)); + return; + } + + writer.write(""); + writer.write(chalk.bold.white(`Monitor mute ${mute.id}`)); + writer.write( + mute.target.kind === MonitorMuteTargetKind.Global + ? chalk.yellow("GLOBAL") + : chalk.cyan(`MONITORS (${mute.target.monitors.length})`), + ); + + renderObject(buildViewData(mute), (text) => writer.write(text)); + } catch (error) { + writer.error(`Error: ${await formatApiError(error)}`); + process.exit(1); + } +} + +export const viewCommand = buildCommand({ + loader: async () => view, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + brief: "Monitor mute rule ID", + parse: String, + }, + ], + }, + flags: { + format: { + kind: "enum", + values: ["json", "csv"], + brief: "Output format (json, csv)", + optional: true, + }, + json: { + kind: "boolean", + brief: "Output as JSON (shorthand for --format=json)", + optional: true, + }, + }, + aliases: {}, + }, + docs: { + brief: "View details of a monitor mute rule", + }, +}); diff --git a/src/rest/client.ts b/src/rest/client.ts index 0a663bc..819dcb1 100644 --- a/src/rest/client.ts +++ b/src/rest/client.ts @@ -4,6 +4,7 @@ import { Configuration, DatasetApi, ExportApi, + MonitorMuteApi, V2KnowledgeGraphApi, } from "./generated"; import { getApiBaseUrl, type Config } from "../lib/config"; @@ -21,6 +22,7 @@ export class ObserveRestSDK { public exportApi: ExportApi; public datasetApi: DatasetApi; public alertApi: AlertApi; + public monitorMuteApi: MonitorMuteApi; public knowledgeGraphApi: V2KnowledgeGraphApi; public skillsApi: SkillsApi; @@ -30,6 +32,7 @@ export class ObserveRestSDK { this.exportApi = new ExportApi(config); this.datasetApi = new DatasetApi(config); this.alertApi = new AlertApi(config); + this.monitorMuteApi = new MonitorMuteApi(config); this.knowledgeGraphApi = new V2KnowledgeGraphApi(config); this.skillsApi = new SkillsApi(config); } diff --git a/src/rest/monitor-mute/create-monitor-mute.ts b/src/rest/monitor-mute/create-monitor-mute.ts new file mode 100644 index 0000000..1d710ce --- /dev/null +++ b/src/rest/monitor-mute/create-monitor-mute.ts @@ -0,0 +1,19 @@ +import type { Config } from "../../lib/config"; +import type { + MonitorMuteCreateRequest, + MonitorMuteResource, +} from "../generated"; +import { ObserveRestSDK } from "../client"; + +export async function createMonitorMute({ + config, + body, +}: { + config: Config; + body: MonitorMuteCreateRequest; +}): Promise { + const sdk = new ObserveRestSDK(config); + return sdk.monitorMuteApi.createMonitorMute({ + monitorMuteCreateRequest: body, + }); +} diff --git a/src/rest/monitor-mute/delete-monitor-mute.ts b/src/rest/monitor-mute/delete-monitor-mute.ts new file mode 100644 index 0000000..215754c --- /dev/null +++ b/src/rest/monitor-mute/delete-monitor-mute.ts @@ -0,0 +1,13 @@ +import type { Config } from "../../lib/config"; +import { ObserveRestSDK } from "../client"; + +export async function deleteMonitorMute({ + config, + id, +}: { + config: Config; + id: string; +}): Promise { + const sdk = new ObserveRestSDK(config); + await sdk.monitorMuteApi.deleteMonitorMute({ id }); +} diff --git a/src/rest/monitor-mute/get-monitor-mute.ts b/src/rest/monitor-mute/get-monitor-mute.ts new file mode 100644 index 0000000..a4132f5 --- /dev/null +++ b/src/rest/monitor-mute/get-monitor-mute.ts @@ -0,0 +1,14 @@ +import type { Config } from "../../lib/config"; +import type { MonitorMuteResource } from "../generated"; +import { ObserveRestSDK } from "../client"; + +export async function getMonitorMute({ + config, + id, +}: { + config: Config; + id: string; +}): Promise { + const sdk = new ObserveRestSDK(config); + return sdk.monitorMuteApi.getMonitorMute({ id, expand: true }); +} diff --git a/src/rest/monitor-mute/list-monitor-mutes.ts b/src/rest/monitor-mute/list-monitor-mutes.ts new file mode 100644 index 0000000..ad3a55e --- /dev/null +++ b/src/rest/monitor-mute/list-monitor-mutes.ts @@ -0,0 +1,15 @@ +import type { Config } from "../../lib/config"; +import type { MonitorMuteApiListMonitorMutesRequest } from "../generated"; +import { ObserveRestSDK } from "../client"; + +export async function listMonitorMutes({ + config, + ...params +}: { config: Config } & Omit) { + const sdk = new ObserveRestSDK(config); + const response = await sdk.monitorMuteApi.listMonitorMutes({ + ...params, + expand: true, + }); + return response; +} diff --git a/src/rest/monitor-mute/update-monitor-mute.ts b/src/rest/monitor-mute/update-monitor-mute.ts new file mode 100644 index 0000000..36346e3 --- /dev/null +++ b/src/rest/monitor-mute/update-monitor-mute.ts @@ -0,0 +1,22 @@ +import type { Config } from "../../lib/config"; +import type { + MonitorMuteResource, + MonitorMuteUpdateRequest, +} from "../generated"; +import { ObserveRestSDK } from "../client"; + +export async function updateMonitorMute({ + config, + id, + body, +}: { + config: Config; + id: string; + body: MonitorMuteUpdateRequest; +}): Promise { + const sdk = new ObserveRestSDK(config); + return sdk.monitorMuteApi.updateMonitorMute({ + id, + monitorMuteUpdateRequest: body, + }); +}