355 lines
8.4 KiB
Go
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())
|
|
}
|