326 lines
7.6 KiB
Go
326 lines
7.6 KiB
Go
package tui
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/cobr-ai/apex/internal/scan"
|
|
|
|
"github.com/charmbracelet/bubbles/table"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
type ScanModel struct {
|
|
configPath string
|
|
cfg *scan.Config
|
|
db *sql.DB
|
|
|
|
table table.Model
|
|
rows []scanRow
|
|
running bool
|
|
cancel context.CancelFunc
|
|
resultsCh <-chan scan.ScanResult
|
|
status string
|
|
width int
|
|
height int
|
|
showDetail bool
|
|
detailIdx int
|
|
}
|
|
|
|
type scanRow struct {
|
|
Name string
|
|
Status string
|
|
Found int
|
|
Filtered int
|
|
Duplicate int
|
|
New int
|
|
Err string
|
|
}
|
|
|
|
func NewScanModel(configPath string, db *sql.DB) *ScanModel {
|
|
m := &ScanModel{configPath: configPath, db: db, status: "press [s] to scan"}
|
|
cfg, err := scan.LoadConfig(configPath)
|
|
if err != nil {
|
|
m.status = fmt.Sprintf("config error: %v", err)
|
|
return m
|
|
}
|
|
m.cfg = cfg
|
|
for _, c := range cfg.TrackedCompanies {
|
|
st := "pending"
|
|
if !c.Enabled {
|
|
st = "disabled"
|
|
}
|
|
m.rows = append(m.rows, scanRow{Name: c.Name, Status: st})
|
|
}
|
|
m.table = newScanTable(m.rows, 0, 0)
|
|
return m
|
|
}
|
|
|
|
func scanColumns() []table.Column {
|
|
return []table.Column{
|
|
{Title: "Company", Width: 26},
|
|
{Title: "Status", Width: 11},
|
|
{Title: "Found", Width: 6},
|
|
{Title: "Filt", Width: 6},
|
|
{Title: "Dup", Width: 6},
|
|
{Title: "New", Width: 6},
|
|
{Title: "Notes", Width: 24},
|
|
}
|
|
}
|
|
|
|
func newScanTable(rows []scanRow, w, h int) table.Model {
|
|
cols := flexColumns(w, scanColumns(), []int{0, 6})
|
|
tableRows := make([]table.Row, 0, len(rows))
|
|
for _, r := range rows {
|
|
notes := r.Err
|
|
if notes == "" && r.Status == "unsupported" {
|
|
notes = "no tier-1 API"
|
|
}
|
|
tableRows = append(tableRows, table.Row{
|
|
r.Name, r.Status, intStr(r.Found), intStr(r.Filtered),
|
|
intStr(r.Duplicate), intStr(r.New), notes,
|
|
})
|
|
}
|
|
t := table.New(
|
|
table.WithColumns(cols),
|
|
table.WithRows(tableRows),
|
|
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 tableHeight(paneH int) int {
|
|
h := paneH - 6
|
|
if h < 5 {
|
|
h = 5
|
|
}
|
|
return h
|
|
}
|
|
|
|
func intStr(n int) string {
|
|
if n == 0 {
|
|
return "-"
|
|
}
|
|
return fmt.Sprintf("%d", n)
|
|
}
|
|
|
|
func (m *ScanModel) Init() tea.Cmd { return nil }
|
|
|
|
func (m *ScanModel) 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, scanColumns(), []int{0, 6}))
|
|
}
|
|
|
|
type scanResultMsg scan.ScanResult
|
|
type scanDoneMsg struct{}
|
|
|
|
func (m *ScanModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
if m.showDetail {
|
|
if k, ok := msg.(tea.KeyMsg); ok {
|
|
switch k.String() {
|
|
case "esc", "enter", "q":
|
|
m.showDetail = false
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
switch msg := msg.(type) {
|
|
case tea.KeyMsg:
|
|
switch msg.String() {
|
|
case "s":
|
|
if !m.running && m.cfg != nil {
|
|
return m, m.startScan()
|
|
}
|
|
case "esc":
|
|
if m.running && m.cancel != nil {
|
|
m.cancel()
|
|
}
|
|
case "r":
|
|
if !m.running {
|
|
return m, m.retryErrored()
|
|
}
|
|
case "enter":
|
|
m.detailIdx = m.table.Cursor()
|
|
m.showDetail = true
|
|
return m, nil
|
|
}
|
|
case scanResultMsg:
|
|
m.applyResult(scan.ScanResult(msg))
|
|
m.refreshTable()
|
|
return m, m.pump()
|
|
case scanDoneMsg:
|
|
m.running = false
|
|
m.status = m.summary()
|
|
m.refreshTable()
|
|
}
|
|
var cmd tea.Cmd
|
|
m.table, cmd = m.table.Update(msg)
|
|
return m, cmd
|
|
}
|
|
|
|
func (m *ScanModel) startScan() tea.Cmd {
|
|
if m.db == nil {
|
|
m.status = "scan unavailable: no database"
|
|
return nil
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
|
m.cancel = cancel
|
|
runner := scan.NewRunner(m.db, m.cfg)
|
|
m.resultsCh = runner.Run(ctx, m.cfg.TrackedCompanies)
|
|
m.running = true
|
|
m.status = "scanning…"
|
|
for i := range m.rows {
|
|
if m.rows[i].Status == "pending" || m.rows[i].Status == "error" {
|
|
m.rows[i].Status = "running"
|
|
m.rows[i].Err = ""
|
|
}
|
|
}
|
|
m.refreshTable()
|
|
return m.pump()
|
|
}
|
|
|
|
func (m *ScanModel) retryErrored() tea.Cmd {
|
|
if m.db == nil || m.cfg == nil {
|
|
return nil
|
|
}
|
|
var targets []scan.Company
|
|
for i, r := range m.rows {
|
|
if r.Status == "error" {
|
|
for _, c := range m.cfg.TrackedCompanies {
|
|
if c.Name == r.Name {
|
|
targets = append(targets, c)
|
|
m.rows[i].Status = "running"
|
|
m.rows[i].Err = ""
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if len(targets) == 0 {
|
|
m.status = "no errored rows to retry"
|
|
return nil
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
|
m.cancel = cancel
|
|
runner := scan.NewRunner(m.db, m.cfg)
|
|
m.resultsCh = runner.Run(ctx, targets)
|
|
m.running = true
|
|
m.status = fmt.Sprintf("retrying %d errored rows…", len(targets))
|
|
m.refreshTable()
|
|
return m.pump()
|
|
}
|
|
|
|
func (m *ScanModel) pump() tea.Cmd {
|
|
return func() tea.Msg {
|
|
res, ok := <-m.resultsCh
|
|
if !ok {
|
|
return scanDoneMsg{}
|
|
}
|
|
return scanResultMsg(res)
|
|
}
|
|
}
|
|
|
|
func (m *ScanModel) applyResult(res scan.ScanResult) {
|
|
for i := range m.rows {
|
|
if m.rows[i].Name != res.Company {
|
|
continue
|
|
}
|
|
if res.Err != nil {
|
|
if scan.IsUnsupported(res.Err) {
|
|
m.rows[i].Status = "unsupported"
|
|
} else {
|
|
m.rows[i].Status = "error"
|
|
m.rows[i].Err = res.Err.Error()
|
|
}
|
|
return
|
|
}
|
|
m.rows[i].Status = "done"
|
|
m.rows[i].Found = res.Found
|
|
m.rows[i].Filtered = res.Filtered
|
|
m.rows[i].Duplicate = res.Duplicate
|
|
m.rows[i].New = res.New
|
|
return
|
|
}
|
|
}
|
|
|
|
func (m *ScanModel) refreshTable() {
|
|
newRows := make([]table.Row, 0, len(m.rows))
|
|
for _, r := range m.rows {
|
|
notes := r.Err
|
|
if notes == "" && r.Status == "unsupported" {
|
|
notes = "no tier-1 API"
|
|
}
|
|
newRows = append(newRows, table.Row{
|
|
r.Name, r.Status, intStr(r.Found), intStr(r.Filtered),
|
|
intStr(r.Duplicate), intStr(r.New), notes,
|
|
})
|
|
}
|
|
m.table.SetRows(newRows)
|
|
}
|
|
|
|
func (m *ScanModel) summary() string {
|
|
var found, newCnt, dup, err, unsup int
|
|
for _, r := range m.rows {
|
|
found += r.Found
|
|
newCnt += r.New
|
|
dup += r.Duplicate
|
|
switch r.Status {
|
|
case "error":
|
|
err++
|
|
case "unsupported":
|
|
unsup++
|
|
}
|
|
}
|
|
return fmt.Sprintf("done — %d found · %d new · %d dup · %d errors · %d unsupported", found, newCnt, dup, err, unsup)
|
|
}
|
|
|
|
func (m *ScanModel) View() string {
|
|
if m.cfg == nil {
|
|
return StyleTitle.Render("Scan") + "\n" + StyleStatusErr.Render(m.status)
|
|
}
|
|
header := StyleTitle.Render("Scan — Tier-1 API adapters")
|
|
sub := StyleSubtitle.Render(fmt.Sprintf("%d companies · [s] scan · [r] retry errored · [enter] detail · [esc] cancel", len(m.rows)))
|
|
status := StyleStatusMuted.Render(m.status)
|
|
view := lipgloss.JoinVertical(lipgloss.Left, header, sub, "", m.table.View(), "", status)
|
|
if m.showDetail && m.detailIdx < len(m.rows) {
|
|
overlay := m.renderDetail(m.rows[m.detailIdx])
|
|
return lipgloss.JoinVertical(lipgloss.Left, view, overlay)
|
|
}
|
|
return view
|
|
}
|
|
|
|
func (m *ScanModel) renderDetail(r scanRow) string {
|
|
var b strings.Builder
|
|
b.WriteString(StyleTitle.Render("Row detail — " + r.Name))
|
|
b.WriteString("\n\n")
|
|
b.WriteString(kv("Status", r.Status))
|
|
b.WriteString(kv("Found", fmt.Sprintf("%d", r.Found)))
|
|
b.WriteString(kv("Filtered", fmt.Sprintf("%d", r.Filtered)))
|
|
b.WriteString(kv("Duplicate", fmt.Sprintf("%d", r.Duplicate)))
|
|
b.WriteString(kv("New", fmt.Sprintf("%d", r.New)))
|
|
if r.Err != "" {
|
|
b.WriteString("\n")
|
|
b.WriteString(StyleStatusErr.Render("Error: " + r.Err))
|
|
b.WriteString("\n")
|
|
}
|
|
b.WriteString("\n" + StyleKeyDesc.Render("press enter or esc to close"))
|
|
return StyleModal.Render(b.String())
|
|
}
|
|
|
|
func kv(k, v string) string {
|
|
return StyleKey.Render(fmt.Sprintf("%-12s", k)) + StyleKeyDesc.Render(v) + "\n"
|
|
}
|