Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/dashboard-url-in-response.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kopai/cli": minor
---

`dashboards create` response now includes a `url` field — the fully-resolved dashboard URL based on the active `.kopairc` (or `--url` flag, or default). Callers (notably the `create-dashboard` skill) should display this URL directly instead of constructing it from `id`.
2 changes: 2 additions & 0 deletions packages/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,8 @@ kopai dashboards schema
echo '{"uiTree":{"root":"s1","elements":{"s1":{"key":"s1","type":"Stack","props":{"direction":"vertical","gap":"md","align":null},"children":[],"parentKey":""}}},"metadata":{}}' | kopai dashboards create --name "My Dashboard" --tree-version "0.5.0" --json
```

The response includes a `url` field — the fully-resolved dashboard URL based on the active `.kopairc` (or `--url` flag, or default). Display it directly.

## Examples for Agents

```bash
Expand Down
173 changes: 173 additions & 0 deletions packages/cli/src/commands/dashboards.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { Command } from "commander";
import { Readable } from "node:stream";

vi.mock("../config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config.js")>();
return {
...actual,
loadConfig: vi.fn().mockReturnValue({}),
};
});

const mockCreateDashboard = vi.fn();

vi.mock("@kopai/sdk", () => ({
KopaiClient: class MockKopaiClient {
createDashboard = mockCreateDashboard;
},
}));

import { loadConfig } from "../config.js";
import { createDashboardsCommand } from "./dashboards.js";

const VALID_TREE = {
uiTree: {
root: "s1",
elements: {
s1: {
key: "s1",
type: "Stack",
props: { direction: "vertical", gap: "md", align: null },
children: [],
parentKey: "",
},
},
},
metadata: {},
};

function pushStdin(payload: string): void {
const stream = Readable.from([Buffer.from(payload, "utf-8")]);
Object.defineProperty(process, "stdin", {
configurable: true,
value: stream,
});
}

function runCreate(args: string[], stdin: string): Promise<string> {
return new Promise((resolve, reject) => {
const logs: string[] = [];
vi.spyOn(console, "log").mockImplementation((...a: unknown[]) =>
logs.push(a.join(" "))
);
vi.spyOn(console, "error").mockImplementation((...a: unknown[]) =>
logs.push(a.join(" "))
);

pushStdin(stdin);

const program = new Command();
program.exitOverride();
program.addCommand(createDashboardsCommand());

program
.parseAsync([
"node",
"test",
"dashboards",
"create",
"--name",
"Test",
"--tree-version",
"0.7.0",
"--json",
...args,
])
.then(
() => resolve(logs.join("\n")),
(err) => reject(err)
);
});
}

describe("dashboards create command", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(loadConfig).mockReturnValue({});
mockCreateDashboard.mockResolvedValue({
id: "dash-123",
name: "Test",
createdAt: "2026-05-18T00:00:00.000Z",
metadata: {},
uiTreeVersion: "0.7.0",
uiTree: VALID_TREE.uiTree,
});
});

afterEach(() => {
vi.restoreAllMocks();
});

it("includes a url built from the .kopairc url in the JSON response", async () => {
vi.mocked(loadConfig).mockReturnValue({
url: "https://custom.example.com",
});

const output = await runCreate([], JSON.stringify(VALID_TREE));
const parsed = JSON.parse(output) as { url: string; id: string };

expect(parsed.id).toBe("dash-123");
expect(parsed.url).toBe(
"https://custom.example.com/?tab=metrics&dashboardId=dash-123"
);
});

it("uses --url flag value over .kopairc when both present", async () => {
vi.mocked(loadConfig).mockReturnValue({
url: "https://from-config.example.com",
});

const output = await runCreate(
["--url", "https://from-flag.example.com"],
JSON.stringify(VALID_TREE)
);
const parsed = JSON.parse(output) as { url: string };

expect(parsed.url).toBe(
"https://from-flag.example.com/?tab=metrics&dashboardId=dash-123"
);
});

it("falls back to localhost default when no url is configured", async () => {
vi.mocked(loadConfig).mockReturnValue({});

const output = await runCreate([], JSON.stringify(VALID_TREE));
const parsed = JSON.parse(output) as { url: string };

expect(parsed.url).toBe(
"http://localhost:8000/?tab=metrics&dashboardId=dash-123"
);
});

