Files
apex-public/internal/tui/pipeline_views.go
T
2026-07-06 11:05:50 -04:00

355 lines
8.4 KiB
Go

package tui
import (
"database/sql"
"fmt"
"os"
"os/exec"
"strings"
"time"
"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type PipelineModel struct {
db *sql.DB
table table.Model
rows []pipelineRow
filteredIdx []int
width int
height int
status string
mode pipelineMode
filterInput textinput.Model
}
type pipelineMode int
const (
pmList pipelineMode = iota
pmFilter
)
type pipelineRow struct {
ID int
JobID int
Company string
Title string
QueuedAt time.Time
StartedAt *time.Time
CompletedAt *time.Time
}
func (r *pipelineRow) Status() string {
if r.CompletedAt != nil {
return "done"
}
if r.StartedAt != nil {
return "running"
}
return "pending"
}
func NewPipelineModel(db *sql.DB) *PipelineModel {
ti := textinput.New()
ti.Placeholder = "filter by company or title…"
ti.CharLimit = 100
ti.Width = 60
m := &PipelineModel{
db: db,
filterInput: ti,
status: "[/] filter [d] dequeue [r] report [s] advance status [q/esc] clear",
}
m.loadRows()
m.applyFilter("")
m.table = newPipelineTable(m.visibleRows(), 0, 0)
return m
}
func (m *PipelineModel) loadRows() {
m.rows = nil
if m.db == nil {
m.status = "no database"
return
}
rows, err := m.db.Query(`
SELECT p.id, p.job_id, j.company, j.title, p.queued_at, p.started_at, p.completed_at
FROM pipeline p
JOIN jobs j ON j.id = p.job_id
ORDER BY p.queued_at DESC
`)
if err != nil {
m.status = fmt.Sprintf("query failed: %v", err)
return
}
defer rows.Close()
for rows.Next() {
var r pipelineRow
if err := rows.Scan(&r.ID, &r.JobID, &r.Company, &r.Title, &r.QueuedAt, &r.StartedAt, &r.CompletedAt); err != nil {
continue
}
m.rows = append(m.rows, r)
}
}
func pipelineColumns() []table.Column {
return []table.Column{
{Title: "ID", Width: 4},
{Title: "Company", Width: 20},
{Title: "Title", Width: 30},
{Title: "Queued", Width: 10},
{Title: "Status", Width: 9},
}
}
func newPipelineTable(rows []pipelineRow, w, h int) table.Model {
cols := flexColumns(w, pipelineColumns(), []int{1, 2})
trows := make([]table.Row, 0, len(rows))
for _, r := range rows {
trows = append(trows, pipelineRowToTable(r))
}
t := table.New(
table.WithColumns(cols),
table.WithRows(trows),
table.WithFocused(true),
table.WithHeight(tableHeight(h)),
)
s := table.DefaultStyles()
s.Header = s.Header.BorderStyle(lipgloss.NormalBorder()).
BorderForeground(ColorBorder).BorderBottom(true).Bold(true).Foreground(ColorAccentAlt)
s.Selected = s.Selected.Bold(true).Foreground(ColorText).Background(ColorHighlight)
t.SetStyles(s)
return t
}
func pipelineRowToTable(r pipelineRow) table.Row {
return table.Row{
fmt.Sprintf("%d", r.ID),
r.Company,
r.Title,
r.QueuedAt.Local().Format("2006-01-02"),
r.Status(),
}
}
func (m *PipelineModel) visibleRows() []pipelineRow {
if len(m.filteredIdx) == 0 {
return m.rows
}
result := make([]pipelineRow, 0, len(m.filteredIdx))
for _, i := range m.filteredIdx {
if i >= 0 && i < len(m.rows) {
result = append(result, m.rows[i])
}
}
return result
}
func (m *PipelineModel) applyFilter(query string) {
m.filteredIdx = nil
if query == "" {
return
}
lower := strings.ToLower(query)
for i, r := range m.rows {
if strings.Contains(strings.ToLower(r.Company), lower) ||
strings.Contains(strings.ToLower(r.Title), lower) {
m.filteredIdx = append(m.filteredIdx, i)
}
}
}
func (m *PipelineModel) selectedRowIndex() int {
cur := m.table.Cursor()
visible := m.visibleRows()
if cur >= len(visible) {
return -1
}
if len(m.filteredIdx) == 0 {
return cur
}
return m.filteredIdx[cur]
}
func (m *PipelineModel) Init() tea.Cmd { return nil }
func (m *PipelineModel) Resize(w, h int) {
m.width = w
m.height = h
m.table.SetHeight(tableHeight(h))
m.table.SetWidth(w - 2)
m.table.SetColumns(flexColumns(w-2, pipelineColumns(), []int{1, 2}))
}
func (m *PipelineModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.mode == pmFilter {
return m.updateFilter(msg)
}
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "/":
m.filterInput.SetValue("")
m.filterInput.Focus()
m.mode = pmFilter
m.status = "type to filter, enter to apply, esc to cancel"
case "d":
if idx := m.selectedRowIndex(); idx >= 0 {
return m, m.dequeueItem(m.rows[idx].ID)
}
case "r":
if idx := m.selectedRowIndex(); idx >= 0 {
m.openReport(m.rows[idx].JobID)
}
case "s":
if idx := m.selectedRowIndex(); idx >= 0 {
return m, m.advanceStatus(m.rows[idx].JobID)
}
case "q", "esc":
if len(m.filteredIdx) > 0 {
m.filteredIdx = nil
m.applyFilter("")
m.refreshTable()
m.status = "filter cleared"
}
}
}
var cmd tea.Cmd
m.table, cmd = m.table.Update(msg)
return m, cmd
}
func (m *PipelineModel) updateFilter(msg tea.Msg) (tea.Model, tea.Cmd) {
if k, ok := msg.(tea.KeyMsg); ok {
switch k.String() {
case "esc":
m.mode = pmList
m.status = "[/] filter [d] dequeue [r] report [s] advance status [q/esc] clear"
return m, nil
case "enter":
query := strings.TrimSpace(m.filterInput.Value())
m.applyFilter(query)
m.table.SetCursor(0)
m.refreshTable()
m.mode = pmList
pending := m.countStatus("pending")
m.status = fmt.Sprintf("%d items (filtered) · %d pending", len(m.visibleRows()), pending)
return m, nil
}
}
var cmd tea.Cmd
m.filterInput, cmd = m.filterInput.Update(msg)
return m, cmd
}
func (m *PipelineModel) dequeueItem(id int) tea.Cmd {
return func() tea.Msg {
_, err := m.db.Exec(`DELETE FROM pipeline WHERE id = ?`, id)
if err != nil {
m.status = fmt.Sprintf("dequeue failed: %v", err)
} else {
m.loadRows()
m.applyFilter(m.filterInput.Value())
m.refreshTable()
m.status = fmt.Sprintf("removed #%d from pipeline", id)
}
return nil
}
}
func (m *PipelineModel) openReport(jobID int) {
if m.db == nil {
return
}
var path string
err := m.db.QueryRow(`
SELECT r.file_path FROM reports r
JOIN applications a ON a.id = r.application_id
WHERE a.job_id = ? ORDER BY r.id DESC LIMIT 1
`, jobID).Scan(&path)
if err != nil {
m.status = fmt.Sprintf("no report found for job #%d", jobID)
return
}
pager := os.Getenv("PAGER")
if pager == "" {
pager = "less"
}
cmd := exec.Command(pager, path)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
_ = cmd.Run()
}
func (m *PipelineModel) advanceStatus(jobID int) tea.Cmd {
return func() tea.Msg {
var status string
err := m.db.QueryRow(`SELECT status FROM applications WHERE job_id = ? ORDER BY id DESC LIMIT 1`, jobID).Scan(&status)
if err == sql.ErrNoRows {
m.status = fmt.Sprintf("no application found for job #%d", jobID)
return nil
}
if err != nil {
m.status = fmt.Sprintf("query failed: %v", err)
return nil
}
if status != "Queued" {
m.status = fmt.Sprintf("job #%d is %s, not Queued; no advance possible", jobID, status)
return nil
}
_, err = m.db.Exec(`UPDATE applications SET status = ? WHERE job_id = ?`, "Evaluated", jobID)
if err != nil {
m.status = fmt.Sprintf("advance failed: %v", err)
} else {
m.status = fmt.Sprintf("job #%d advanced to Evaluated", jobID)
}
return nil
}
}
func (m *PipelineModel) countStatus(s string) int {
count := 0
for _, r := range m.visibleRows() {
if r.Status() == s {
count++
}
}
return count
}
func (m *PipelineModel) refreshTable() {
trows := make([]table.Row, 0, len(m.visibleRows()))
for _, r := range m.visibleRows() {
trows = append(trows, pipelineRowToTable(r))
}
m.table.SetRows(trows)
}
func (m *PipelineModel) View() string {
header := StyleTitle.Render("Pipeline — batch evaluation queue")
pending := m.countStatus("pending")
sub := StyleSubtitle.Render(fmt.Sprintf("%d items (%d pending) · [/] filter · [d] dequeue · [r] report · [s] advance · [q/esc] clear", len(m.visibleRows()), pending))
var body string
if m.mode == pmFilter {
body = m.renderFilterPrompt()
} else {
body = m.table.View()
}
status := StyleStatusMuted.Render(m.status)
return lipgloss.JoinVertical(lipgloss.Left, header, sub, "", body, "", status)
}
func (m *PipelineModel) renderFilterPrompt() string {
var b strings.Builder
b.WriteString("Filter by company or title:\n\n")
b.WriteString(m.filterInput.View())
b.WriteString("\n\n" + StyleKeyDesc.Render("[enter] apply · [esc] cancel"))
return StyleModal.Render(b.String())
}