diff --git a/.changeset/dashboard-url-in-response.md b/.changeset/dashboard-url-in-response.md new file mode 100644 index 0000000..61c2950 --- /dev/null +++ b/.changeset/dashboard-url-in-response.md @@ -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`. diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index eced219..996f395 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -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 diff --git a/packages/cli/src/commands/dashboards.test.ts b/packages/cli/src/commands/dashboards.test.ts new file mode 100644 index 0000000..944232b --- /dev/null +++ b/packages/cli/src/commands/dashboards.test.ts @@ -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(); + 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 { + 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" + ); + }); +}); diff --git a/packages/cli/src/commands/dashboards.ts b/packages/cli/src/commands/dashboards.ts index 4c47afe..e255afd 100644 --- a/packages/cli/src/commands/dashboards.ts +++ b/packages/cli/src/commands/dashboards.ts @@ -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; try { @@ -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); diff --git a/skills/create-dashboard/SKILL.md b/skills/create-dashboard/SKILL.md index e7fe97a..6d95f6b 100644 --- a/skills/create-dashboard/SKILL.md +++ b/skills/create-dashboard/SKILL.md @@ -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 diff --git a/skills/create-dashboard/rules/workflow.md b/skills/create-dashboard/rules/workflow.md index 4385ca7..9030af6 100644 --- a/skills/create-dashboard/rules/workflow.md +++ b/skills/create-dashboard/rules/workflow.md @@ -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: - -``` -/?tab=metrics&dashboardId= -``` - -- `` — the `id` field from the CLI JSON response -- `` — 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: diff --git a/skills/create-dashboard/tile.json b/skills/create-dashboard/tile.json index a680a19..ccf1282 100644 --- a/skills/create-dashboard/tile.json +++ b/skills/create-dashboard/tile.json @@ -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": { diff --git a/skills/root-cause-analysis/rules/workflow-identify-cause.md b/skills/root-cause-analysis/rules/workflow-identify-cause.md index 54037b2..9a9f5f7 100644 --- a/skills/root-cause-analysis/rules/workflow-identify-cause.md +++ b/skills/root-cause-analysis/rules/workflow-identify-cause.md @@ -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: - -``` -/?tab=metrics&dashboardId= -``` - -Where `` is from the CLI JSON response and `` 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?