From 87e7aeb39a329c6df47dc3fab6b47c99b42e95ed Mon Sep 17 00:00:00 2001 From: Aaron Brewbaker Date: Wed, 24 Jun 2026 11:59:15 -0400 Subject: [PATCH] feat(board): port board (dashboard) commands from Go CLI Add `observe board` with create/update (DashboardInput JSON), scaffold, set-default/clear-default, and explicit list/get/delete (the new CLI has no generic object layer). Reproduces PR #9 behavior: visibility/folderId fields and a printed board VIEW URL after create/update. Normalizes board JSON for saveDashboard: renames deprecated stage `stageID` to StageQueryInput `id`, defaults each stage `input` to [], and defaults InputDefinitionInput `stageId`/`inputRole` so read-back queries don't hit null non-null fields. Closes #2 Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 2 + README.md | 9 ++ src/app.ts | 2 + src/commands/board/board-input.test.ts | 78 ++++++++++++ src/commands/board/board-input.ts | 158 +++++++++++++++++++++++ src/commands/board/clear-default.ts | 60 +++++++++ src/commands/board/create.test.ts | 156 +++++++++++++++++++++++ src/commands/board/create.ts | 71 +++++++++++ src/commands/board/default.test.ts | 167 +++++++++++++++++++++++++ src/commands/board/delete.ts | 56 +++++++++ src/commands/board/get.ts | 56 +++++++++ src/commands/board/index.ts | 39 ++++++ src/commands/board/list.test.ts | 141 +++++++++++++++++++++ src/commands/board/list.ts | 89 +++++++++++++ src/commands/board/scaffold.ts | 41 ++++++ src/commands/board/set-default.ts | 65 ++++++++++ src/commands/board/update.ts | 75 +++++++++++ src/gql/board/board-ops.graphql | 67 ++++++++++ src/gql/board/board.ts | 99 +++++++++++++++ 19 files changed, 1431 insertions(+) create mode 100644 src/commands/board/board-input.test.ts create mode 100644 src/commands/board/board-input.ts create mode 100644 src/commands/board/clear-default.ts create mode 100644 src/commands/board/create.test.ts create mode 100644 src/commands/board/create.ts create mode 100644 src/commands/board/default.test.ts create mode 100644 src/commands/board/delete.ts create mode 100644 src/commands/board/get.ts create mode 100644 src/commands/board/index.ts create mode 100644 src/commands/board/list.test.ts create mode 100644 src/commands/board/list.ts create mode 100644 src/commands/board/scaffold.ts create mode 100644 src/commands/board/set-default.ts create mode 100644 src/commands/board/update.ts create mode 100644 src/gql/board/board-ops.graphql create mode 100644 src/gql/board/board.ts diff --git a/AGENTS.md b/AGENTS.md index 0198696..f12fb3c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,7 @@ src/ ├── commands/ # CLI commands organized by resource │ ├── alert/ # Alert commands (list, view) │ ├── auth/ # Auth commands (configure, login, logout, status) +│ ├── board/ # Board/dashboard commands (create, update, scaffold, set-default, clear-default, list, get, delete) │ ├── cli/ # CLI management (install, uninstall, upgrade) │ ├── content/ # Content pack management │ │ ├── host/ # Host Explorer (install, view) @@ -52,6 +53,7 @@ src/ ├── gql/ # GraphQL layer │ ├── generated/ # Auto-generated types (DO NOT EDIT) │ ├── authtoken/ # Auth token mutations +│ ├── board/ # Board/dashboard queries/mutations │ ├── content/ # Content pack queries/mutations │ ├── dataset/ # Dataset queries │ ├── datastream/ # Datastream queries/mutations diff --git a/README.md b/README.md index a7f6baa..b7aec7e 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Command line interface for [Observe Inc](https://www.observeinc.com). - **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. +- **Board (Dashboard) Management** - Create, update, scaffold, list, get, delete, and set/clear default boards (dashboards). - **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. @@ -74,6 +75,14 @@ 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 board create` | Create a board (dashboard) from a JSON file | +| `observe board update` | Update a board (dashboard) from a JSON file | +| `observe board scaffold` | Print a starting board (dashboard) JSON template | +| `observe board set-default` | Set the default board (dashboard) for a dataset | +| `observe board clear-default` | Clear the default board (dashboard) for a dataset | +| `observe board list` | List boards (dashboards) | +| `observe board get` | Get a board (dashboard) by ID | +| `observe board delete` | Delete a board (dashboard) by ID | | `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..6943020 100644 --- a/src/app.ts +++ b/src/app.ts @@ -5,6 +5,7 @@ import { dataConnectionRoutes } from "./commands/data-connection/index.js"; import { datasourceRoutes } from "./commands/datasource/index.js"; import { datastreamTokenRoutes } from "./commands/datastream-token/index.js"; import { authRoutes } from "./commands/auth/index.js"; +import { boardRoutes } from "./commands/board/index.js"; import { cliRoutes } from "./commands/cli/index.js"; import { contentRoutes } from "./commands/content/index.js"; import { datasetRoutes } from "./commands/dataset/index.js"; @@ -36,6 +37,7 @@ export const routes = buildRouteMap({ "data-connection": dataConnectionRoutes, datastream: datastreamRoutes, "datastream-token": datastreamTokenRoutes, + board: boardRoutes, cli: cliRoutes, }, defaultCommand: "help", diff --git a/src/commands/board/board-input.test.ts b/src/commands/board/board-input.test.ts new file mode 100644 index 0000000..fffc59b --- /dev/null +++ b/src/commands/board/board-input.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from "bun:test"; +import type { Config } from "../../lib/config"; +import { boardViewURL, normalizeStages } from "./board-input"; + +describe("normalizeStages", () => { + test("renames stageID to id and defaults missing input to []", () => { + const input = normalizeStages({ + stages: [ + { + stageID: "stage-abc", + pipeline: "limit 10", + input: [{ inputName: "main", datasetId: "1" }], + }, + { stageID: "stage-xyz", pipeline: "limit 5" }, + ], + }); + + const stages = input.stages as Record[]; + for (const stage of stages) { + expect("stageID" in stage).toBe(false); + expect("id" in stage).toBe(true); + } + expect(stages[0]!.id).toBe("stage-abc"); + expect(stages[1]!.id).toBe("stage-xyz"); + // stage with no input gets an empty array + expect(stages[1]!.input).toEqual([]); + }); + + test("defaults stageId and inputRole on each dataset input", () => { + const input = normalizeStages({ + stages: [ + { + stageID: "stage-abc", + pipeline: "limit 10", + input: [ + { inputName: "main", datasetId: "42450595" }, + { + inputName: "other", + datasetId: "42450596", + stageId: "stage-xyz", + }, + ], + }, + ], + }); + + const inputs = (input.stages as Record[])[0]! + .input as Record[]; + // First entry had no stageId or inputRole — normalized + expect(inputs[0]!.stageId).toBe(""); + expect(inputs[0]!.inputRole).toBe("Data"); + // Second entry preserved its stageId; inputRole still defaulted + expect(inputs[1]!.stageId).toBe("stage-xyz"); + expect(inputs[1]!.inputRole).toBe("Data"); + }); + + test("does not overwrite an existing id when stageID also present", () => { + const input = normalizeStages({ + stages: [{ id: "real-id", stageID: "old-id", pipeline: "limit 1" }], + }); + const stage = (input.stages as Record[])[0]!; + expect(stage.id).toBe("real-id"); + expect("stageID" in stage).toBe(false); + }); +}); + +describe("boardViewURL", () => { + test("builds the workspace/dashboard URL from config", () => { + const config = { + customerId: "109601619518", + domain: "observeinc.com", + token: "t", + } as Config; + expect(boardViewURL(config, "42379913", "43102612")).toBe( + "https://109601619518.observeinc.com/workspace/42379913/dashboard/43102612", + ); + }); +}); diff --git a/src/commands/board/board-input.ts b/src/commands/board/board-input.ts new file mode 100644 index 0000000..485d309 --- /dev/null +++ b/src/commands/board/board-input.ts @@ -0,0 +1,158 @@ +import { readFileSync } from "node:fs"; +import { getApiBaseUrl, type Config } from "../../lib/config"; + +/** + * Fields the Observe API returns on a Dashboard but rejects as input to the + * saveDashboard mutation. They are stripped before sending. + */ +const READ_ONLY_BOARD_FIELDS = ["updatedDate"] as const; + +type JsonObject = Record; + +function isObject(v: unknown): v is JsonObject { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +/** + * Read a DashboardInput JSON file and normalize it for the saveDashboard + * mutation. + * + * StageQueryInput uses "id" for the user-defined stage label (the deprecated + * field name "stageID" appears in older board exports). If the rename is not + * applied, the API silently ignores the label and generates random IDs, which + * breaks every layout card.stageId reference (panels render blank). + * + * Each stage's "input" must be present ([] when there is no dataset input), + * and every InputDefinitionInput needs stageId/inputRole defaulted so the + * stored values are non-null (read-back queries require String!/InputRole!). + */ +export function readBoardInput(filePath: string): JsonObject { + let raw: string; + try { + raw = readFileSync(filePath, "utf-8"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`could not read file "${filePath}": ${message}`); + } + + let input: unknown; + try { + input = JSON.parse(raw); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`could not parse JSON from "${filePath}": ${message}`); + } + + if (!isObject(input)) { + throw new Error(`board input from "${filePath}" must be a JSON object`); + } + + for (const field of READ_ONLY_BOARD_FIELDS) { + delete input[field]; + } + + return normalizeStages(input); +} + +/** Normalize the stages array in place (exported for testing). */ +export function normalizeStages(input: JsonObject): JsonObject { + const stages = input["stages"]; + if (!Array.isArray(stages)) { + return input; + } + + for (const s of stages) { + if (!isObject(s)) { + continue; + } + + // StageQueryInput.id holds the stage label; rename the deprecated "stageID". + if ("stageID" in s) { + if (!("id" in s)) { + s["id"] = s["stageID"]; + } + delete s["stageID"]; + } + + // DashboardStageInput requires "input" to be defined; default to []. + if (!("input" in s)) { + s["input"] = []; + } + + const inputs = s["input"]; + if (Array.isArray(inputs)) { + for (const inp of inputs) { + if (!isObject(inp)) { + continue; + } + // stageId: String! and inputRole: InputRole! must be non-null on read. + if (!("stageId" in inp)) { + inp["stageId"] = ""; + } + if (!("inputRole" in inp)) { + inp["inputRole"] = "Data"; + } + } + } + } + + return input; +} + +/** + * The Observe UI URL for viewing a dashboard. + * Pattern: https://{customerid}.{domain}/workspace/{workspaceId}/dashboard/{boardId} + */ +export function boardViewURL( + config: Config, + workspaceId: string, + boardId: string, +): string { + const base = getApiBaseUrl(config); + return `${base}/workspace/${workspaceId}/dashboard/${boardId}`; +} + +/** Template emitted by `board scaffold` as a starting point for board JSON. */ +export function boardScaffoldTemplate(name = "My Dashboard"): JsonObject { + return { + name, + workspaceId: "YOUR_WORKSPACE_ID", + visibility: "Listed", + layout: { + autoPack: true, + gridLayout: { + sections: [ + { + card: { title: "Section", closed: false, cardType: "section" }, + items: [ + { + card: { stageId: "stage-abc123", cardType: "stage" }, + layout: { h: 12, w: 12, x: 0, y: 0 }, + }, + ], + }, + ], + }, + stageListLayout: { + timeRange: { + display: "Past 24 hours", + millisFromCurrentTime: 86400000, + timeRangeInfo: { + key: "PRESETS", + name: "Presets", + presetType: "PAST_24_HOURS", + }, + }, + isModified: false, + parameters: [], + }, + }, + stages: [ + { + stageID: "stage-abc123", + pipeline: "limit 100", + input: [{ inputName: "main", datasetId: "YOUR_DATASET_ID" }], + }, + ], + }; +} diff --git a/src/commands/board/clear-default.ts b/src/commands/board/clear-default.ts new file mode 100644 index 0000000..e32bd67 --- /dev/null +++ b/src/commands/board/clear-default.ts @@ -0,0 +1,60 @@ +import { buildCommand } from "@stricli/core"; +import type { LocalContext } from "../../context"; +import { clearDefaultDashboard } from "../../gql/board/board"; +import { GqlApiError } from "../../gql/gql-request"; +import { loadConfig } from "../../lib/config"; + +export interface ClearDefaultBoardDeps { + loadConfig?: typeof loadConfig; + clearDefaultDashboard?: typeof clearDefaultDashboard; +} + +export async function clearDefault( + this: LocalContext, + _flags: Record, + datasetId: string, + deps: ClearDefaultBoardDeps = {}, +): Promise { + const { + loadConfig: loadConfigImpl = loadConfig, + clearDefaultDashboard: clearDefaultDashboardImpl = clearDefaultDashboard, + } = deps; + const { process, writer } = this; + + try { + const config = loadConfigImpl(); + await clearDefaultDashboardImpl(config, { dsid: datasetId }); + writer.write("Default dashboard cleared successfully"); + } 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 clearDefaultCommand = buildCommand({ + loader: async () => clearDefault, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + brief: "Dataset ID to clear the default dashboard for", + parse: String, + }, + ], + }, + flags: {}, + }, + docs: { + brief: "Clear the default board (dashboard) for a dataset", + fullDescription: + "Clear the default board (dashboard) for a dataset.\n\n" + + "Example:\n" + + " observe board clear-default 42450595", + }, +}); diff --git a/src/commands/board/create.test.ts b/src/commands/board/create.test.ts new file mode 100644 index 0000000..1fcf145 --- /dev/null +++ b/src/commands/board/create.test.ts @@ -0,0 +1,156 @@ +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 gqlModulePath = resolve(repoRoot, "src/gql/board/board.ts"); + +const loadConfigFn = mock(() => ({ + customerId: "109601619518", + token: "test-token", + domain: "observeinc.com", +})); + +const saveBoardFn = mock((_config: unknown, _vars: unknown) => + Promise.resolve({ + id: "42000001", + name: "My Board", + workspaceId: "42379913", + folderId: "42379919", + visibility: "Listed", + }), +); + +const readBoardInputFn = mock((_file: string) => ({ + name: "My Board", + workspaceId: "42379913", + layout: {}, +})); + +let create: (typeof import("./create"))["create"]; + +let previousNoColor: string | undefined; +let previousForceColor: string | undefined; + +const deps = { + loadConfig: loadConfigFn, + saveBoard: saveBoardFn, + readBoardInput: readBoardInputFn, +} as Parameters<(typeof import("./create"))["create"]>[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(gqlModulePath, () => ({ + saveBoard: saveBoardFn, + getBoard: mock(() => Promise.resolve({})), + searchBoards: mock(() => Promise.resolve([])), + deleteBoard: mock(() => Promise.resolve()), + setDefaultDashboard: mock(() => Promise.resolve()), + clearDefaultDashboard: mock(() => Promise.resolve()), + })); + + 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("board create", () => { + beforeEach(() => { + loadConfigFn.mockClear(); + saveBoardFn.mockClear(); + readBoardInputFn.mockClear(); + }); + + test("prints created name/id, visibility, and view URL", async () => { + const { context, stdout } = createMockContext(); + await create.call(context, {}, "board.json", deps); + + expect(saveBoardFn).toHaveBeenCalledTimes(1); + const out = stdout.join(""); + expect(out).toContain("Created: My Board (id: 42000001)"); + expect(out).toContain("Visibility: Listed"); + expect(out).toContain( + "View: https://109601619518.observeinc.com/workspace/42379913/dashboard/42000001", + ); + }); + + test("passes the parsed input under the input variable", async () => { + const { context } = createMockContext(); + await create.call(context, {}, "board.json", deps); + const [, vars] = saveBoardFn.mock.calls[0]!; + expect(vars).toEqual({ + input: { name: "My Board", workspaceId: "42379913", layout: {} }, + }); + }); + + test("exits with code 1 on API error", async () => { + saveBoardFn.mockImplementationOnce(() => { + const err = new Error("Server error"); + err.name = "GqlApiError"; + (err as unknown as { statusCode: number }).statusCode = 500; + throw err; + }); + + const { context, stderr, getExitCode } = createMockContext(); + try { + await create.call(context, {}, "board.json", 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/board/create.ts b/src/commands/board/create.ts new file mode 100644 index 0000000..65a8be6 --- /dev/null +++ b/src/commands/board/create.ts @@ -0,0 +1,71 @@ +import { buildCommand } from "@stricli/core"; +import type { LocalContext } from "../../context"; +import { saveBoard } from "../../gql/board/board"; +import { GqlApiError } from "../../gql/gql-request"; +import { loadConfig } from "../../lib/config"; +import { boardViewURL, readBoardInput } from "./board-input"; + +export interface CreateBoardDeps { + loadConfig?: typeof loadConfig; + saveBoard?: typeof saveBoard; + readBoardInput?: typeof readBoardInput; +} + +export async function create( + this: LocalContext, + _flags: Record, + file: string, + deps: CreateBoardDeps = {}, +): Promise { + const { + loadConfig: loadConfigImpl = loadConfig, + saveBoard: saveBoardImpl = saveBoard, + readBoardInput: readBoardInputImpl = readBoardInput, + } = deps; + const { process, writer } = this; + + try { + const config = loadConfigImpl(); + const input = readBoardInputImpl(file); + const result = await saveBoardImpl(config, { input }); + + writer.write(`Created: ${result.name} (id: ${result.id})`); + writer.write(`Visibility: ${result.visibility}`); + writer.write( + `View: ${boardViewURL(config, result.workspaceId, result.id)}`, + ); + } 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 createCommand = buildCommand({ + loader: async () => create, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + brief: "Path to DashboardInput JSON file (name, workspaceId, layout)", + parse: String, + }, + ], + }, + flags: {}, + }, + docs: { + brief: "Create a board (dashboard) from a JSON file", + fullDescription: + "Create a board (dashboard) from a DashboardInput JSON file.\n\n" + + "The file must contain name, workspaceId, and layout; stages are optional.\n" + + "Run 'observe board scaffold' to print a starting template.\n\n" + + "Example:\n" + + " observe board create board.json", + }, +}); diff --git a/src/commands/board/default.test.ts b/src/commands/board/default.test.ts new file mode 100644 index 0000000..6387e5f --- /dev/null +++ b/src/commands/board/default.test.ts @@ -0,0 +1,167 @@ +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 gqlModulePath = resolve(repoRoot, "src/gql/board/board.ts"); + +const loadConfigFn = mock(() => ({ + customerId: "test-customer", + token: "test-token", + domain: "observeinc.com", +})); + +const setDefaultDashboardFn = mock((_config: unknown, _vars: unknown) => + Promise.resolve(), +); +const clearDefaultDashboardFn = mock((_config: unknown, _vars: unknown) => + Promise.resolve(), +); + +let setDefault: (typeof import("./set-default"))["setDefault"]; +let clearDefault: (typeof import("./clear-default"))["clearDefault"]; + +let previousNoColor: string | undefined; +let previousForceColor: string | undefined; + +const setDeps = { + loadConfig: loadConfigFn, + setDefaultDashboard: setDefaultDashboardFn, +} as Parameters<(typeof import("./set-default"))["setDefault"]>[3]; + +const clearDeps = { + loadConfig: loadConfigFn, + clearDefaultDashboard: clearDefaultDashboardFn, +} as Parameters<(typeof import("./clear-default"))["clearDefault"]>[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(gqlModulePath, () => ({ + setDefaultDashboard: setDefaultDashboardFn, + clearDefaultDashboard: clearDefaultDashboardFn, + saveBoard: mock(() => Promise.resolve({})), + getBoard: mock(() => Promise.resolve({})), + searchBoards: mock(() => Promise.resolve([])), + deleteBoard: mock(() => Promise.resolve()), + })); + + setDefault = (await import("./set-default.ts")).setDefault; + clearDefault = (await import("./clear-default.ts")).clearDefault; +}); + +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("board set-default", () => { + beforeEach(() => { + loadConfigFn.mockClear(); + setDefaultDashboardFn.mockClear(); + }); + + test("prints success and passes dsid/dashid", async () => { + const { context, stdout } = createMockContext(); + await setDefault.call(context, {}, "ds-100", "board-200", setDeps); + + expect(setDefaultDashboardFn).toHaveBeenCalledTimes(1); + const [, vars] = setDefaultDashboardFn.mock.calls[0]!; + expect(vars).toEqual({ dsid: "ds-100", dashid: "board-200" }); + expect(stdout.join("")).toContain("Default dashboard set successfully"); + }); + + test("exits with code 1 when the mutation throws", async () => { + setDefaultDashboardFn.mockImplementationOnce(() => { + throw new Error("dataset not found"); + }); + const { context, stderr, getExitCode } = createMockContext(); + try { + await setDefault.call(context, {}, "invalid-ds", "board-200", setDeps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("dataset not found"); + }); +}); + +describe("board clear-default", () => { + beforeEach(() => { + loadConfigFn.mockClear(); + clearDefaultDashboardFn.mockClear(); + }); + + test("prints success and passes dsid", async () => { + const { context, stdout } = createMockContext(); + await clearDefault.call(context, {}, "ds-100", clearDeps); + + expect(clearDefaultDashboardFn).toHaveBeenCalledTimes(1); + const [, vars] = clearDefaultDashboardFn.mock.calls[0]!; + expect(vars).toEqual({ dsid: "ds-100" }); + expect(stdout.join("")).toContain("Default dashboard cleared successfully"); + }); + + test("exits with code 1 when the mutation throws", async () => { + clearDefaultDashboardFn.mockImplementationOnce(() => { + throw new Error("permission denied"); + }); + const { context, stderr, getExitCode } = createMockContext(); + try { + await clearDefault.call(context, {}, "invalid-ds", clearDeps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("permission denied"); + }); +}); diff --git a/src/commands/board/delete.ts b/src/commands/board/delete.ts new file mode 100644 index 0000000..865538f --- /dev/null +++ b/src/commands/board/delete.ts @@ -0,0 +1,56 @@ +import { buildCommand } from "@stricli/core"; +import type { LocalContext } from "../../context"; +import { deleteBoard } from "../../gql/board/board"; +import { GqlApiError } from "../../gql/gql-request"; +import { loadConfig } from "../../lib/config"; + +export interface DeleteBoardDeps { + loadConfig?: typeof loadConfig; + deleteBoard?: typeof deleteBoard; +} + +export async function deleteBoardCommandHandler( + this: LocalContext, + _flags: Record, + id: string, + deps: DeleteBoardDeps = {}, +): Promise { + const { + loadConfig: loadConfigImpl = loadConfig, + deleteBoard: deleteBoardImpl = deleteBoard, + } = deps; + const { process, writer } = this; + + try { + const config = loadConfigImpl(); + await deleteBoardImpl(config, { id }); + writer.write(`Deleted board ${id}`); + } 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 deleteCommand = buildCommand({ + loader: async () => deleteBoardCommandHandler, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + brief: "Board (dashboard) ID to delete", + parse: String, + }, + ], + }, + flags: {}, + }, + docs: { + brief: "Delete a board (dashboard) by ID", + }, +}); diff --git a/src/commands/board/get.ts b/src/commands/board/get.ts new file mode 100644 index 0000000..1059cc1 --- /dev/null +++ b/src/commands/board/get.ts @@ -0,0 +1,56 @@ +import { buildCommand } from "@stricli/core"; +import type { LocalContext } from "../../context"; +import { getBoard } from "../../gql/board/board"; +import { GqlApiError } from "../../gql/gql-request"; +import { loadConfig } from "../../lib/config"; + +export interface GetBoardDeps { + loadConfig?: typeof loadConfig; + getBoard?: typeof getBoard; +} + +export async function get( + this: LocalContext, + _flags: Record, + id: string, + deps: GetBoardDeps = {}, +): Promise { + const { + loadConfig: loadConfigImpl = loadConfig, + getBoard: getBoardImpl = getBoard, + } = deps; + const { process, writer } = this; + + try { + const config = loadConfigImpl(); + const result = await getBoardImpl(config, { id }); + writer.write(JSON.stringify(result, null, 2)); + } 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: "Board (dashboard) ID", + parse: String, + }, + ], + }, + flags: {}, + }, + docs: { + brief: "Get a board (dashboard) by ID", + }, +}); diff --git a/src/commands/board/index.ts b/src/commands/board/index.ts new file mode 100644 index 0000000..d5348b3 --- /dev/null +++ b/src/commands/board/index.ts @@ -0,0 +1,39 @@ +import { buildRouteMap } from "@stricli/core"; +import { createCommand } from "./create"; +import { updateCommand } from "./update"; +import { scaffoldCommand } from "./scaffold"; +import { setDefaultCommand } from "./set-default"; +import { clearDefaultCommand } from "./clear-default"; +import { listCommand } from "./list"; +import { getCommand } from "./get"; +import { deleteCommand } from "./delete"; + +export const boardRoutes = buildRouteMap({ + routes: { + create: createCommand, + update: updateCommand, + scaffold: scaffoldCommand, + "set-default": setDefaultCommand, + "clear-default": clearDefaultCommand, + list: listCommand, + get: getCommand, + delete: deleteCommand, + }, + docs: { + brief: "Manage boards (dashboards)", + fullDescription: [ + "Create, update, scaffold, list, get, delete, and set/clear defaults for", + "boards (dashboards) in Observe.", + "", + "Commands:", + " create Create a board from a JSON file", + " update Update a board from a JSON file", + " scaffold Print a starting board JSON template", + " set-default Set the default board for a dataset", + " clear-default Clear the default board for a dataset", + " list List boards (dashboards)", + " get Get a board by ID", + " delete Delete a board by ID", + ].join("\n"), + }, +}); diff --git a/src/commands/board/list.test.ts b/src/commands/board/list.test.ts new file mode 100644 index 0000000..e138e8a --- /dev/null +++ b/src/commands/board/list.test.ts @@ -0,0 +1,141 @@ +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 gqlModulePath = resolve(repoRoot, "src/gql/board/board.ts"); + +const loadConfigFn = mock(() => ({ + customerId: "test-customer", + token: "test-token", + domain: "observeinc.com", +})); + +const searchBoardsFn = mock((_config: unknown, _vars: unknown) => + Promise.resolve([ + { + score: "1", + dashboard: { + id: "d1", + name: "Dash A", + workspaceId: "ws1", + updatedDate: "2026-01-01", + }, + }, + ]), +); + +let list: (typeof import("./list"))["list"]; + +let previousNoColor: string | undefined; +let previousForceColor: string | undefined; + +const deps = { + loadConfig: loadConfigFn, + searchBoards: searchBoardsFn, +} 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(gqlModulePath, () => ({ + searchBoards: searchBoardsFn, + saveBoard: mock(() => Promise.resolve({})), + getBoard: mock(() => Promise.resolve({})), + deleteBoard: mock(() => Promise.resolve()), + setDefaultDashboard: mock(() => Promise.resolve()), + clearDefaultDashboard: mock(() => Promise.resolve()), + })); + + list = (await import("./list.ts")).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("board list", () => { + beforeEach(() => { + loadConfigFn.mockClear(); + searchBoardsFn.mockClear(); + }); + + test("unwraps dashboards from search results", async () => { + const { context, stdout } = createMockContext(); + await list.call(context, {}, deps); + + expect(searchBoardsFn).toHaveBeenCalledTimes(1); + const output = JSON.parse(stdout.join("")); + expect(output).toHaveLength(1); + expect(output[0].id).toBe("d1"); + expect(output[0].name).toBe("Dash A"); + }); + + test("maps name/workspace/folder flags into list-typed search terms", async () => { + const { context } = createMockContext(); + await list.call( + context, + { name: "Dash", workspace: "ws1", folder: "f1" }, + deps, + ); + const [, vars] = searchBoardsFn.mock.calls[0]!; + expect(vars).toEqual({ + terms: { name: ["Dash"], workspaceId: ["ws1"], folderId: ["f1"] }, + }); + }); + + test("sends empty terms when no filters are set", async () => { + const { context } = createMockContext(); + await list.call(context, {}, deps); + const [, vars] = searchBoardsFn.mock.calls[0]!; + expect(vars).toEqual({ terms: {} }); + }); +}); diff --git a/src/commands/board/list.ts b/src/commands/board/list.ts new file mode 100644 index 0000000..21a9be7 --- /dev/null +++ b/src/commands/board/list.ts @@ -0,0 +1,89 @@ +import { buildCommand } from "@stricli/core"; +import type { LocalContext } from "../../context"; +import { searchBoards } from "../../gql/board/board"; +import type { SearchBoardsQueryVariables } from "../../gql/generated/graphql"; +import { GqlApiError } from "../../gql/gql-request"; +import { loadConfig } from "../../lib/config"; + +interface ListBoardsFlags { + name?: string; + folder?: string; + workspace?: string; +} + +export interface ListBoardsDeps { + loadConfig?: typeof loadConfig; + searchBoards?: typeof searchBoards; +} + +export async function list( + this: LocalContext, + flags: ListBoardsFlags, + deps: ListBoardsDeps = {}, +): Promise { + const { + loadConfig: loadConfigImpl = loadConfig, + searchBoards: searchBoardsImpl = searchBoards, + } = deps; + const { process, writer } = this; + + try { + const config = loadConfigImpl(); + + // DWSearchInput fields are all lists; only include the terms that are set. + const terms: SearchBoardsQueryVariables["terms"] = {}; + if (flags.name) terms.name = [flags.name]; + if (flags.workspace) terms.workspaceId = [flags.workspace]; + if (flags.folder) terms.folderId = [flags.folder]; + + const items = await searchBoardsImpl(config, { terms }); + const boards = items.map((item) => item.dashboard); + writer.write(JSON.stringify(boards, null, 2)); + } 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: [], + }, + flags: { + name: { + kind: "parsed", + parse: String, + brief: "Filter boards by name (case-insensitive substring)", + optional: true, + }, + folder: { + kind: "parsed", + parse: String, + brief: "Filter boards by folder ID", + optional: true, + }, + workspace: { + kind: "parsed", + parse: String, + brief: "Filter boards by workspace ID", + optional: true, + }, + }, + }, + docs: { + brief: "List boards (dashboards)", + fullDescription: + "List boards (dashboards) via dashboard search, with optional filters.\n\n" + + "Examples:\n" + + " observe board list\n" + + ' observe board list --name "CPU" --workspace 42379913', + }, +}); diff --git a/src/commands/board/scaffold.ts b/src/commands/board/scaffold.ts new file mode 100644 index 0000000..d5e16bc --- /dev/null +++ b/src/commands/board/scaffold.ts @@ -0,0 +1,41 @@ +import { buildCommand } from "@stricli/core"; +import type { LocalContext } from "../../context"; +import { boardScaffoldTemplate } from "./board-input"; + +interface ScaffoldBoardFlags { + name?: string; +} + +export async function scaffold( + this: LocalContext, + flags: ScaffoldBoardFlags, +): Promise { + const { writer } = this; + const template = boardScaffoldTemplate(flags.name ?? "My Dashboard"); + writer.write(JSON.stringify(template, null, 2)); +} + +export const scaffoldCommand = buildCommand({ + loader: async () => scaffold, + parameters: { + positional: { + kind: "tuple", + parameters: [], + }, + flags: { + name: { + kind: "parsed", + parse: String, + brief: "Name to set on the scaffolded board", + optional: true, + }, + }, + }, + docs: { + brief: "Print a starting board (dashboard) JSON template", + fullDescription: + "Print a DashboardInput JSON template suitable for 'observe board create'.\n\n" + + "Example:\n" + + ' observe board scaffold --name "My Dashboard" > board.json', + }, +}); diff --git a/src/commands/board/set-default.ts b/src/commands/board/set-default.ts new file mode 100644 index 0000000..cc041be --- /dev/null +++ b/src/commands/board/set-default.ts @@ -0,0 +1,65 @@ +import { buildCommand } from "@stricli/core"; +import type { LocalContext } from "../../context"; +import { setDefaultDashboard } from "../../gql/board/board"; +import { GqlApiError } from "../../gql/gql-request"; +import { loadConfig } from "../../lib/config"; + +export interface SetDefaultBoardDeps { + loadConfig?: typeof loadConfig; + setDefaultDashboard?: typeof setDefaultDashboard; +} + +export async function setDefault( + this: LocalContext, + _flags: Record, + datasetId: string, + boardId: string, + deps: SetDefaultBoardDeps = {}, +): Promise { + const { + loadConfig: loadConfigImpl = loadConfig, + setDefaultDashboard: setDefaultDashboardImpl = setDefaultDashboard, + } = deps; + const { process, writer } = this; + + try { + const config = loadConfigImpl(); + await setDefaultDashboardImpl(config, { dsid: datasetId, dashid: boardId }); + writer.write("Default dashboard set successfully"); + } 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 setDefaultCommand = buildCommand({ + loader: async () => setDefault, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + brief: "Dataset ID to set the default dashboard for", + parse: String, + }, + { + brief: "Board (dashboard) ID to set as default", + parse: String, + }, + ], + }, + flags: {}, + }, + docs: { + brief: "Set the default board (dashboard) for a dataset", + fullDescription: + "Set the default board (dashboard) shown for a dataset.\n\n" + + "Example:\n" + + " observe board set-default 42450595 42000001", + }, +}); diff --git a/src/commands/board/update.ts b/src/commands/board/update.ts new file mode 100644 index 0000000..0e23cee --- /dev/null +++ b/src/commands/board/update.ts @@ -0,0 +1,75 @@ +import { buildCommand } from "@stricli/core"; +import type { LocalContext } from "../../context"; +import { saveBoard } from "../../gql/board/board"; +import { GqlApiError } from "../../gql/gql-request"; +import { loadConfig } from "../../lib/config"; +import { boardViewURL, readBoardInput } from "./board-input"; + +export interface UpdateBoardDeps { + loadConfig?: typeof loadConfig; + saveBoard?: typeof saveBoard; + readBoardInput?: typeof readBoardInput; +} + +export async function update( + this: LocalContext, + _flags: Record, + id: string, + file: string, + deps: UpdateBoardDeps = {}, +): Promise { + const { + loadConfig: loadConfigImpl = loadConfig, + saveBoard: saveBoardImpl = saveBoard, + readBoardInput: readBoardInputImpl = readBoardInput, + } = deps; + const { process, writer } = this; + + try { + const config = loadConfigImpl(); + const input = readBoardInputImpl(file); + input["id"] = id; + const result = await saveBoardImpl(config, { input }); + + writer.write(`Updated: ${result.name} (id: ${result.id})`); + writer.write(`Visibility: ${result.visibility}`); + writer.write( + `View: ${boardViewURL(config, result.workspaceId, result.id)}`, + ); + } 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 updateCommand = buildCommand({ + loader: async () => update, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + brief: "Board (dashboard) ID to update", + parse: String, + }, + { + brief: "Path to DashboardInput JSON file", + parse: String, + }, + ], + }, + flags: {}, + }, + docs: { + brief: "Update a board (dashboard) from a JSON file", + fullDescription: + "Update an existing board (dashboard) from a DashboardInput JSON file.\n\n" + + "Example:\n" + + " observe board update 42000001 board.json", + }, +}); diff --git a/src/gql/board/board-ops.graphql b/src/gql/board/board-ops.graphql new file mode 100644 index 0000000..a9f5002 --- /dev/null +++ b/src/gql/board/board-ops.graphql @@ -0,0 +1,67 @@ +mutation SaveBoard($input: DashboardInput!) { + saveDashboard(dash: $input) { + id + name + workspaceId + folderId + visibility + } +} + +query GetBoard($id: ObjectId!) { + dashboard(id: $id) { + id + name + workspaceId + description + folderId + visibility + updatedDate + layout + stages { + id + pipeline + input { + inputName + datasetId + stageId + inputRole + } + } + } +} + +query SearchBoards($terms: DWSearchInput!, $maxCount: Int64) { + dashboardSearch(terms: $terms, maxCount: $maxCount) { + dashboards { + score + dashboard { + id + name + workspaceId + updatedDate + } + } + } +} + +mutation DeleteBoard($id: ObjectId!) { + deleteDashboard(id: $id) { + success + errorMessage + } +} + +mutation SetDefaultDashboard($dsid: ObjectId!, $dashid: ObjectId!) { + setDefaultDashboard(dsid: $dsid, dashid: $dashid) { + success + errorMessage + } +} + +mutation ClearDefaultDashboard($dsid: ObjectId!) { + clearDefaultDashboard(dsid: $dsid) { + success + errorMessage + } +} diff --git a/src/gql/board/board.ts b/src/gql/board/board.ts new file mode 100644 index 0000000..be67822 --- /dev/null +++ b/src/gql/board/board.ts @@ -0,0 +1,99 @@ +import type { Config } from "../../lib/config"; +import { + SaveBoardDocument, + type SaveBoardMutation, + type SaveBoardMutationVariables, + GetBoardDocument, + type GetBoardQuery, + type GetBoardQueryVariables, + SearchBoardsDocument, + type SearchBoardsQuery, + type SearchBoardsQueryVariables, + DeleteBoardDocument, + type DeleteBoardMutationVariables, + SetDefaultDashboardDocument, + type SetDefaultDashboardMutationVariables, + ClearDefaultDashboardDocument, + type ClearDefaultDashboardMutationVariables, +} from "../generated/graphql"; +import { executeGraphQL } from "../gql-request"; + +export type GqlSavedBoard = SaveBoardMutation["saveDashboard"]; +export type GqlBoard = GetBoardQuery["dashboard"]; +export type GqlBoardSearchItem = + SearchBoardsQuery["dashboardSearch"]["dashboards"][number]; + +/** Create or update a dashboard via saveDashboard (update when input.id is set). */ +export async function saveBoard( + config: Config, + variables: SaveBoardMutationVariables, +): Promise { + const response = await executeGraphQL(config, SaveBoardDocument, variables); + return response.data.saveDashboard; +} + +/** Fetch a single dashboard by ID. */ +export async function getBoard( + config: Config, + variables: GetBoardQueryVariables, +): Promise { + const response = await executeGraphQL(config, GetBoardDocument, variables); + return response.data.dashboard; +} + +/** Search dashboards by optional name/workspace/folder terms. */ +export async function searchBoards( + config: Config, + variables: SearchBoardsQueryVariables, +): Promise { + const response = await executeGraphQL( + config, + SearchBoardsDocument, + variables, + ); + return response.data.dashboardSearch.dashboards; +} + +/** Delete a dashboard by ID. Throws if the API reports a failure. */ +export async function deleteBoard( + config: Config, + variables: DeleteBoardMutationVariables, +): Promise { + const response = await executeGraphQL(config, DeleteBoardDocument, variables); + const result = response.data.deleteDashboard; + if (!result.success && result.errorMessage) { + throw new Error(result.errorMessage); + } +} + +/** Set the default dashboard for a dataset. Throws if the API reports a failure. */ +export async function setDefaultDashboard( + config: Config, + variables: SetDefaultDashboardMutationVariables, +): Promise { + const response = await executeGraphQL( + config, + SetDefaultDashboardDocument, + variables, + ); + const result = response.data.setDefaultDashboard; + if (!result.success && result.errorMessage) { + throw new Error(result.errorMessage); + } +} + +/** Clear the default dashboard for a dataset. Throws if the API reports a failure. */ +export async function clearDefaultDashboard( + config: Config, + variables: ClearDefaultDashboardMutationVariables, +): Promise { + const response = await executeGraphQL( + config, + ClearDefaultDashboardDocument, + variables, + ); + const result = response.data.clearDefaultDashboard; + if (!result.success && result.errorMessage) { + throw new Error(result.errorMessage); + } +}