diff --git a/AGENTS.md b/AGENTS.md index 0198696..ddff8ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,7 @@ src/ │ ├── datastream/ # Datastream commands (create, list, view, update) │ ├── ingest-token/ # Ingest token commands (create, list, view, update) │ ├── metric/ # Metric commands (list, view) +│ ├── monitor/ # Monitor V2 commands (list, get, preview-query, preview, alarms) │ ├── skill/ # AI agent skill commands (list, view) │ ├── tag-key/ # Tag key commands (list) │ ├── tag-value/ # Tag value commands (list) @@ -57,6 +58,7 @@ src/ │ ├── datastream/ # Datastream queries/mutations │ ├── ingest-token/ # Ingest token queries/mutations │ ├── metric/ # Metric queries +│ ├── monitor/ # Monitor V2 queries │ ├── workspace/ # Workspace queries │ ├── gql-request.ts # GraphQL client/executor │ └── gql-codegen.config.ts # Codegen configuration diff --git a/README.md b/README.md index a7f6baa..d70fd8c 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,11 @@ To update installed skills after edits in this repo, run `npx skills update`. | `observe datastream view` | View a datastream by ID | | `observe datastream update` | Update a datastream | | `observe datastream-token check-status` | Poll a datastream token until ingest data arrives | +| `observe monitor list` | List Monitor V2 resources | +| `observe monitor get` | Get a Monitor V2 by ID (includes its definition) | +| `observe monitor preview-query` | Compile a monitor input into its OPAL pipeline | +| `observe monitor preview` | Preview whether a monitor would fire against recent data | +| `observe monitor alarms` | Search Monitor V2 alarms | | `observe cli install` | Configure shell integration (PATH, completions) | | `observe cli uninstall` | Remove shell integration | | `observe cli upgrade` | Upgrade to the latest version | diff --git a/src/app.ts b/src/app.ts index a51b600..33d0ecb 100644 --- a/src/app.ts +++ b/src/app.ts @@ -12,6 +12,7 @@ import { datastreamRoutes } from "./commands/datastream/index.js"; import { helpCommand } from "./commands/help.js"; import { ingestTokenRoutes } from "./commands/ingest-token/index.js"; import { metricRoutes } from "./commands/metric/index.js"; +import { monitorRoutes } from "./commands/monitor/index.js"; import { queryCommand } from "./commands/query.js"; import { skillRoutes } from "./commands/skill/index.js"; import { tagKeyRoutes } from "./commands/tag-key/index.js"; @@ -36,6 +37,7 @@ export const routes = buildRouteMap({ "data-connection": dataConnectionRoutes, datastream: datastreamRoutes, "datastream-token": datastreamTokenRoutes, + monitor: monitorRoutes, cli: cliRoutes, }, defaultCommand: "help", diff --git a/src/commands/monitor/alarms.test.ts b/src/commands/monitor/alarms.test.ts new file mode 100644 index 0000000..31d5a5b --- /dev/null +++ b/src/commands/monitor/alarms.test.ts @@ -0,0 +1,162 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import type { LocalContext } from "../../context"; +import { createWriter } from "../../lib/writer"; + +const loadConfigFn = mock(() => ({ + customerId: "test-customer", + token: "test-token", + domain: "observeinc.com", +})); + +const searchAlarmsFn = mock((_config: unknown, _variables: unknown) => + Promise.resolve([ + { + id: "alarm-1", + level: "Critical", + isActive: true, + start: "2024-01-01T00:00:00Z", + end: null, + detectedStart: "2024-01-01T00:00:00Z", + detectedEnd: null, + monitor: { id: "mon-1", name: "CPU high" }, + }, + { + id: "alarm-2", + level: "Warning", + isActive: false, + start: "2024-01-01T00:00:00Z", + end: "2024-01-01T01:00:00Z", + detectedStart: "2024-01-01T00:00:00Z", + detectedEnd: "2024-01-01T01:00:00Z", + monitor: { id: "mon-2", name: "Disk" }, + }, + ]), +); + +const fixedNow = () => new Date("2024-01-02T00:00:00Z"); + +const deps = { + loadConfig: loadConfigFn, + searchMonitorAlarms: searchAlarmsFn, + now: fixedNow, +} as unknown as Parameters<(typeof import("./alarms"))["alarms"]>[1]; + +let previousNoColor: string | undefined; +let previousForceColor: string | undefined; + +beforeAll(() => { + previousNoColor = process.env.NO_COLOR; + previousForceColor = process.env.FORCE_COLOR; + process.env.NO_COLOR = "1"; + process.env.FORCE_COLOR = "0"; +}); + +afterAll(() => { + 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 alarms", () => { + let alarms: (typeof import("./alarms"))["alarms"]; + + beforeAll(async () => { + alarms = (await import("./alarms")).alarms; + }); + + beforeEach(() => { + loadConfigFn.mockClear(); + searchAlarmsFn.mockClear(); + }); + + test("projects rows with derived monitorId/status/startTime/endTime (JSON)", async () => { + const { context, stdout } = createMockContext(); + await alarms.call(context, { json: true, since: 86_400_000 }, deps); + + const output = JSON.parse(stdout.join("")); + expect(output).toHaveLength(2); + expect(output[0]).toEqual({ + id: "alarm-1", + monitorId: "mon-1", + level: "Critical", + status: "Active", + startTime: "2024-01-01T00:00:00Z", + endTime: null, + }); + expect(output[1].status).toBe("Ended"); + }); + + test("computes minTime from since and forwards monitorId + level", async () => { + const { context } = createMockContext(); + await alarms.call( + context, + { json: true, since: 86_400_000, monitorId: "mon-9", level: "Critical" }, + deps, + ); + const [, variables] = searchAlarmsFn.mock.calls[0]!; + expect(variables).toMatchObject({ + monitorIds: ["mon-9"], + levels: ["Critical"], + minTime: "2024-01-01T00:00:00.000Z", + }); + }); + + test("warns when no alarms (table mode)", async () => { + searchAlarmsFn.mockImplementationOnce(() => Promise.resolve([])); + const { context, stdout } = createMockContext(); + await alarms.call(context, { since: 86_400_000 }, deps); + expect(stdout.join("")).toContain("No alarms found"); + }); + + test("exits 1 on error", async () => { + searchAlarmsFn.mockImplementationOnce(() => { + throw new Error("boom"); + }); + const { context, getExitCode } = createMockContext(); + try { + await alarms.call(context, { since: 86_400_000 }, deps); + throw new Error("expected exit"); + } catch (e) { + expect((e as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + }); +}); diff --git a/src/commands/monitor/alarms.ts b/src/commands/monitor/alarms.ts new file mode 100644 index 0000000..57ee704 --- /dev/null +++ b/src/commands/monitor/alarms.ts @@ -0,0 +1,204 @@ +import { buildCommand } from "@stricli/core"; +import chalk from "chalk"; +import type { LocalContext } from "../../context"; +import { + searchMonitorAlarms, + type GqlMonitorAlarm, +} from "../../gql/monitor/search-alarms"; +import { + type MonitorV2AlarmLevel, + MonitorV2AlarmLevel as AlarmLevel, +} from "../../gql/generated/graphql"; +import { GqlApiError } from "../../gql/gql-request"; +import { loadConfig } from "../../lib/config"; +import { muteStatusWriter } from "../../lib/writer"; +import { + formatTable, + createColumnHelper, + type ColumnDef, +} from "../../lib/formatters/table"; +import { renderAsCSV } from "../../lib/formatters/csv"; +import { parseDurationMs } from "./duration"; +import { timeToDisplay } from "./format"; + +type OutputFormat = "json" | "csv"; + +interface AlarmsFlags { + workspace?: string; + monitorId?: string; + since: number; + level?: MonitorV2AlarmLevel; + format?: OutputFormat; + json?: boolean; +} + +export interface AlarmsDeps { + loadConfig?: typeof loadConfig; + searchMonitorAlarms?: typeof searchMonitorAlarms; + now?: () => Date; +} + +/** searchMonitorV2Alarms has no `status`; derive it from the `isActive` flag. */ +function alarmStatus(alarm: GqlMonitorAlarm): string { + return alarm.isActive ? "Active" : "Ended"; +} + +const col = createColumnHelper(); + +const COLUMNS: ColumnDef[] = [ + col.accessor((row) => row.id, { header: "ID", format: (v) => chalk.cyan(v) }), + col.accessor((row) => row.monitor?.id ?? "-", { header: "MONITOR ID" }), + col.accessor((row) => row.level, { header: "LEVEL" }), + col.accessor((row) => row, { + header: "STATUS", + format: (_v, row) => { + const status = alarmStatus(row); + return status === "Active" ? chalk.green(status) : chalk.dim(status); + }, + }), + col.accessor((row) => timeToDisplay(row.start), { header: "START" }), + col.accessor((row) => timeToDisplay(row.end), { header: "END" }), +]; + +export async function alarms( + this: LocalContext, + flags: AlarmsFlags, + deps: AlarmsDeps = {}, +): Promise { + const { + loadConfig: loadConfigImpl = loadConfig, + searchMonitorAlarms: searchImpl = searchMonitorAlarms, + now = () => new Date(), + } = 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(); + + const minTime = + flags.since > 0 + ? new Date(now().getTime() - flags.since).toISOString() + : undefined; + + writer.info("Searching alarms..."); + + const result = await searchImpl(config, { + workspaceId: flags.workspace, + monitorIds: flags.monitorId ? [flags.monitorId] : undefined, + minTime, + levels: flags.level ? [flags.level] : undefined, + }); + + // Project the search result into the brief's flat column contract so JSON + // and CSV consumers see `monitorId`/`status`/`startTime`/`endTime` even + // though the underlying type nests/derives them. + const rows = result.map((a) => ({ + id: a.id, + monitorId: a.monitor?.id ?? null, + level: a.level, + status: alarmStatus(a), + startTime: a.start, + endTime: a.end, + })); + + if (format === "json") { + writer.write(JSON.stringify(rows, null, 2)); + return; + } + + if (format === "csv") { + writer.write(renderAsCSV(rows)); + return; + } + + if (result.length === 0) { + writer.warn("No alarms found."); + return; + } + + writer.write(chalk.green(`Found ${result.length} alarm(s):\n`)); + writer.write(formatTable(result, COLUMNS)); + } catch (error) { + if (error instanceof GqlApiError) { + writer.error(`API Error (${error.statusCode}): ${error.message}`); + } else { + const message = error instanceof Error ? error.message : String(error); + writer.error(`Error: ${message}`); + } + process.exit(1); + } +} + +const LEVEL_BY_INPUT: Record = { + critical: AlarmLevel.Critical, + error: AlarmLevel.Error, + warning: AlarmLevel.Warning, + informational: AlarmLevel.Informational, +}; + +function parseLevel(value: string): MonitorV2AlarmLevel { + const level = LEVEL_BY_INPUT[value.toLowerCase()]; + if (!level) { + throw new Error( + `Invalid level "${value}". Expected one of: ${Object.keys(LEVEL_BY_INPUT).join(", ")}`, + ); + } + return level; +} + +const DEFAULT_SINCE = "24h"; + +export const alarmsCommand = buildCommand({ + loader: async () => alarms, + parameters: { + positional: { + kind: "tuple", + parameters: [], + }, + flags: { + workspace: { + kind: "parsed", + parse: String, + brief: "Workspace ID to scope the search", + optional: true, + }, + monitorId: { + kind: "parsed", + parse: String, + brief: "Monitor ID to filter alarms", + optional: true, + }, + since: { + kind: "parsed", + parse: parseDurationMs, + brief: "Duration to look back for alarms (e.g. 24h, 7d)", + default: DEFAULT_SINCE, + }, + level: { + kind: "parsed", + parse: parseLevel, + brief: "Alarm level filter: critical|error|warning|informational", + 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, + }, + }, + aliases: {}, + }, + docs: { + brief: "Search Monitor V2 alarms", + }, +}); diff --git a/src/commands/monitor/duration.ts b/src/commands/monitor/duration.ts new file mode 100644 index 0000000..16bd38f --- /dev/null +++ b/src/commands/monitor/duration.ts @@ -0,0 +1,24 @@ +/** + * Parse a lookback duration string into milliseconds. Mirrors the Go CLI's + * `--since` semantics (Go-style durations), extended with `d` for days which + * the Go flag also accepted. Supported units: `s`, `m`, `h`, `d`. A bare + * number is treated as seconds. + */ +const UNIT_MS: Record = { + s: 1000, + m: 60_000, + h: 3_600_000, + d: 86_400_000, +}; + +export function parseDurationMs(value: string): number { + const match = /^(\d+)\s*([smhd])?$/.exec(value.trim()); + if (!match) { + throw new Error( + `Invalid duration "${value}". Use a number with an optional unit s|m|h|d (e.g. 24h, 7d).`, + ); + } + const amount = Number(match[1]); + const unit = match[2] ?? "s"; + return amount * (UNIT_MS[unit] ?? 1000); +} diff --git a/src/commands/monitor/format.ts b/src/commands/monitor/format.ts new file mode 100644 index 0000000..9b70c5e --- /dev/null +++ b/src/commands/monitor/format.ts @@ -0,0 +1,8 @@ +/** + * Render a GraphQL `Time` scalar (typed as `unknown` by codegen, but always an + * ISO-8601 string at runtime) for display. Non-string values fall back to a + * placeholder rather than `[object Object]`. + */ +export function timeToDisplay(value: unknown, fallback = "-"): string { + return typeof value === "string" ? value : fallback; +} diff --git a/src/commands/monitor/get.test.ts b/src/commands/monitor/get.test.ts new file mode 100644 index 0000000..ed470c5 --- /dev/null +++ b/src/commands/monitor/get.test.ts @@ -0,0 +1,149 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import type { LocalContext } from "../../context"; +import { createWriter } from "../../lib/writer"; + +const loadConfigFn = mock(() => ({ + customerId: "c", + token: "t", + domain: "observeinc.com", +})); + +const monitor = { + id: "mon-1", + workspaceId: "ws-1", + name: "CPU high", + description: "watch cpu", + disabled: false, + ruleKind: "Count", + createdBy: "u-1", + createdDate: "2024-01-01T00:00:00Z", + updatedDate: "2024-01-02T00:00:00Z", + definition: { + lookbackTime: "1h", + dataStabilizationDelay: null, + maxAlertsPerHour: "100", + inputQuery: { + outputStage: "s1", + stages: [{ id: "s1", pipeline: "filter true" }], + }, + rules: [ + { + level: "Critical", + count: { compareValues: [] }, + threshold: null, + promote: null, + anomaly: null, + }, + ], + groupings: [ + { columnPath: { name: "region", path: null }, linkColumn: null }, + ], + }, +}; + +const getMonitorFn = mock((_config: unknown, _id: string) => + Promise.resolve(monitor), +); + +const deps = { + loadConfig: loadConfigFn, + getMonitor: getMonitorFn, +} as unknown as Parameters<(typeof import("./get"))["get"]>[2]; + +let previousNoColor: string | undefined; +let previousForceColor: string | undefined; + +beforeAll(() => { + previousNoColor = process.env.NO_COLOR; + previousForceColor = process.env.FORCE_COLOR; + process.env.NO_COLOR = "1"; + process.env.FORCE_COLOR = "0"; +}); + +afterAll(() => { + 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 get", () => { + let get: (typeof import("./get"))["get"]; + + beforeAll(async () => { + get = (await import("./get")).get; + }); + + beforeEach(() => { + loadConfigFn.mockClear(); + getMonitorFn.mockClear(); + }); + + test("outputs the full monitor (including definition) as JSON", async () => { + const { context, stdout } = createMockContext(); + await get.call(context, { json: true }, "mon-1", deps); + expect(getMonitorFn).toHaveBeenCalledWith(expect.anything(), "mon-1"); + const output = JSON.parse(stdout.join("")); + expect(output.id).toBe("mon-1"); + expect(output.definition.rules[0].level).toBe("Critical"); + }); + + test("renders a human summary by default", async () => { + const { context, stdout } = createMockContext(); + await get.call(context, {}, "mon-1", deps); + const out = stdout.join(""); + expect(out).toContain("Monitor mon-1"); + expect(out).toContain("CPU high"); + }); + + test("exits 1 on error", async () => { + getMonitorFn.mockImplementationOnce(() => { + throw new Error("boom"); + }); + const { context, getExitCode } = createMockContext(); + try { + await get.call(context, {}, "mon-1", deps); + throw new Error("expected exit"); + } catch (e) { + expect((e as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + }); +}); diff --git a/src/commands/monitor/get.ts b/src/commands/monitor/get.ts new file mode 100644 index 0000000..23a2623 --- /dev/null +++ b/src/commands/monitor/get.ts @@ -0,0 +1,141 @@ +import { buildCommand } from "@stricli/core"; +import chalk from "chalk"; +import type { LocalContext } from "../../context"; +import { getMonitor, type GqlMonitor } from "../../gql/monitor/get-monitor"; +import { GqlApiError } from "../../gql/gql-request"; +import { loadConfig } from "../../lib/config"; +import { muteStatusWriter } from "../../lib/writer"; +import { renderObject } from "../../lib/formatters/object"; +import { renderAsCSV } from "../../lib/formatters/csv"; +import { timeToDisplay } from "./format"; + +type OutputFormat = "json" | "csv"; + +interface GetMonitorFlags { + format?: OutputFormat; + json?: boolean; +} + +export interface GetMonitorDeps { + loadConfig?: typeof loadConfig; + getMonitor?: typeof getMonitor; +} + +interface Column { + columnPath?: { name: string; path?: string | null } | null; + linkColumn?: { name: string } | null; +} + +function columnName(column: Column): string { + return column.columnPath?.name ?? column.linkColumn?.name ?? "?"; +} + +/** Build a flattened, human-readable view of a monitor for default output. */ +function buildViewData(monitor: GqlMonitor) { + const def = monitor.definition; + return { + id: monitor.id, + name: monitor.name, + description: monitor.description ?? "-", + disabled: monitor.disabled ?? false, + ruleKind: monitor.ruleKind, + workspaceId: monitor.workspaceId, + createdDate: timeToDisplay(monitor.createdDate), + updatedDate: timeToDisplay(monitor.updatedDate), + lookbackTime: def.lookbackTime ?? "-", + dataStabilizationDelay: def.dataStabilizationDelay ?? "-", + maxAlertsPerHour: def.maxAlertsPerHour ?? "-", + groupings: (def.groupings ?? []).map(columnName).join(", ") || "-", + rules: def.rules.map((rule) => ({ + level: rule.level, + kind: rule.count + ? "count" + : rule.threshold + ? "threshold" + : rule.promote + ? "promote" + : rule.anomaly + ? "anomaly" + : "-", + })), + }; +} + +export async function get( + this: LocalContext, + flags: GetMonitorFlags, + id: string, + deps: GetMonitorDeps = {}, +): Promise { + const { + loadConfig: loadConfigImpl = loadConfig, + getMonitor: getMonitorImpl = getMonitor, + } = 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..."); + + const monitor = await getMonitorImpl(config, id); + + if (format === "json") { + writer.write(JSON.stringify(monitor, null, 2)); + return; + } + + if (format === "csv") { + writer.write(renderAsCSV(monitor)); + return; + } + + writer.write(""); + writer.write(chalk.bold.white(`Monitor ${monitor.id}`)); + renderObject(buildViewData(monitor), (text) => writer.write(text)); + } catch (error) { + if (error instanceof GqlApiError) { + writer.error(`API Error (${error.statusCode}): ${error.message}`); + } else { + const message = error instanceof Error ? error.message : String(error); + writer.error(`Error: ${message}`); + } + process.exit(1); + } +} + +export const getCommand = buildCommand({ + loader: async () => get, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + brief: "Monitor 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: "Get a Monitor V2 by ID (includes its definition)", + }, +}); diff --git a/src/commands/monitor/index.ts b/src/commands/monitor/index.ts new file mode 100644 index 0000000..1f77b64 --- /dev/null +++ b/src/commands/monitor/index.ts @@ -0,0 +1,29 @@ +import { buildRouteMap } from "@stricli/core"; +import { listCommand } from "./list"; +import { getCommand } from "./get"; +import { previewQueryCommand } from "./preview-query"; +import { previewCommand } from "./preview"; +import { alarmsCommand } from "./alarms"; + +export const monitorRoutes = buildRouteMap({ + routes: { + list: listCommand, + get: getCommand, + "preview-query": previewQueryCommand, + preview: previewCommand, + alarms: alarmsCommand, + }, + docs: { + brief: "Query Monitor V2 resources", + fullDescription: [ + "Query Monitor V2 resources in Observe.", + "", + "Commands:", + " list List monitors (optionally filtered by name substring)", + " get Get a monitor by ID, including its definition", + " preview-query Compile a monitor input into its OPAL pipeline", + " preview Preview whether a monitor would fire against recent data", + " alarms Search monitor alarms", + ].join("\n"), + }, +}); diff --git a/src/commands/monitor/list.test.ts b/src/commands/monitor/list.test.ts new file mode 100644 index 0000000..162d868 --- /dev/null +++ b/src/commands/monitor/list.test.ts @@ -0,0 +1,144 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import type { LocalContext } from "../../context"; +import { createWriter } from "../../lib/writer"; + +const loadConfigFn = mock(() => ({ + customerId: "test-customer", + token: "test-token", + domain: "observeinc.com", +})); + +const searchMonitorsFn = mock((_config: unknown, _variables: unknown) => + Promise.resolve([ + { + id: "mon-1", + name: "CPU high", + description: "", + disabled: false, + updatedDate: "2024-01-01T00:00:00Z", + }, + { + id: "mon-2", + name: "Disk full", + description: "", + disabled: true, + updatedDate: "2024-01-02T00:00:00Z", + }, + ]), +); + +const deps = { + loadConfig: loadConfigFn, + searchMonitors: searchMonitorsFn, +} as unknown as Parameters<(typeof import("./list"))["list"]>[2]; + +let previousNoColor: string | undefined; +let previousForceColor: string | undefined; + +beforeAll(() => { + previousNoColor = process.env.NO_COLOR; + previousForceColor = process.env.FORCE_COLOR; + process.env.NO_COLOR = "1"; + process.env.FORCE_COLOR = "0"; +}); + +afterAll(() => { + 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 list", () => { + let list: (typeof import("./list"))["list"]; + + beforeAll(async () => { + list = (await import("./list")).list; + }); + + beforeEach(() => { + loadConfigFn.mockClear(); + searchMonitorsFn.mockClear(); + }); + + test("outputs JSON of monitors", async () => { + const { context, stdout } = createMockContext(); + await list.call(context, { json: true }, undefined, deps); + + expect(searchMonitorsFn).toHaveBeenCalledTimes(1); + const output = JSON.parse(stdout.join("")); + expect(output).toHaveLength(2); + expect(output[0].name).toBe("CPU high"); + }); + + test("passes name substring and workspace as variables", async () => { + const { context } = createMockContext(); + await list.call(context, { json: true, workspace: "ws-1" }, "cpu", deps); + + const [, variables] = searchMonitorsFn.mock.calls[0]!; + expect(variables).toEqual({ workspaceId: "ws-1", nameSubstring: "cpu" }); + }); + + test("renders a table by default", async () => { + const { context, stdout } = createMockContext(); + await list.call(context, {}, undefined, deps); + const out = stdout.join(""); + expect(out).toContain("CPU high"); + expect(out).toContain("ID"); + }); + + test("exits 1 on API error", async () => { + searchMonitorsFn.mockImplementationOnce(() => { + throw new Error("boom"); + }); + const { context, getExitCode, stderr } = createMockContext(); + try { + await list.call(context, {}, undefined, deps); + throw new Error("expected exit"); + } catch (e) { + expect((e as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Error"); + }); +}); diff --git a/src/commands/monitor/list.ts b/src/commands/monitor/list.ts new file mode 100644 index 0000000..2aa53b9 --- /dev/null +++ b/src/commands/monitor/list.ts @@ -0,0 +1,144 @@ +import { buildCommand } from "@stricli/core"; +import chalk from "chalk"; +import type { LocalContext } from "../../context"; +import { + searchMonitors, + type GqlMonitorSummary, +} from "../../gql/monitor/search-monitors"; +import { GqlApiError } from "../../gql/gql-request"; +import { loadConfig } from "../../lib/config"; +import { muteStatusWriter } from "../../lib/writer"; +import { + formatTable, + createColumnHelper, + type ColumnDef, +} from "../../lib/formatters/table"; +import { renderAsCSV } from "../../lib/formatters/csv"; +import { timeToDisplay } from "./format"; + +type OutputFormat = "json" | "csv"; + +interface ListMonitorsFlags { + workspace?: string; + format?: OutputFormat; + json?: boolean; +} + +export interface ListMonitorsDeps { + loadConfig?: typeof loadConfig; + searchMonitors?: typeof searchMonitors; +} + +const col = createColumnHelper(); + +const COLUMNS: ColumnDef[] = [ + col.accessor((row) => row.id, { + header: "ID", + format: (value) => chalk.cyan(value), + }), + col.accessor((row) => row.name, { + header: "NAME", + flex: true, + }), + col.accessor((row) => row.disabled ?? false, { + header: "DISABLED", + format: (value) => (value ? chalk.yellow("Yes") : chalk.dim("No")), + }), + col.accessor((row) => row.updatedDate, { + header: "UPDATED", + format: (value) => timeToDisplay(value), + }), +]; + +export async function list( + this: LocalContext, + flags: ListMonitorsFlags, + nameSubstring: string | undefined, + deps: ListMonitorsDeps = {}, +): Promise { + const { + loadConfig: loadConfigImpl = loadConfig, + searchMonitors: searchMonitorsImpl = searchMonitors, + } = 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("Searching monitors..."); + + const monitors = await searchMonitorsImpl(config, { + workspaceId: flags.workspace, + nameSubstring, + }); + + if (format === "json") { + writer.write(JSON.stringify(monitors, null, 2)); + return; + } + + if (format === "csv") { + writer.write(renderAsCSV(monitors)); + return; + } + + if (monitors.length === 0) { + writer.warn("No monitors found."); + return; + } + + writer.write(chalk.green(`Found ${monitors.length} monitor(s):\n`)); + writer.write(formatTable(monitors, COLUMNS)); + } catch (error) { + if (error instanceof GqlApiError) { + writer.error(`API Error (${error.statusCode}): ${error.message}`); + } else { + const message = error instanceof Error ? error.message : String(error); + writer.error(`Error: ${message}`); + } + process.exit(1); + } +} + +export const listCommand = buildCommand({ + loader: async () => list, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + brief: "Filter monitors by name substring (case-insensitive)", + parse: String, + optional: true, + }, + ], + }, + flags: { + workspace: { + kind: "parsed", + parse: String, + brief: "Workspace ID to scope the search", + 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, + }, + }, + aliases: {}, + }, + docs: { + brief: "List Monitor V2 resources", + }, +}); diff --git a/src/commands/monitor/preview-query.test.ts b/src/commands/monitor/preview-query.test.ts new file mode 100644 index 0000000..3d41c43 --- /dev/null +++ b/src/commands/monitor/preview-query.test.ts @@ -0,0 +1,133 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import type { LocalContext } from "../../context"; +import { createWriter } from "../../lib/writer"; + +const loadConfigFn = mock(() => ({ + customerId: "c", + token: "t", + domain: "observeinc.com", +})); + +const readInputFn = mock((_path: string) => ({ + name: "m", + ruleKind: "Count", + definition: {}, +})); + +const evaluateFn = mock((_config: unknown, _input: unknown) => + Promise.resolve({ + query: { + outputStage: "stage-1", + stages: [ + { id: "stage-0", pipeline: "filter true" }, + { id: "stage-1", pipeline: "statsby count: count()" }, + ], + }, + }), +); + +const deps = { + loadConfig: loadConfigFn, + evaluateMonitorSource: evaluateFn, + readMonitorInput: readInputFn, +} as unknown as Parameters< + (typeof import("./preview-query"))["previewQuery"] +>[2]; + +let previousNoColor: string | undefined; +let previousForceColor: string | undefined; + +beforeAll(() => { + previousNoColor = process.env.NO_COLOR; + previousForceColor = process.env.FORCE_COLOR; + process.env.NO_COLOR = "1"; + process.env.FORCE_COLOR = "0"; +}); + +afterAll(() => { + 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 preview-query", () => { + let previewQuery: (typeof import("./preview-query"))["previewQuery"]; + + beforeAll(async () => { + previewQuery = (await import("./preview-query")).previewQuery; + }); + + beforeEach(() => { + loadConfigFn.mockClear(); + readInputFn.mockClear(); + evaluateFn.mockClear(); + }); + + test("prints output stage and joined pipeline", async () => { + const { context, stdout } = createMockContext(); + await previewQuery.call(context, {}, "in.json", deps); + const out = stdout.join(""); + expect(out).toContain("Output stage: stage-1"); + expect(out).toContain("statsby count: count()"); + expect(out).toContain("// stage stage-0"); + }); + + test("emits raw evaluation as JSON", async () => { + const { context, stdout } = createMockContext(); + await previewQuery.call(context, { json: true }, "in.json", deps); + const output = JSON.parse(stdout.join("")); + expect(output.query.outputStage).toBe("stage-1"); + }); + + test("exits 1 on API error", async () => { + evaluateFn.mockImplementationOnce(() => { + throw new Error("boom"); + }); + const { context, getExitCode } = createMockContext(); + try { + await previewQuery.call(context, {}, "in.json", deps); + throw new Error("expected exit"); + } catch (e) { + expect((e as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + }); +}); diff --git a/src/commands/monitor/preview-query.ts b/src/commands/monitor/preview-query.ts new file mode 100644 index 0000000..6cf4f63 --- /dev/null +++ b/src/commands/monitor/preview-query.ts @@ -0,0 +1,118 @@ +import { buildCommand } from "@stricli/core"; +import chalk from "chalk"; +import type { LocalContext } from "../../context"; +import { + evaluateMonitorSource, + type GqlSourceEvaluation, +} from "../../gql/monitor/evaluate-source"; +import { GqlApiError } from "../../gql/gql-request"; +import { loadConfig } from "../../lib/config"; +import { muteStatusWriter } from "../../lib/writer"; +import { readMonitorInput } from "./read-input"; + +type OutputFormat = "json" | "csv"; + +interface PreviewQueryFlags { + format?: OutputFormat; + json?: boolean; +} + +export interface PreviewQueryDeps { + loadConfig?: typeof loadConfig; + evaluateMonitorSource?: typeof evaluateMonitorSource; + readMonitorInput?: typeof readMonitorInput; +} + +/** Join the per-stage OPAL pipelines into a single readable program. */ +function renderPipeline(evaluation: GqlSourceEvaluation): string { + return evaluation.query.stages + .map((stage) => `// stage ${stage.id}\n${stage.pipeline}`) + .join("\n\n"); +} + +export async function previewQuery( + this: LocalContext, + flags: PreviewQueryFlags, + file: string, + deps: PreviewQueryDeps = {}, +): Promise { + const { + loadConfig: loadConfigImpl = loadConfig, + evaluateMonitorSource: evaluateImpl = evaluateMonitorSource, + readMonitorInput: readInputImpl = readMonitorInput, + } = 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(); + const input = readInputImpl(file); + + writer.info("Evaluating monitor source..."); + + const evaluation = await evaluateImpl(config, input); + + if (format === "json") { + writer.write(JSON.stringify(evaluation, null, 2)); + return; + } + + if (format === "csv") { + writer.write( + evaluation.query.stages + .map((stage) => ({ stage: stage.id, pipeline: stage.pipeline })) + .map((row) => `${row.stage},${JSON.stringify(row.pipeline)}`) + .join("\n") + "\n", + ); + return; + } + + writer.write(chalk.bold(`Output stage: ${evaluation.query.outputStage}`)); + writer.write(""); + writer.write(chalk.bold("Pipeline:")); + writer.write(renderPipeline(evaluation)); + } catch (error) { + if (error instanceof GqlApiError) { + writer.error(`API Error (${error.statusCode}): ${error.message}`); + } else { + const message = error instanceof Error ? error.message : String(error); + writer.error(`Error: ${message}`); + } + process.exit(1); + } +} + +export const previewQueryCommand = buildCommand({ + loader: async () => previewQuery, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + brief: "Path to a MonitorV2Input JSON file", + 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: "Compile a monitor input into its OPAL pipeline + result schema", + }, +}); diff --git a/src/commands/monitor/preview.test.ts b/src/commands/monitor/preview.test.ts new file mode 100644 index 0000000..35f8036 --- /dev/null +++ b/src/commands/monitor/preview.test.ts @@ -0,0 +1,165 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import type { LocalContext } from "../../context"; +import { createWriter } from "../../lib/writer"; + +const loadConfigFn = mock(() => ({ + customerId: "c", + token: "t", + domain: "observeinc.com", +})); + +const readInputFn = mock((_path: string) => ({ + name: "m", + ruleKind: "Count", + definition: {}, +})); + +const previewFn = mock( + (_config: unknown, _input: unknown, _params: unknown, _ws?: string) => + Promise.resolve({ + stabilityBookmarkTime: "2024-01-02T00:00:00Z", + alarms: [ + { + id: "a-1", + level: "Critical", + start: "2024-01-01T00:00:00Z", + end: null, + isActive: true, + capturedValues: [], + context: [ + { + value: "us-east-1", + column: { + columnPath: { name: "region", path: null }, + linkColumn: null, + }, + }, + ], + }, + ], + }), +); + +const fixedNow = () => new Date("2024-01-02T00:00:00Z"); + +const deps = { + loadConfig: loadConfigFn, + previewMonitor: previewFn, + readMonitorInput: readInputFn, + now: fixedNow, +} as unknown as Parameters<(typeof import("./preview"))["preview"]>[2]; + +let previousNoColor: string | undefined; +let previousForceColor: string | undefined; + +beforeAll(() => { + previousNoColor = process.env.NO_COLOR; + previousForceColor = process.env.FORCE_COLOR; + process.env.NO_COLOR = "1"; + process.env.FORCE_COLOR = "0"; +}); + +afterAll(() => { + 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 preview", () => { + let preview: (typeof import("./preview"))["preview"]; + + beforeAll(async () => { + preview = (await import("./preview")).preview; + }); + + beforeEach(() => { + loadConfigFn.mockClear(); + readInputFn.mockClear(); + previewFn.mockClear(); + }); + + test("prints Would fire: true and sample groupings", async () => { + const { context, stdout } = createMockContext(); + await preview.call(context, { since: 3_600_000 }, "in.json", deps); + const out = stdout.join(""); + expect(out).toContain("Would fire: true"); + expect(out).toContain("region=us-east-1"); + }); + + test("computes params time window from since + now and forwards workspace", async () => { + const { context } = createMockContext(); + await preview.call( + context, + { since: 3_600_000, workspace: "ws-1" }, + "in.json", + deps, + ); + const [, , params, ws] = previewFn.mock.calls[0]!; + expect(ws).toBe("ws-1"); + expect(params).toEqual({ + startTime: "2024-01-01T23:00:00.000Z", + endTime: "2024-01-02T00:00:00.000Z", + }); + }); + + test("Would fire: false when no alarms", async () => { + previewFn.mockImplementationOnce(() => + Promise.resolve({ stabilityBookmarkTime: "x", alarms: [] }), + ); + const { context, stdout } = createMockContext(); + await preview.call(context, { since: 3_600_000 }, "in.json", deps); + expect(stdout.join("")).toContain("Would fire: false"); + }); + + test("exits 1 when input file cannot be read", async () => { + readInputFn.mockImplementationOnce(() => { + throw new Error("could not read file"); + }); + const { context, getExitCode } = createMockContext(); + try { + await preview.call(context, { since: 3_600_000 }, "missing.json", deps); + throw new Error("expected exit"); + } catch (e) { + expect((e as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + }); +}); diff --git a/src/commands/monitor/preview.ts b/src/commands/monitor/preview.ts new file mode 100644 index 0000000..9fbbec5 --- /dev/null +++ b/src/commands/monitor/preview.ts @@ -0,0 +1,177 @@ +import { buildCommand } from "@stricli/core"; +import chalk from "chalk"; +import type { LocalContext } from "../../context"; +import { + previewMonitor, + type GqlMonitorPreview, +} from "../../gql/monitor/preview-monitor"; +import { GqlApiError } from "../../gql/gql-request"; +import { loadConfig } from "../../lib/config"; +import { muteStatusWriter } from "../../lib/writer"; +import { parseDurationMs } from "./duration"; +import { readMonitorInput } from "./read-input"; +import { timeToDisplay } from "./format"; + +type OutputFormat = "json" | "csv"; + +interface PreviewFlags { + workspace?: string; + since: number; + format?: OutputFormat; + json?: boolean; +} + +export interface PreviewDeps { + loadConfig?: typeof loadConfig; + previewMonitor?: typeof previewMonitor; + readMonitorInput?: typeof readMonitorInput; + now?: () => Date; +} + +interface Column { + columnPath?: { name: string; path?: string | null } | null; + linkColumn?: { name: string } | null; +} + +function columnName(column: Column): string { + return column.columnPath?.name ?? column.linkColumn?.name ?? "?"; +} + +/** Render the grouping context for a single previewed alarm as `k=v` pairs. */ +function describeGroupings(alarm: GqlMonitorPreview["alarms"][number]): string { + const entries = + alarm.context.length > 0 ? alarm.context : alarm.capturedValues; + return entries + .map((entry) => `${columnName(entry.column)}=${entry.value ?? ""}`) + .join(" "); +} + +export async function preview( + this: LocalContext, + flags: PreviewFlags, + file: string, + deps: PreviewDeps = {}, +): Promise { + const { + loadConfig: loadConfigImpl = loadConfig, + previewMonitor: previewImpl = previewMonitor, + readMonitorInput: readInputImpl = readMonitorInput, + now = () => new Date(), + } = 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(); + const input = readInputImpl(file); + + const end = now(); + const start = new Date(end.getTime() - flags.since); + const params = { + startTime: start.toISOString(), + endTime: end.toISOString(), + }; + + writer.info("Previewing monitor..."); + + const result = await previewImpl(config, input, params, flags.workspace); + + if (format === "json") { + writer.write(JSON.stringify(result, null, 2)); + return; + } + + if (format === "csv") { + writer.write( + result.alarms + .map((a) => ({ + id: a.id, + level: a.level, + start: timeToDisplay(a.start, ""), + end: timeToDisplay(a.end, ""), + groupings: describeGroupings(a), + })) + .map( + (r) => + `${r.id},${r.level},${r.start},${r.end},${JSON.stringify(r.groupings)}`, + ) + .join("\n") + "\n", + ); + return; + } + + const wouldFire = result.alarms.length > 0; + writer.write( + `Would fire: ${wouldFire ? chalk.green("true") : chalk.dim("false")}`, + ); + + if (result.alarms.length > 0) { + writer.write(""); + writer.write(chalk.bold("Sample alarm groupings:")); + for (const alarm of result.alarms) { + const groupings = describeGroupings(alarm); + writer.write( + ` level=${alarm.level} start=${timeToDisplay(alarm.start)}` + + (groupings ? ` ${groupings}` : ""), + ); + } + } + } catch (error) { + if (error instanceof GqlApiError) { + writer.error(`API Error (${error.statusCode}): ${error.message}`); + } else { + const message = error instanceof Error ? error.message : String(error); + writer.error(`Error: ${message}`); + } + process.exit(1); + } +} + +const DEFAULT_SINCE = "1h"; + +export const previewCommand = buildCommand({ + loader: async () => preview, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + brief: "Path to a MonitorV2Input JSON file", + parse: String, + }, + ], + }, + flags: { + workspace: { + kind: "parsed", + parse: String, + brief: "Workspace ID to scope the preview", + optional: true, + }, + since: { + kind: "parsed", + parse: parseDurationMs, + brief: "Lookback window for the preview (e.g. 1h, 24h, 7d)", + default: DEFAULT_SINCE, + }, + 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: "Preview whether a monitor would fire against recent data", + }, +}); diff --git a/src/commands/monitor/read-input.ts b/src/commands/monitor/read-input.ts new file mode 100644 index 0000000..ada4373 --- /dev/null +++ b/src/commands/monitor/read-input.ts @@ -0,0 +1,25 @@ +import * as fs from "node:fs"; +import type { MonitorV2Input } from "../../gql/generated/graphql"; + +/** + * Read and parse a MonitorV2Input JSON file from disk. The minimal required + * shape is `{ name, ruleKind, definition }`; the GraphQL backend performs full + * validation, so this only enforces that the file exists and is valid JSON. + */ +export function readMonitorInput(filePath: string): MonitorV2Input { + let data: string; + try { + data = fs.readFileSync(filePath, "utf-8"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`could not read file "${filePath}": ${message}`, { + cause: error, + }); + } + + try { + return JSON.parse(data) as MonitorV2Input; + } catch { + throw new Error(`could not parse JSON from "${filePath}"`); + } +} diff --git a/src/gql/monitor/evaluate-source.graphql b/src/gql/monitor/evaluate-source.graphql new file mode 100644 index 0000000..963d145 --- /dev/null +++ b/src/gql/monitor/evaluate-source.graphql @@ -0,0 +1,11 @@ +query EvaluateMonitorV2Source($input: MonitorV2Input!) { + evaluateMonitorV2Source(input: $input) { + query { + outputStage + stages { + id + pipeline + } + } + } +} diff --git a/src/gql/monitor/evaluate-source.ts b/src/gql/monitor/evaluate-source.ts new file mode 100644 index 0000000..70c8c2e --- /dev/null +++ b/src/gql/monitor/evaluate-source.ts @@ -0,0 +1,22 @@ +import type { Config } from "../../lib/config"; +import { + EvaluateMonitorV2SourceDocument, + type EvaluateMonitorV2SourceQuery, + type MonitorV2Input, +} from "../generated/graphql"; +import { executeGraphQL } from "../gql-request"; + +export type GqlSourceEvaluation = + EvaluateMonitorV2SourceQuery["evaluateMonitorV2Source"]; + +export async function evaluateMonitorSource( + config: Config, + input: MonitorV2Input, +): Promise { + const response = await executeGraphQL( + config, + EvaluateMonitorV2SourceDocument, + { input }, + ); + return response.data.evaluateMonitorV2Source; +} diff --git a/src/gql/monitor/get-monitor.graphql b/src/gql/monitor/get-monitor.graphql new file mode 100644 index 0000000..53885ab --- /dev/null +++ b/src/gql/monitor/get-monitor.graphql @@ -0,0 +1,84 @@ +query GetMonitorV2($id: ObjectId!) { + monitorV2(id: $id) { + id + workspaceId + name + description + disabled + ruleKind + createdBy + createdDate + updatedDate + definition { + lookbackTime + dataStabilizationDelay + maxAlertsPerHour + inputQuery { + outputStage + stages { + id + pipeline + } + } + rules { + level + count { + compareValues { + compareFn + compareValue { + bool + float64 + int64 + string + timestamp + duration + } + } + } + threshold { + valueColumnName + aggregation + compareValues { + compareFn + compareValue { + bool + float64 + int64 + string + timestamp + duration + } + } + } + promote { + compareColumns { + column { + columnPath { + name + path + } + linkColumn { + name + } + } + compareValues { + compareFn + } + } + } + anomaly { + comparePercentage + } + } + groupings { + columnPath { + name + path + } + linkColumn { + name + } + } + } + } +} diff --git a/src/gql/monitor/get-monitor.ts b/src/gql/monitor/get-monitor.ts new file mode 100644 index 0000000..93a1516 --- /dev/null +++ b/src/gql/monitor/get-monitor.ts @@ -0,0 +1,16 @@ +import type { Config } from "../../lib/config"; +import { + GetMonitorV2Document, + type GetMonitorV2Query, +} from "../generated/graphql"; +import { executeGraphQL } from "../gql-request"; + +export type GqlMonitor = GetMonitorV2Query["monitorV2"]; + +export async function getMonitor( + config: Config, + id: string, +): Promise { + const response = await executeGraphQL(config, GetMonitorV2Document, { id }); + return response.data.monitorV2; +} diff --git a/src/gql/monitor/preview-monitor.graphql b/src/gql/monitor/preview-monitor.graphql new file mode 100644 index 0000000..bf448a4 --- /dev/null +++ b/src/gql/monitor/preview-monitor.graphql @@ -0,0 +1,41 @@ +query PreviewMonitorV2( + $workspaceId: ObjectId + $input: MonitorV2Input! + $params: QueryParams! +) { + previewMonitorV2(workspaceId: $workspaceId, input: $input, params: $params) { + stabilityBookmarkTime + alarms { + id + level + start + end + isActive + capturedValues { + types + value + column { + columnPath { + name + path + } + linkColumn { + name + } + } + } + context { + value + column { + columnPath { + name + path + } + linkColumn { + name + } + } + } + } + } +} diff --git a/src/gql/monitor/preview-monitor.ts b/src/gql/monitor/preview-monitor.ts new file mode 100644 index 0000000..a5b248c --- /dev/null +++ b/src/gql/monitor/preview-monitor.ts @@ -0,0 +1,24 @@ +import type { Config } from "../../lib/config"; +import { + PreviewMonitorV2Document, + type PreviewMonitorV2Query, + type MonitorV2Input, + type QueryParams, +} from "../generated/graphql"; +import { executeGraphQL } from "../gql-request"; + +export type GqlMonitorPreview = PreviewMonitorV2Query["previewMonitorV2"]; + +export async function previewMonitor( + config: Config, + input: MonitorV2Input, + params: QueryParams, + workspaceId?: string, +): Promise { + const response = await executeGraphQL(config, PreviewMonitorV2Document, { + workspaceId, + input, + params, + }); + return response.data.previewMonitorV2; +} diff --git a/src/gql/monitor/search-alarms.graphql b/src/gql/monitor/search-alarms.graphql new file mode 100644 index 0000000..5750b09 --- /dev/null +++ b/src/gql/monitor/search-alarms.graphql @@ -0,0 +1,29 @@ +query SearchMonitorV2Alarms( + $workspaceId: ObjectId + $monitorIds: [ObjectId!] + $minTime: Time + $maxTime: Time + $levels: [MonitorV2AlarmLevel!] +) { + searchMonitorV2Alarms( + workspaceId: $workspaceId + monitorIds: $monitorIds + minTime: $minTime + maxTime: $maxTime + levels: $levels + ) { + results { + id + level + isActive + start + end + detectedStart + detectedEnd + monitor { + id + name + } + } + } +} diff --git a/src/gql/monitor/search-alarms.ts b/src/gql/monitor/search-alarms.ts new file mode 100644 index 0000000..fa08c65 --- /dev/null +++ b/src/gql/monitor/search-alarms.ts @@ -0,0 +1,22 @@ +import type { Config } from "../../lib/config"; +import { + SearchMonitorV2AlarmsDocument, + type SearchMonitorV2AlarmsQuery, + type SearchMonitorV2AlarmsQueryVariables, +} from "../generated/graphql"; +import { executeGraphQL } from "../gql-request"; + +export type GqlMonitorAlarm = + SearchMonitorV2AlarmsQuery["searchMonitorV2Alarms"]["results"][number]; + +export async function searchMonitorAlarms( + config: Config, + variables?: SearchMonitorV2AlarmsQueryVariables, +): Promise { + const response = await executeGraphQL( + config, + SearchMonitorV2AlarmsDocument, + variables ?? {}, + ); + return response.data.searchMonitorV2Alarms.results; +} diff --git a/src/gql/monitor/search-monitors.graphql b/src/gql/monitor/search-monitors.graphql new file mode 100644 index 0000000..03bf7ec --- /dev/null +++ b/src/gql/monitor/search-monitors.graphql @@ -0,0 +1,11 @@ +query SearchMonitorV2($workspaceId: ObjectId, $nameSubstring: String) { + searchMonitorV2(workspaceId: $workspaceId, nameSubstring: $nameSubstring) { + results { + id + name + description + disabled + updatedDate + } + } +} diff --git a/src/gql/monitor/search-monitors.ts b/src/gql/monitor/search-monitors.ts new file mode 100644 index 0000000..9b8fcc8 --- /dev/null +++ b/src/gql/monitor/search-monitors.ts @@ -0,0 +1,22 @@ +import type { Config } from "../../lib/config"; +import { + SearchMonitorV2Document, + type SearchMonitorV2Query, + type SearchMonitorV2QueryVariables, +} from "../generated/graphql"; +import { executeGraphQL } from "../gql-request"; + +export type GqlMonitorSummary = + SearchMonitorV2Query["searchMonitorV2"]["results"][number]; + +export async function searchMonitors( + config: Config, + variables?: SearchMonitorV2QueryVariables, +): Promise { + const response = await executeGraphQL( + config, + SearchMonitorV2Document, + variables ?? {}, + ); + return response.data.searchMonitorV2.results; +}