Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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/cli-create-dashboards-saved-searches.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hyperdx/cli': minor
---

Add `hdx dashboards create --file <json>` and a new `hdx saved-searches` command group (`list` / `create`) so dashboards and saved searches can be created from the terminal. Dashboard definitions are validated locally against the shared schema before being sent, and missing tile ids are generated automatically. Also fixes `getSavedSearches()` to call the correct `/saved-search` API path (the previous `/saved-searches` path always returned 404).
9 changes: 6 additions & 3 deletions packages/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ hdx tui -a <url> # Interactive TUI (main command)
hdx sources -a <url> # List available sources (with schemas)
hdx connections # List ClickHouse connections (id, name, host)
hdx dashboards # List dashboards with tile summaries
hdx dashboards create -f <json> # Create a dashboard from a JSON definition
hdx saved-searches [list] # List saved searches
hdx saved-searches create # Create a saved search (--name/--source/--where)
hdx chart -d <dashboard> [-t tile] # Render dashboard tiles as ANSI charts
hdx chart -s <source> [--agg ...] # Ad-hoc chart over a source (builder mode)
hdx chart --sql <query> -s <source> # Ad-hoc chart from raw SQL
Expand All @@ -41,9 +44,9 @@ the CLI falls back to the app URL saved in the session file from a previous
```
src/
├── cli.tsx # Entry point — Commander CLI with commands:
│ # tui, sources, connections, dashboards, chart,
│ # query, auth (login/logout/status), team,
│ # upload-sourcemaps
│ # tui, sources, connections, dashboards (list/create),
│ # saved-searches (list/create), chart, query,
│ # auth (login/logout/status), team, upload-sourcemaps
│ # Also contains the standalone LoginPrompt component
├── App.tsx # App shell — state machine:
│ # loading → login → pick-source → EventViewer
Expand Down
52 changes: 49 additions & 3 deletions packages/cli/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,30 @@ import {
} from '@hyperdx/common-utils/dist/core/metadata';

import { loadSession, saveSession, clearSession } from '@/utils/config';
import {
import type {
AlertThresholdType,
DashboardWithoutId,
MetricTable,
SavedSearch,
Tile,
UseTextIndex,
} from '@hyperdx/common-utils/dist/types';

/**
* Extract a readable detail string from an error response body.
* Handles both plain-text errors and zod-express-middleware's JSON
* validation issue arrays.
*/
async function formatErrorBody(res: Response): Promise<string> {
try {
const text = await res.text();
if (!text) return '';
return `\n${text.length > 2000 ? `${text.slice(0, 2000)}…` : text}`;
} catch {
return '';
}
}

// ------------------------------------------------------------------
// API Client (session management + REST calls)
// ------------------------------------------------------------------
Expand Down Expand Up @@ -232,17 +249,43 @@ export class ApiClient {
}

async getSavedSearches(): Promise<SavedSearchResponse[]> {
const res = await this.get('/saved-searches');
if (!res.ok) throw new Error(`GET /saved-searches failed: ${res.status}`);
// NOTE: the API mounts this router at the singular `/saved-search`
// (see packages/api/src/api-app.ts).
const res = await this.get('/saved-search');
if (!res.ok) throw new Error(`GET /saved-search failed: ${res.status}`);
return res.json() as Promise<SavedSearchResponse[]>;
}

async createSavedSearch(
input: SavedSearchCreateInput,
): Promise<SavedSearchResponse> {
const res = await this.post('/saved-search', input);
if (!res.ok) {
throw new Error(
`POST /saved-search failed: ${res.status}${await formatErrorBody(res)}`,
);
}
return res.json() as Promise<SavedSearchResponse>;
}

async getDashboards(): Promise<DashboardResponse[]> {
const res = await this.get('/dashboards');
if (!res.ok) throw new Error(`GET /dashboards failed: ${res.status}`);
return res.json() as Promise<DashboardResponse[]>;
}

async createDashboard(
input: DashboardWithoutId,
): Promise<DashboardResponse> {
const res = await this.post('/dashboards', input);
if (!res.ok) {
throw new Error(
`POST /dashboards failed: ${res.status}${await formatErrorBody(res)}`,
);
}
return res.json() as Promise<DashboardResponse>;
}

async getAlerts(): Promise<AlertsResponse> {
const res = await this.get('/alerts');
if (!res.ok) throw new Error(`GET /alerts failed: ${res.status}`);
Expand Down Expand Up @@ -490,6 +533,9 @@ interface ConnectionResponse {
username: string;
}

/** Body accepted by POST /saved-search (SavedSearchSchema minus the id). */
export type SavedSearchCreateInput = Omit<SavedSearch, 'id'>;

export interface SavedSearchResponse {
id: string;
_id: string;
Expand Down
Loading
Loading