it("strips trailing /signals path from configured url before building dashboard url", async () => {
vi.mocked(loadConfig).mockReturnValue({
url: "https://hosted.example.com/signals",
});

const output = await runCreate([], JSON.stringify(VALID_TREE));
const parsed = JSON.parse(output) as { url: string };

expect(parsed.url).toBe(
"https://hosted.example.com/?tab=metrics&dashboardId=dash-123"
);
});

it("encodes the dashboard id in the url", async () => {
mockCreateDashboard.mockResolvedValue({
id: "needs/encoding",
name: "Test",
createdAt: "2026-05-18T00:00:00.000Z",
metadata: {},
uiTreeVersion: "0.7.0",
uiTree: VALID_TREE.uiTree,
});

const output = await runCreate([], JSON.stringify(VALID_TREE));
const parsed = JSON.parse(output) as { url: string };

expect(parsed.url).toBe(
"http://localhost:8000/?tab=metrics&dashboardId=needs%2Fencoding"
);
});
});
4 changes: 3 additions & 1 deletion packages/cli/src/commands/dashboards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ Example:
const isJson = opts.json ?? false;
try {
const client = createClient(opts);
const { url: baseUrl } = resolveConnectionOpts(opts);
const raw = await readStdin();
let body: Record<string, unknown>;
try {
Expand All @@ -84,7 +85,8 @@ Example:
});

const format = detectFormat(opts.json, opts.table);
output(result, { format });
const url = `${baseUrl}/?tab=metrics&dashboardId=${encodeURIComponent(result.id)}`;
output({ ...result, url }, { format });
} catch (err) {
outputError(err, isJson);
process.exit(1);
Expand Down
2 changes: 1 addition & 1 deletion skills/create-dashboard/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ description: Create observability dashboards from OTEL metrics, logs, and traces
license: Apache-2.0
metadata:
author: kopai
version: "1.1.0"
version: "1.2.0"
---

# Create Dashboard with Kopai
Expand Down
9 changes: 1 addition & 8 deletions skills/create-dashboard/rules/workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,14 +94,7 @@ When creation fails, read the error message, fix the tree, and retry. Do not gue

## Post-Creation

After the dashboard is created, display the URL to the user:

```
<baseUrl>/?tab=metrics&dashboardId=<id>
```

- `<id>` — the `id` field from the CLI JSON response
- `<baseUrl>` — the URL used for the CLI command: the `--url` flag value, or `http://localhost:8000` if omitted
After the dashboard is created, display the URL to the user. The CLI JSON response includes a `url` field — the fully-resolved dashboard URL based on the active `.kopairc` (or `--url` flag, or default). Show that value directly. Do not construct the URL yourself.

Common pitfalls:

Expand Down
2 changes: 1 addition & 1 deletion skills/create-dashboard/tile.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "kopai/create-dashboard",
"version": "1.1.0",
"version": "1.2.0",
"private": false,
"summary": "Create observability dashboards from OTEL metrics, logs, and traces using Kopai. Use when building metric visualizations, monitoring views, KPI panels, or when the user wants to see their telemetry data in a dashboard — even if they don't say \"dashboard\" explicitly. Also use when other skills or workflows need to present telemetry data visually (e.g. after root cause analysis).",
"skills": {
Expand Down
8 changes: 1 addition & 7 deletions skills/root-cause-analysis/rules/workflow-identify-cause.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,7 @@ The dashboard should include:
2. **Logs** — LogTimeline component filtered to the affected service(s) during the incident timeframe (dataSource method: `searchLogsPage` with `serviceName` param)
3. **Traces** — TraceDetail component showing a representative error trace

After dashboard creation, present the link to the user:

```
<baseUrl>/?tab=metrics&dashboardId=<id>
```

Where `<id>` is from the CLI JSON response and `<baseUrl>` is the `--url` flag value or `http://localhost:8000` if omitted.
After dashboard creation, present the link to the user — use the `url` field from the CLI JSON response. It's the fully-resolved dashboard URL based on the active `.kopairc` (or `--url` flag, or default). Do not construct the URL yourself.

### Why create a dashboard?

Expand Down