Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 |
Expand Down
2 changes: 2 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -36,6 +37,7 @@ export const routes = buildRouteMap({
"data-connection": dataConnectionRoutes,
datastream: datastreamRoutes,
"datastream-token": datastreamTokenRoutes,
board: boardRoutes,
cli: cliRoutes,
},
defaultCommand: "help",
Expand Down
78 changes: 78 additions & 0 deletions src/commands/board/board-input.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>[];
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<string, unknown>[])[0]!
.input as Record<string, unknown>[];
// 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<string, unknown>[])[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",
);
});
});
158 changes: 158 additions & 0 deletions src/commands/board/board-input.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

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" }],
},
],
};
}
60 changes: 60 additions & 0 deletions src/commands/board/clear-default.ts
Original file line number Diff line number Diff line change
@@ -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<string, never>,
datasetId: string,
deps: ClearDefaultBoardDeps = {},
): Promise<void> {
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",
},
});
Loading