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