initial public release
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
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())
|
||||
}
|
||||
Reference in New Issue
Block a user