Skip to content

Commit 38efd64

Browse files
committed
feat: add TUI features, CLI subcommands, and fix benchmark pagination
TUI features: - Object create form with optional file upload or pre-signed S3 URL copy - Blueprint duplication from detail view - Axon events viewer and SQL workbench - Benchmark tab toggle (Tab key with visual tab bar) CLI subcommands: - blueprint duplicate <name-or-id>: duplicate a blueprint by name or ID - axon events <id>: list events for an axon - scenario list: list scenario runs with pagination Fix: - Benchmark pagination total count now uses API total_count
1 parent 3ef6271 commit 38efd64

21 files changed

Lines changed: 1695 additions & 33 deletions

src/commands/axon/events.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/**
2+
* List axon events command
3+
*/
4+
5+
import chalk from "chalk";
6+
import {
7+
listAxonEvents as listAxonEventsService,
8+
type AxonEvent,
9+
} from "../../services/axonService.js";
10+
import { output, outputError, parseLimit } from "../../utils/output.js";
11+
12+
interface EventsOptions {
13+
limit?: string;
14+
output?: string;
15+
}
16+
17+
function printTable(events: AxonEvent[], hasMore: boolean): void {
18+
if (events.length === 0) {
19+
console.log(chalk.dim("No events found"));
20+
return;
21+
}
22+
23+
const COL_SEQUENCE = 10;
24+
const COL_TIMESTAMP = 24;
25+
const COL_ORIGIN = 12;
26+
const COL_SOURCE = 16;
27+
const COL_EVENT_TYPE = 20;
28+
29+
const header =
30+
"SEQUENCE".padEnd(COL_SEQUENCE) +
31+
" " +
32+
"TIMESTAMP".padEnd(COL_TIMESTAMP) +
33+
" " +
34+
"ORIGIN".padEnd(COL_ORIGIN) +
35+
" " +
36+
"SOURCE".padEnd(COL_SOURCE) +
37+
" " +
38+
"EVENT_TYPE".padEnd(COL_EVENT_TYPE) +
39+
" " +
40+
"PAYLOAD";
41+
console.log(chalk.bold(header));
42+
console.log(chalk.dim("─".repeat(header.length)));
43+
44+
for (const event of events) {
45+
const sequence = String(event.sequence).padEnd(COL_SEQUENCE);
46+
const timestamp = new Date(event.timestamp_ms)
47+
.toISOString()
48+
.padEnd(COL_TIMESTAMP);
49+
const origin =
50+
event.origin.length > COL_ORIGIN
51+
? event.origin.slice(0, COL_ORIGIN - 1) + "…"
52+
: event.origin.padEnd(COL_ORIGIN);
53+
const source =
54+
event.source.length > COL_SOURCE
55+
? event.source.slice(0, COL_SOURCE - 1) + "…"
56+
: event.source.padEnd(COL_SOURCE);
57+
const eventType =
58+
event.event_type.length > COL_EVENT_TYPE
59+
? event.event_type.slice(0, COL_EVENT_TYPE - 1) + "…"
60+
: event.event_type.padEnd(COL_EVENT_TYPE);
61+
const payload =
62+
event.payload.length > 60
63+
? event.payload.slice(0, 59) + "…"
64+
: event.payload;
65+
66+
console.log(
67+
`${sequence} ${timestamp} ${origin} ${source} ${eventType} ${payload}`,
68+
);
69+
}
70+
71+
console.log();
72+
console.log(
73+
chalk.dim(`${events.length} event${events.length !== 1 ? "s" : ""}`),
74+
);
75+
76+
if (hasMore) {
77+
console.log(
78+
chalk.dim("More events available; increase --limit to fetch more."),
79+
);
80+
}
81+
}
82+
83+
export async function listAxonEventsCommand(
84+
axonId: string,
85+
options: EventsOptions,
86+
): Promise<void> {
87+
try {
88+
const parsed = parseLimit(options.limit);
89+
const limit = parsed === Infinity ? 50 : parsed;
90+
const result = await listAxonEventsService(axonId, { limit });
91+
92+
if (options.output && options.output !== "text") {
93+
output(result.events, { format: options.output, defaultFormat: "json" });
94+
return;
95+
}
96+
97+
printTable(result.events, result.hasMore);
98+
} catch (error) {
99+
outputError("Failed to get axon events", error);
100+
}
101+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* Duplicate blueprint command
3+
*/
4+
5+
import { getClient } from "../../utils/client.js";
6+
import { output, outputError } from "../../utils/output.js";
7+
8+
interface DuplicateBlueprintOptions {
9+
name?: string;
10+
output?: string;
11+
}
12+
13+
export async function duplicateBlueprint(
14+
nameOrId: string,
15+
options: DuplicateBlueprintOptions,
16+
) {
17+
try {
18+
const client = getClient();
19+
20+
let source;
21+
22+
if (nameOrId.startsWith("bpt_")) {
23+
source = await client.blueprints.retrieve(nameOrId);
24+
} else {
25+
const result = await client.blueprints.list({ name: nameOrId });
26+
const blueprints = result.blueprints || [];
27+
if (blueprints.length === 0) {
28+
return outputError(`Blueprint not found: ${nameOrId}`);
29+
}
30+
source = blueprints.find((b) => b.name === nameOrId) || blueprints[0];
31+
}
32+
33+
const copyName = options.name ?? (source.name || "blueprint") + "-copy";
34+
35+
const newBlueprint = await client.blueprints.create({
36+
name: copyName,
37+
base_blueprint_id: source.id,
38+
system_setup_commands:
39+
source.parameters?.system_setup_commands ?? undefined,
40+
launch_parameters: source.parameters?.launch_parameters ?? undefined,
41+
metadata: source.metadata ?? undefined,
42+
secrets: source.parameters?.secrets ?? undefined,
43+
file_mounts: source.parameters?.file_mounts ?? undefined,
44+
code_mounts: source.parameters?.code_mounts ?? undefined,
45+
});
46+
47+
output(newBlueprint, { format: options.output, defaultFormat: "json" });
48+
} catch (error) {
49+
outputError("Failed to duplicate blueprint", error);
50+
}
51+
}

src/commands/object/list.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,8 @@ const ListObjectsUI = ({
558558
} else if (input === "a" && selectedObjectItem) {
559559
setShowPopup(true);
560560
setSelectedOperation(0);
561+
} else if (input === "c") {
562+
navigate("object-create");
561563
} else if (input === "/") {
562564
search.enterSearchMode();
563565
} else if (key.escape) {
@@ -841,6 +843,7 @@ const ListObjectsUI = ({
841843
},
842844
{ key: "Enter", label: "Details" },
843845
{ key: "a", label: "Actions" },
846+
{ key: "c", label: "Create" },
844847
{ key: "/", label: "Search" },
845848
{ key: "Esc", label: "Back" },
846849
]}

src/commands/scenario/list.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/**
2+
* List scenario runs command
3+
*/
4+
5+
import chalk from "chalk";
6+
import { listScenarioRuns } from "../../services/benchmarkService.js";
7+
import type { ScenarioRun } from "../../store/benchmarkStore.js";
8+
import { output, outputError, parseLimit } from "../../utils/output.js";
9+
import { formatTimeAgo } from "../../utils/time.js";
10+
11+
interface ListOptions {
12+
limit?: string;
13+
benchmarkRunId?: string;
14+
output?: string;
15+
}
16+
17+
const PAGE_SIZE = 100;
18+
19+
const COL_ID = 34;
20+
const COL_STATUS = 12;
21+
const COL_SCORE = 8;
22+
const COL_CREATED = 12;
23+
const FIXED_WIDTH = COL_ID + COL_STATUS + COL_SCORE + COL_CREATED + 4;
24+
25+
function truncate(str: string, maxLen: number): string {
26+
if (str.length <= maxLen) return str;
27+
return str.slice(0, maxLen - 1) + "…";
28+
}
29+
30+
function colorState(state: string): string {
31+
switch (state) {
32+
case "completed":
33+
case "scored":
34+
return chalk.green(state);
35+
case "running":
36+
case "scoring":
37+
return chalk.yellow(state);
38+
case "failed":
39+
case "timeout":
40+
return chalk.red(state);
41+
case "canceled":
42+
return chalk.dim(state);
43+
default:
44+
return state;
45+
}
46+
}
47+
48+
async function fetchRuns(
49+
benchmarkRunId?: string,
50+
maxResults?: number,
51+
): Promise<ScenarioRun[]> {
52+
const all: ScenarioRun[] = [];
53+
let cursor: string | undefined;
54+
while (true) {
55+
const pageLimit =
56+
maxResults && maxResults > 0
57+
? Math.min(PAGE_SIZE, maxResults - all.length)
58+
: PAGE_SIZE;
59+
const result = await listScenarioRuns({
60+
limit: pageLimit,
61+
startingAfter: cursor,
62+
benchmarkRunId,
63+
});
64+
all.push(...result.scenarioRuns);
65+
if (!result.hasMore || result.scenarioRuns.length === 0) break;
66+
if (maxResults && maxResults > 0 && all.length >= maxResults) break;
67+
cursor = result.scenarioRuns[result.scenarioRuns.length - 1].id;
68+
}
69+
return all;
70+
}
71+
72+
function printTable(runs: ScenarioRun[]): void {
73+
if (runs.length === 0) {
74+
console.log(chalk.dim("No scenario runs found"));
75+
return;
76+
}
77+
78+
const termWidth = process.stdout.columns || 120;
79+
const nameWidth = Math.max(10, termWidth - FIXED_WIDTH);
80+
81+
const header =
82+
"ID".padEnd(COL_ID) +
83+
" " +
84+
"NAME".padEnd(nameWidth) +
85+
" " +
86+
"STATUS".padEnd(COL_STATUS) +
87+
" " +
88+
"SCORE".padStart(COL_SCORE) +
89+
" " +
90+
"CREATED".padEnd(COL_CREATED);
91+
console.log(chalk.bold(header));
92+
console.log(chalk.dim("─".repeat(Math.min(header.length, termWidth))));
93+
94+
for (const run of runs) {
95+
const id = truncate(run.id, COL_ID).padEnd(COL_ID);
96+
const name = truncate(run.name || "", nameWidth).padEnd(nameWidth);
97+
const status = colorState(run.state || "unknown");
98+
const statusRaw = run.state || "unknown";
99+
const statusPad = " ".repeat(Math.max(0, COL_STATUS - statusRaw.length));
100+
101+
let scoreStr: string;
102+
const score = run.scoring_contract_result?.score;
103+
if (score !== undefined && score !== null) {
104+
const pct = Math.round(score * 100);
105+
const pctStr = `${pct}%`.padStart(COL_SCORE);
106+
scoreStr = pct >= 50 ? chalk.green(pctStr) : chalk.yellow(pctStr);
107+
} else {
108+
scoreStr = chalk.dim("N/A".padStart(COL_SCORE));
109+
}
110+
111+
const created = run.start_time_ms
112+
? formatTimeAgo(run.start_time_ms).padEnd(COL_CREATED)
113+
: chalk.dim("N/A".padEnd(COL_CREATED));
114+
115+
console.log(`${id} ${name} ${status}${statusPad} ${scoreStr} ${created}`);
116+
}
117+
118+
console.log();
119+
console.log(chalk.dim(`${runs.length} run${runs.length !== 1 ? "s" : ""}`));
120+
}
121+
122+
export async function listScenarioRunsCommand(
123+
options: ListOptions,
124+
): Promise<void> {
125+
try {
126+
const maxResults = parseLimit(options.limit);
127+
const runs = await fetchRuns(
128+
options.benchmarkRunId,
129+
maxResults === Infinity ? undefined : maxResults,
130+
);
131+
132+
runs.sort((a, b) => (a.start_time_ms || 0) - (b.start_time_ms || 0));
133+
134+
const format = options.output || "text";
135+
if (format !== "text") {
136+
output(runs, { format, defaultFormat: "json" });
137+
} else {
138+
printTable(runs);
139+
}
140+
} catch (error) {
141+
outputError("Failed to list scenario runs", error);
142+
}
143+
}

0 commit comments

Comments
 (0)