Skip to content

Commit 9a28f3e

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 9a28f3e

21 files changed

Lines changed: 1684 additions & 33 deletions

src/commands/axon/events.ts

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

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

0 commit comments

Comments
 (0)