diff --git a/acceptance/experimental/air/list/output.txt b/acceptance/experimental/air/list/output.txt index e27cfb0bf39..6c1eca7d7f2 100644 --- a/acceptance/experimental/air/list/output.txt +++ b/acceptance/experimental/air/list/output.txt @@ -1,8 +1,8 @@ === list (text) >>> [CLI] experimental air list - Run ID Experiment Status Started Duration MLflow User Accelerators - [NUMID] qwen-train ● SUCCESS [TIMESTAMP] 12s …/runs/run1 [USERNAME] 8x H100 + Run ID Experiment Status Started Duration MLflow User Accelerators + [NUMID] qwen-train ● SUCCESS [TIMESTAMP] 12s qwen-train-001 [USERNAME] 8x H100 === list (json) >>> [CLI] experimental air list -o json @@ -25,8 +25,8 @@ === list --all-status (text, via AiTrainingService index) >>> [CLI] experimental air list --all-status - Run ID Experiment Status Started Duration MLflow User Accelerators - [NUMID] qwen-train ● SUCCESS [TIMESTAMP] 12s …/runs/run1 [USERNAME] 8x H100 + Run ID Experiment Status Started Duration MLflow User Accelerators + [NUMID] qwen-train ● SUCCESS [TIMESTAMP] 12s qwen-train-001 [USERNAME] 8x H100 === list --all-status (json) >>> [CLI] experimental air list --all-status -o json diff --git a/acceptance/experimental/air/list/test.toml b/acceptance/experimental/air/list/test.toml index bcaa75fb34a..9b68f61173b 100644 --- a/acceptance/experimental/air/list/test.toml +++ b/acceptance/experimental/air/list/test.toml @@ -61,6 +61,19 @@ Response.Body = ''' {"training_workflows": [{"job_run_id": "334747067049496", "submit_time": "2024-06-05T17:32:39Z"}]} ''' +# MLflow run names (for the MLflow column label) are fetched per AIR run (text mode). +[[Server]] +Pattern = "GET /api/2.0/mlflow/runs/get" +Response.Body = ''' +{ + "run": { + "info": { + "run_name": "qwen-train-001" + } + } +} +''' + # runs/get hydrates one index id into the same shape as a runs/list element. [[Server]] Pattern = "GET /api/2.2/jobs/runs/get" diff --git a/experimental/air/cmd/list.go b/experimental/air/cmd/list.go index 7d5c703a39c..c5cc64aafdf 100644 --- a/experimental/air/cmd/list.go +++ b/experimental/air/cmd/list.go @@ -46,10 +46,13 @@ type listRow struct { // Experiment, Duration, MLflowURL and Accelerators are table-only columns, // omitted from JSON to match `air list --json`. - Experiment string `json:"-"` - Duration string `json:"-"` - MLflowURL string `json:"-"` - Accelerators string `json:"-"` + Experiment string `json:"-"` + Duration string `json:"-"` + MLflowURL string `json:"-"` + MLflowLabel string `json:"-"` + RunURL string `json:"-"` + ExperimentURL string `json:"-"` + Accelerators string `json:"-"` } // listedRun pairs a row with its task run id, so the MLflow link can be fetched @@ -68,6 +71,7 @@ type listQuery struct { filters listFilters fetchMLflow bool limit int + workspaceID int64 } func newListCommand() *cobra.Command { @@ -118,6 +122,15 @@ func newListCommand() *cobra.Command { userFilter = currentUser } + // Fetch workspace ID once for dashboard links; proceed with 0 on error. + var workspaceID int64 + wsID, err := w.CurrentWorkspaceID(ctx) + if err != nil { + log.Debugf(ctx, "air list: could not fetch workspace ID for dashboard links: %v", err) + } else { + workspaceID = wsID + } + fetcher := newRunFetcher(ctx, w, listQuery{ activeOnly: !allStatus, allUsers: allUsers, @@ -126,6 +139,7 @@ func newListCommand() *cobra.Command { filters: f, fetchMLflow: root.OutputType(cmd) == flags.OutputText, limit: limit, + workspaceID: workspaceID, }) // JSON prints the newest `limit` runs once. Text renders the table: @@ -165,6 +179,7 @@ type runFetcher struct { w *databricks.WorkspaceClient fetchMLflow bool strategy listStrategy + workspaceID int64 exhausted bool } @@ -175,6 +190,7 @@ func newRunFetcher(ctx context.Context, w *databricks.WorkspaceClient, q listQue w: w, fetchMLflow: q.fetchMLflow, strategy: newListStrategy(ctx, w, q), + workspaceID: q.workspaceID, } } @@ -209,7 +225,7 @@ func (f *runFetcher) next(want int) ([]listRow, error) { // MLflow links appear only in the text table, so the per-run get-output // lookups are skipped for JSON output (which omits the column anyway). if f.fetchMLflow { - setMLflowLinks(f.ctx, f.w, entries) + setMLflowLinks(f.ctx, f.w, f.w.Config.Host, entries) } rows := make([]listRow, len(entries)) @@ -223,11 +239,13 @@ func (f *runFetcher) next(want int) ([]listRow, error) { // and filters. It buffers a page's leftover runs so successive next() calls // resume where the last stopped. type jobsScanStrategy struct { - ctx context.Context - w *databricks.WorkspaceClient - iter listing.Iterator[jobs.BaseRun] - userFilter string - filters listFilters + ctx context.Context + w *databricks.WorkspaceClient + iter listing.Iterator[jobs.BaseRun] + userFilter string + filters listFilters + host string + workspaceID int64 scanned int } @@ -240,11 +258,13 @@ func newJobsScanStrategy(ctx context.Context, w *databricks.WorkspaceClient, q l ActiveOnly: q.activeOnly, } return &jobsScanStrategy{ - ctx: ctx, - w: w, - iter: w.Jobs.ListRuns(ctx, req), - userFilter: q.userFilter, - filters: q.filters, + ctx: ctx, + w: w, + iter: w.Jobs.ListRuns(ctx, req), + userFilter: q.userFilter, + filters: q.filters, + host: w.Config.Host, + workspaceID: q.workspaceID, } } @@ -267,7 +287,7 @@ func (s *jobsScanStrategy) next(want int) ([]listedRun, error) { if !s.filters.matches(run) { continue } - entries = append(entries, listedRun{row: buildListRow(run), taskRunID: taskRunID(run)}) + entries = append(entries, listedRun{row: buildListRow(run, s.host, s.workspaceID), taskRunID: taskRunID(run)}) } return entries, nil } @@ -288,15 +308,18 @@ func warnIfTruncated(ctx context.Context, f *runFetcher) { } } -// setMLflowLinks fills in each row's MLflow link in parallel, best-effort: a row -// whose IDs can't be resolved keeps its "-" placeholder. -func setMLflowLinks(ctx context.Context, w *databricks.WorkspaceClient, entries []listedRun) { +// setMLflowLinks fills in each row's MLflow link, label, and experiment URL in +// parallel, best-effort: a row whose IDs can't be resolved keeps its "-" placeholder. +func setMLflowLinks(ctx context.Context, w *databricks.WorkspaceClient, host string, entries []listedRun) { var g errgroup.Group g.SetLimit(enrichConcurrency) for i := range entries { g.Go(func() error { if ids := mlflowIDsForTask(ctx, w, entries[i].taskRunID); ids != nil { - entries[i].row.MLflowURL = mlflowLogsURL(w.Config.Host, ids) + entries[i].row.MLflowURL = mlflowLogsURL(host, ids) + name := fetchMLflowRunName(ctx, w, ids.RunID) + entries[i].row.MLflowLabel = mlflowRunLabel(name, ids.RunID) + entries[i].row.ExperimentURL = mlflowExperimentURL(host, ids) } return nil }) diff --git a/experimental/air/cmd/list_cache.go b/experimental/air/cmd/list_cache.go index 6f0b95a97d3..1b2d47d57d3 100644 --- a/experimental/air/cmd/list_cache.go +++ b/experimental/air/cmd/list_cache.go @@ -28,25 +28,29 @@ type listCacheKey struct { // table-only columns, which listRow tags json:"-" and so wouldn't survive a // direct marshal), the filter inputs, and the submit time. type cachedRun struct { - RunID string `json:"run_id"` - RunName string `json:"run_name"` - User string `json:"user"` - Status string `json:"status"` - StartedAt *string `json:"started_at"` - IsSweep bool `json:"is_sweep"` - Experiment string `json:"experiment"` - Duration string `json:"duration"` - MLflowURL string `json:"mlflow_url"` - Accelerators string `json:"accelerators"` - Fields filterFields `json:"filter_fields"` - SubmitTimeMs int64 `json:"submit_time_ms"` + RunID string `json:"run_id"` + RunName string `json:"run_name"` + User string `json:"user"` + Status string `json:"status"` + StartedAt *string `json:"started_at"` + IsSweep bool `json:"is_sweep"` + Experiment string `json:"experiment"` + Duration string `json:"duration"` + MLflowURL string `json:"mlflow_url"` + MLflowLabel string `json:"mlflow_label"` + RunURL string `json:"run_url"` + ExperimentURL string `json:"experiment_url"` + Accelerators string `json:"accelerators"` + Fields filterFields `json:"filter_fields"` + SubmitTimeMs int64 `json:"submit_time_ms"` } func (c cachedRun) toRow() listRow { return listRow{ RunID: c.RunID, RunName: c.RunName, User: c.User, Status: c.Status, StartedAt: c.StartedAt, IsSweep: c.IsSweep, Experiment: c.Experiment, - Duration: c.Duration, MLflowURL: c.MLflowURL, Accelerators: c.Accelerators, + Duration: c.Duration, MLflowURL: c.MLflowURL, MLflowLabel: c.MLflowLabel, + RunURL: c.RunURL, ExperimentURL: c.ExperimentURL, Accelerators: c.Accelerators, } } @@ -54,7 +58,8 @@ func cachedRunFromRow(r listRow, fields filterFields, submitTimeMs int64) cached return cachedRun{ RunID: r.RunID, RunName: r.RunName, User: r.User, Status: r.Status, StartedAt: r.StartedAt, IsSweep: r.IsSweep, Experiment: r.Experiment, - Duration: r.Duration, MLflowURL: r.MLflowURL, Accelerators: r.Accelerators, + Duration: r.Duration, MLflowURL: r.MLflowURL, MLflowLabel: r.MLflowLabel, + RunURL: r.RunURL, ExperimentURL: r.ExperimentURL, Accelerators: r.Accelerators, Fields: fields, SubmitTimeMs: submitTimeMs, } } diff --git a/experimental/air/cmd/list_detail.go b/experimental/air/cmd/list_detail.go new file mode 100644 index 00000000000..c0eca25b13f --- /dev/null +++ b/experimental/air/cmd/list_detail.go @@ -0,0 +1,64 @@ +package aircmd + +import ( + "bytes" + "context" + "errors" + "fmt" + + "github.com/databricks/databricks-sdk-go" + "github.com/databricks/databricks-sdk-go/apierr" + "github.com/databricks/databricks-sdk-go/service/jobs" +) + +// runDetailText fetches a run and renders the same styled view as `air get` into +// a string, for the list picker's detail pane. +func runDetailText(ctx context.Context, w *databricks.WorkspaceClient, runID int64) (string, error) { + run, err := w.Jobs.GetRun(ctx, jobs.GetRunRequest{RunId: runID}) + if err != nil { + if errors.Is(err, apierr.ErrResourceDoesNotExist) { + return "", fmt.Errorf("run %d not found", runID) + } + return "", fmt.Errorf("failed to fetch run: %w", err) + } + + // A missing workspace id only drops the ?o= org hint from the dashboard link. + workspaceID, _ := w.CurrentWorkspaceID(ctx) + + data := buildGetData(run) + data.DashboardURL = dashboardURL(w.Config.Host, runID, workspaceID) + ids := mlflowIDs(ctx, w, run) + if ids != nil { + url := mlflowLogsURL(w.Config.Host, ids) + data.MLflowURL = &url + } + + var buf bytes.Buffer + renderRunText(ctx, &buf, w, run, &data, ids) + return buf.String(), nil +} + +// runLogsSnapshot fetches a one-shot tail of a run's logs into a string, for the +// list picker's detail pane. +func runLogsSnapshot(ctx context.Context, w *databricks.WorkspaceClient, runID int64) (string, error) { + status, err := resolveRunStatus(ctx, w, runID) + if err != nil { + if errors.Is(err, apierr.ErrResourceDoesNotExist) { + return "", fmt.Errorf("run %d not found", runID) + } + return "", fmt.Errorf("failed to fetch run status: %w", err) + } + + req := logRequest{ + runID: runID, + attempt: -1, // latest attempt + tailLines: 1000, // one-shot tail, not a live follow + staticView: true, + } + + var buf bytes.Buffer + if _, err := fetchLogs(ctx, w, &buf, req, status); err != nil { + return "", fmt.Errorf("failed to fetch logs: %w", err) + } + return buf.String(), nil +} diff --git a/experimental/air/cmd/list_format.go b/experimental/air/cmd/list_format.go index bfa74728cc3..393b48a768c 100644 --- a/experimental/air/cmd/list_format.go +++ b/experimental/air/cmd/list_format.go @@ -9,7 +9,8 @@ import ( // buildListRow extracts the columns shown for one run. Optional cells fall back // to "-"; MLflowURL starts as "-" and setMLflowLinks fills it in for text output. -func buildListRow(run *jobs.Run) listRow { +// host and workspaceID are used for building dashboard URLs. +func buildListRow(run *jobs.Run, host string, workspaceID int64) listRow { experiment := "-" if e := jobExperiment(run); e != "" { experiment = e @@ -42,6 +43,8 @@ func buildListRow(run *jobs.Run) listRow { Experiment: experiment, Duration: duration, MLflowURL: "-", + MLflowLabel: "-", + RunURL: dashboardURL(host, run.RunId, workspaceID), Accelerators: accel, } } diff --git a/experimental/air/cmd/list_index.go b/experimental/air/cmd/list_index.go index 243affd664f..6e42c1695eb 100644 --- a/experimental/air/cmd/list_index.go +++ b/experimental/air/cmd/list_index.go @@ -16,12 +16,14 @@ import ( // skip the network. Unlike the Jobs scan it can't lazy-page (it must sort the // whole id set first), but it still yields in batches so the table paints early. type indexStrategy struct { - ctx context.Context - w *databricks.WorkspaceClient - activeOnly bool - filters listFilters - limit int - cache *cache.Cache + ctx context.Context + w *databricks.WorkspaceClient + activeOnly bool + filters listFilters + limit int + cache *cache.Cache + host string + workspaceID int64 ids []int64 // newest-first run ids to hydrate, resolved on first next() pos int @@ -30,12 +32,14 @@ type indexStrategy struct { func newIndexStrategy(ctx context.Context, w *databricks.WorkspaceClient, q listQuery, limit int) *indexStrategy { return &indexStrategy{ - ctx: ctx, - w: w, - activeOnly: q.activeOnly, - filters: q.filters, - limit: limit, - cache: newListCache(ctx), + ctx: ctx, + w: w, + activeOnly: q.activeOnly, + filters: q.filters, + limit: limit, + cache: newListCache(ctx), + host: w.Config.Host, + workspaceID: q.workspaceID, } } @@ -120,7 +124,7 @@ func (s *indexStrategy) hydrate(ids []int64) ([]listedRun, error) { if !s.filters.matchesFields(fields) { continue } - row := buildListRow(run) + row := buildListRow(run, s.host, s.workspaceID) rows = append(rows, listedRun{row: row, taskRunID: taskRunID(run)}) if isTerminal(run) { start, _ := jobTiming(run) diff --git a/experimental/air/cmd/list_test.go b/experimental/air/cmd/list_test.go index f70330240e5..058b73a2e7d 100644 --- a/experimental/air/cmd/list_test.go +++ b/experimental/air/cmd/list_test.go @@ -167,7 +167,7 @@ func TestBuildListRowFromRun(t *testing.T) { assert.Equal(t, "GPU_1xA10", gpu) assert.Equal(t, 1, count) - row := buildListRow(&run) + row := buildListRow(&run, "https://example.test", 0) assert.Equal(t, "842552489592352", row.RunID) assert.Equal(t, "SUCCESS", row.Status) assert.Equal(t, "my-first-air-run", row.Experiment) @@ -181,7 +181,7 @@ func TestBuildListRow(t *testing.T) { run.EndTime = 1700000012000 run.State = &jobs.RunState{ResultState: jobs.RunResultStateSuccess} - row := buildListRow(&run) + row := buildListRow(&run, "https://example.test", 0) assert.Equal(t, "123", row.RunID) assert.Equal(t, "me@example.com", row.User) assert.Equal(t, "SUCCESS", row.Status) @@ -195,7 +195,7 @@ func TestBuildListRow(t *testing.T) { func TestBuildListRowDashFallbacks(t *testing.T) { // A run with no task, compute, or start time falls back to dashes and UNKNOWN. - row := buildListRow(&jobs.Run{RunId: 7}) + row := buildListRow(&jobs.Run{RunId: 7}, "https://example.test", 0) assert.Equal(t, "-", row.Experiment) assert.Equal(t, "-", row.Duration) assert.Equal(t, "-", row.Accelerators) @@ -208,8 +208,8 @@ func TestBuildListRowSweep(t *testing.T) { run := jobs.Run{RunId: 9, Tasks: []jobs.RunTask{{ ForEachTask: &jobs.RunForEachTask{Task: jobs.Task{AiRuntimeTask: &jobs.AiRuntimeTask{Experiment: "sweep"}}}, }}} - assert.True(t, buildListRow(&run).IsSweep) - assert.Equal(t, "sweep", buildListRow(&run).Experiment) + assert.True(t, buildListRow(&run, "https://example.test", 0).IsSweep) + assert.Equal(t, "sweep", buildListRow(&run, "https://example.test", 0).Experiment) } func TestListInvalidLimit(t *testing.T) { diff --git a/experimental/air/cmd/list_tui.go b/experimental/air/cmd/list_tui.go index 85fe70774b4..4707fd55fce 100644 --- a/experimental/air/cmd/list_tui.go +++ b/experimental/air/cmd/list_tui.go @@ -3,8 +3,10 @@ package aircmd import ( "fmt" "io" + "strconv" "strings" + "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/databricks/cli/libs/cmdio" @@ -73,6 +75,19 @@ func staticListTable(r *lipgloss.Renderer, rows []listRow, links bool) string { return b.String() } +// Mode constants for the TUI. +const ( + modeList = 0 + modeDetail = 1 +) + +// detailMsg carries fetched run details or error. +type detailMsg struct { + title string + body string + err error +} + // listModel is the inline, navigable runs table. It lazily pages older runs from // the fetcher as the cursor nears the end of the loaded rows. fetcher is nil for // a fixed, non-paging table (e.g. in tests). @@ -88,15 +103,23 @@ type listModel struct { cursor int offset int // index of the first visible row height int // terminal height, for windowing + + mode int // modeList or modeDetail + viewport viewport.Model // for detail pane + detailTitle string // title of detail pane + detailLoading bool // loading detail + detailContent string // rendered detail content } func newListModel(r *lipgloss.Renderer, f *runFetcher, rows []listRow, links bool) listModel { return listModel{ - rows: rows, - styles: newListStyles(r), - cols: computeListCols(rows), - links: links, - fetcher: f, + rows: rows, + styles: newListStyles(r), + cols: computeListCols(rows), + links: links, + fetcher: f, + mode: modeList, + viewport: viewport.New(0, 0), } } @@ -148,6 +171,8 @@ func (m listModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.height = msg.Height + m.viewport.Width = msg.Width + m.viewport.Height = max(msg.Height-3, 1) m.offset = m.clampedOffset() return m.maybeFetch() @@ -167,9 +192,46 @@ func (m listModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case detailMsg: + // The fetch is async: if the user pressed esc back to the list before it + // resolved, drop the late result rather than snapping them into a pane + // they already dismissed. + if m.mode != modeDetail { + return m, nil + } + m.detailLoading = false + if msg.err != nil { + m.detailContent = fmt.Sprintf("Error: %v", msg.err) + } else { + m.detailContent = msg.body + } + m.detailTitle = msg.title + m.viewport.SetContent(m.detailContent) + m.viewport.GotoTop() + return m, nil + case tea.KeyMsg: + // Detail pane key handling. + if m.mode == modeDetail { + switch msg.String() { + case "esc", "q": + m.mode = modeList + return m, nil + case "ctrl+c": + return m, tea.Quit + default: + // Delegate scrolling keys to the viewport. + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd + } + } + + // List pane key handling. switch msg.String() { - case "q", "ctrl+c", "esc": + case "q", "ctrl+c": + return m, tea.Quit + case "esc": return m, tea.Quit case "up", "k": if m.cursor > 0 { @@ -194,6 +256,24 @@ func (m listModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, openURL(url) } } + case "i": + // Open the run details pane; the fetch fills it in. + if m.fetcher != nil && len(m.rows) > 0 { + m.mode = modeDetail + m.detailLoading = true + m.detailTitle = "Run Details" + runID, _ := parseRunID(m.rows[m.cursor].RunID) + return m, m.fetchRunDetail(runID) + } + case "L", "l": + // Open the logs snapshot pane; the fetch fills it in. + if m.fetcher != nil && len(m.rows) > 0 { + m.mode = modeDetail + m.detailLoading = true + m.detailTitle = "Logs Snapshot" + runID, _ := parseRunID(m.rows[m.cursor].RunID) + return m, m.fetchRunLogs(runID) + } } m.offset = m.clampedOffset() return m.maybeFetch() @@ -212,6 +292,19 @@ func (m listModel) clampedOffset() int { } func (m listModel) View() string { + if m.mode == modeDetail { + faint := m.styles.r.NewStyle().Foreground(colN7) + lines := []string{m.detailTitle} + if m.detailLoading { + lines = append(lines, "Loading…") + } else { + lines = append(lines, m.viewport.View()) + } + footer := faint.Render("↑/↓ scroll · esc back · q quit") + lines = append(lines, footer) + return strings.Join(lines, "\n") + } + if len(m.rows) == 0 { return m.styles.r.NewStyle().Foreground(colN9).Render("No runs found.") + "\n" } @@ -229,7 +322,7 @@ func (m listModel) View() string { // paging state (loading / load failed). func (m listModel) renderHint() string { faint := m.styles.r.NewStyle().Foreground(colN7) - hint := fmt.Sprintf("↑/↓ navigate · ←/→ page · ↵ mlflow · q quit · row %d/%d", m.cursor+1, len(m.rows)) + hint := fmt.Sprintf("↑/↓ navigate · ←/→ page · ↵ mlflow · i info · L logs · q quit · row %d/%d", m.cursor+1, len(m.rows)) switch { case m.loadErr != nil: hint += " (load failed)" @@ -239,6 +332,32 @@ func (m listModel) renderHint() string { return faint.Render(hint) } +// fetchRunDetail returns a tea.Cmd that fetches run details in the background. +func (m listModel) fetchRunDetail(runID int64) tea.Cmd { + ctx := m.fetcher.ctx + w := m.fetcher.w + return func() tea.Msg { + body, err := runDetailText(ctx, w, runID) + if err != nil { + return detailMsg{title: "Run Details", err: err} + } + return detailMsg{title: "Run Details", body: body} + } +} + +// fetchRunLogs returns a tea.Cmd that fetches a log snapshot in the background. +func (m listModel) fetchRunLogs(runID int64) tea.Cmd { + ctx := m.fetcher.ctx + w := m.fetcher.w + return func() tea.Msg { + body, err := runLogsSnapshot(ctx, w, runID) + if err != nil { + return detailMsg{title: "Logs Snapshot", err: err} + } + return detailMsg{title: "Logs Snapshot", body: body} + } +} + // openURL opens a URL in the user's default browser, best-effort. func openURL(url string) tea.Cmd { return func() tea.Msg { @@ -246,3 +365,9 @@ func openURL(url string) tea.Cmd { return nil } } + +// parseRunID parses a run id string to int64. Rows carry a formatted int64, so +// this only fails on an unexpectedly malformed value. +func parseRunID(runIDStr string) (int64, error) { + return strconv.ParseInt(runIDStr, 10, 64) +} diff --git a/experimental/air/cmd/list_tui_render.go b/experimental/air/cmd/list_tui_render.go index 6a81deceb5c..ad70369230c 100644 --- a/experimental/air/cmd/list_tui_render.go +++ b/experimental/air/cmd/list_tui_render.go @@ -22,7 +22,7 @@ const ( colBlue = lipgloss.Color("#6CA8F0") // MLflow link ) -const mlflowColWidth = 18 +const mlflowColWidth = 22 // listStyles renders the runs table. The renderer carries the color profile, so // styles render plain under --no-color / non-tty. @@ -105,10 +105,21 @@ func (s listStyles) renderRow(cols listCols, r listRow, selected, links bool) st gutter = "▸" } + runIDLink := "" + if links { + runIDLink = r.RunURL + } + experimentLink := "" + if links { + experimentLink = r.ExperimentURL + } + + // Underline only cells that actually carry a link, so unlinked text isn't + // styled as clickable. cells := []string{ s.cell(base, gutter, 1, fg(colN7), false, false, ""), - s.cell(base, r.RunID, cols.runID, fg(colRunID), false, false, ""), - s.cell(base, r.Experiment, cols.experiment, fg(colN11), false, false, ""), + s.cell(base, r.RunID, cols.runID, fg(colRunID), false, runIDLink != "", runIDLink), + s.cell(base, r.Experiment, cols.experiment, fg(colN11), false, experimentLink != "", experimentLink), s.cell(base, "● "+r.Status, cols.status, fg(statusColor(r.Status)), false, false, ""), s.cell(base, startedDisplay(r), cols.started, fg(colN9), false, false, ""), s.cell(base, r.Duration, cols.duration, fg(colN9), true, false, ""), @@ -159,7 +170,11 @@ func (s listStyles) mlflowCell(base lipgloss.Style, r listRow, selected, links b if links { link = r.MLflowURL } - return s.cell(base, mlflowDisplay(r.MLflowURL), mlflowColWidth, fg, false, true, link) + label := r.MLflowLabel + if label == "" || label == "-" { + label = "-" + } + return s.cell(base, label, mlflowColWidth, fg, false, true, link) } // statusColor maps an air run status word to its data color. @@ -189,29 +204,6 @@ func startedDisplay(r listRow) string { return s } -// mlflowDisplay shortens an MLflow run URL to a "…/runs/" label; the -// OSC 8 target keeps the full URL. -func mlflowDisplay(url string) string { - id := mlflowRunID(url) - if id == "" { - return truncate(url, mlflowColWidth) - } - if len(id) > 8 { - id = id[:8] + "…" - } - return "…/runs/" + id -} - -// mlflowRunID extracts the run-id path segment from an MLflow URL. -func mlflowRunID(url string) string { - _, after, ok := strings.Cut(url, "/runs/") - if !ok { - return "" - } - id, _, _ := strings.Cut(after, "/") - return id -} - // pad pads (or truncates) s to a visible width of n, right-aligned when right is // set. It measures visible width, so it is safe on styled strings. func pad(s string, n int, right bool) string { diff --git a/experimental/air/cmd/list_tui_test.go b/experimental/air/cmd/list_tui_test.go index ba709bbf35c..8b52417ee23 100644 --- a/experimental/air/cmd/list_tui_test.go +++ b/experimental/air/cmd/list_tui_test.go @@ -6,7 +6,6 @@ import ( "testing" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" "github.com/databricks/cli/libs/cmdio" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -16,9 +15,9 @@ import ( // absent MLflow link, and a still-running (no end) row. func testListRows() []listRow { return []listRow{ - {RunID: "1", Experiment: "qwen-train", User: "me@example.com", Status: "SUCCESS", StartedAt: new("2026-06-05T17:32:39.000000+00:00"), Duration: "1m 14s", MLflowURL: "https://h/ml/experiments/E/runs/04c41514fbb0/artifacts/logs/node_0", Accelerators: "8x H100"}, - {RunID: "2", Experiment: "llama-train", User: "me@example.com", Status: "RUNNING", StartedAt: new("2026-06-05T18:43:24.000000+00:00"), Duration: "3m 32s", MLflowURL: "-", Accelerators: "1x A10"}, - {RunID: "3", Experiment: "mixtral", User: "me@example.com", Status: "FAILED", StartedAt: nil, Duration: "-", MLflowURL: "-", Accelerators: "-"}, + {RunID: "1", Experiment: "qwen-train", User: "me@example.com", Status: "SUCCESS", StartedAt: new("2026-06-05T17:32:39.000000+00:00"), Duration: "1m 14s", MLflowURL: "https://h/ml/experiments/E/runs/04c41514fbb0/artifacts/logs/node_0", MLflowLabel: "qwen-run-001", Accelerators: "8x H100"}, + {RunID: "2", Experiment: "llama-train", User: "me@example.com", Status: "RUNNING", StartedAt: new("2026-06-05T18:43:24.000000+00:00"), Duration: "3m 32s", MLflowURL: "-", MLflowLabel: "-", Accelerators: "1x A10"}, + {RunID: "3", Experiment: "mixtral", User: "me@example.com", Status: "FAILED", StartedAt: nil, Duration: "-", MLflowURL: "-", MLflowLabel: "-", Accelerators: "-"}, } } @@ -161,7 +160,7 @@ func TestListModelView(t *testing.T) { for _, want := range []string{ "Run ID", "Experiment", "Status", "Started", "Duration", "MLflow", "User", "Accelerators", "qwen-train", "● SUCCESS", "● RUNNING", "● FAILED", - "…/runs/04c41514…", // shortened MLflow link + "qwen-run-001", // MLflow run label "2026-06-05T17:32:39", // started trimmed to seconds "▸", // selection gutter on the first row "↑/↓ navigate", // hint line @@ -176,7 +175,7 @@ func TestStaticListTable(t *testing.T) { assert.NotContains(t, out, "\x1b") assert.NotContains(t, out, "▸", "static table has no selection") - for _, want := range []string{"Run ID", "1", "qwen-train", "…/runs/04c41514…", "Accelerators"} { + for _, want := range []string{"Run ID", "1", "qwen-train", "qwen-run-001", "Accelerators"} { assert.Contains(t, out, want) } @@ -197,15 +196,86 @@ func TestStartedDisplay(t *testing.T) { assert.Equal(t, "2026-06-05T17:32:39", startedDisplay(listRow{StartedAt: new("2026-06-05T17:32:39.000000+00:00")})) } -func TestMLflowDisplay(t *testing.T) { - assert.Equal(t, "…/runs/04c41514…", mlflowDisplay("https://h/ml/experiments/E/runs/04c41514fbb0/artifacts/logs/node_0")) - assert.Equal(t, "…/runs/run1", mlflowDisplay("https://h/ml/experiments/E/runs/run1/artifacts/logs/node_0")) - assert.LessOrEqual(t, lipgloss.Width(mlflowDisplay("https://h/no-runs/here")), mlflowColWidth) +func TestRenderRowHyperlinks(t *testing.T) { + r, _ := cmdio.NewRenderer(cmdio.MockDiscard(t.Context()), io.Discard) + styles := newListStyles(r) + row := listRow{ + RunID: "1", Experiment: "exp", Status: "SUCCESS", Duration: "-", Accelerators: "-", + RunURL: "https://h/jobs/runs/1?o=2", ExperimentURL: "https://h/ml/experiments/E?o=2", + MLflowURL: "https://h/ml/experiments/E/runs/rid", MLflowLabel: "my-run", + } + cols := computeListCols([]listRow{row}) + + linked := styles.renderRow(cols, row, false, true) + assert.Contains(t, linked, "\x1b]8;;https://h/jobs/runs/1?o=2", "run id links to the dashboard") + assert.Contains(t, linked, "\x1b]8;;https://h/ml/experiments/E?o=2", "experiment links to the experiment page") + + plain := styles.renderRow(cols, row, false, false) + assert.NotContains(t, plain, "\x1b]8;;", "no links when links are disabled") +} + +func TestListModelInfoKeyOpensDetail(t *testing.T) { + r, _ := cmdio.NewRenderer(cmdio.MockDiscard(t.Context()), io.Discard) + f := &runFetcher{ctx: t.Context(), w: newTestWorkspaceClient(t, "https://x.test")} + m := newListModel(r, f, testListRows(), false) + + // `i` opens the detail pane in a loading state and dispatches a fetch (not run here). + next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("i")}) + m = next.(listModel) + assert.Equal(t, modeDetail, m.mode) + assert.True(t, m.detailLoading) + assert.NotNil(t, cmd) +} + +// detailLoadingModel returns a sized model in the detail-loading state, as if +// the user just pressed `i` and the fetch is still in flight. +func detailLoadingModel(t *testing.T) listModel { + t.Helper() + r, _ := cmdio.NewRenderer(cmdio.MockDiscard(t.Context()), io.Discard) + f := &runFetcher{ctx: t.Context(), w: newTestWorkspaceClient(t, "https://x.test")} + m := newListModel(r, f, testListRows(), false) + next, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + m = next.(listModel) + next, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("i")}) + m = next.(listModel) + require.Equal(t, modeDetail, m.mode) + require.True(t, m.detailLoading) + return m +} + +func TestListModelDetailPaneAndBack(t *testing.T) { + m := detailLoadingModel(t) + + // A resolved detailMsg fills the pane. + next, _ := m.Update(detailMsg{title: "Run Details", body: "hello from the detail pane"}) + m = next.(listModel) + require.Equal(t, modeDetail, m.mode) + assert.False(t, m.detailLoading) + view := m.View() + assert.Contains(t, view, "Run Details") + assert.Contains(t, view, "hello from the detail pane") + assert.Contains(t, view, "esc back") + + // esc returns to the list. + next, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + m = next.(listModel) + assert.Equal(t, modeList, m.mode) + assert.Contains(t, m.View(), "Run ID") } -func TestMLflowRunID(t *testing.T) { - assert.Equal(t, "abc123", mlflowRunID("https://h/ml/experiments/1/runs/abc123/artifacts")) - assert.Empty(t, mlflowRunID("https://h/no-runs-here")) +func TestListModelDetailLateMsgDropped(t *testing.T) { + m := detailLoadingModel(t) + + // User escapes back to the list before the fetch resolves. + next, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + m = next.(listModel) + require.Equal(t, modeList, m.mode) + + // The late result must be dropped, not snap the user back into the pane. + next, _ = m.Update(detailMsg{title: "Run Details", body: "late result"}) + m = next.(listModel) + assert.Equal(t, modeList, m.mode) + assert.NotContains(t, m.View(), "late result") } func TestPadAndTruncate(t *testing.T) {