feat: pipeline_launcher TUI para lanzar pipelines y registrar ejecuciones
TUI fullscreen con dos tabs: Pipelines (lista filtrable del registry, lanzamiento como subproceso, registro automático en operations.db) y History (historial de ejecuciones con status, duración y detalles). Incluye launcher.sh en la raíz para ejecución rápida. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
package views
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
ops "fn-registry/fn_operations"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/lucasdataproyects/devfactory/tui"
|
||||
)
|
||||
|
||||
type historyState int
|
||||
|
||||
const (
|
||||
historyLoading historyState = iota
|
||||
historyList
|
||||
historyDetail
|
||||
)
|
||||
|
||||
type historyLoadedMsg []ops.Execution
|
||||
|
||||
// HistoryModel shows execution history.
|
||||
type HistoryModel struct {
|
||||
state historyState
|
||||
list tui.FilteredListModel
|
||||
spinner tui.SpinnerModel
|
||||
styles tui.Styles
|
||||
executions []ops.Execution
|
||||
detail string
|
||||
scrollOff int
|
||||
opsDB *ops.DB
|
||||
pipelineNames map[string]string
|
||||
}
|
||||
|
||||
// NewHistoryModel creates a new history view.
|
||||
func NewHistoryModel(styles tui.Styles, opsDB *ops.DB, names map[string]string) HistoryModel {
|
||||
return HistoryModel{
|
||||
state: historyLoading,
|
||||
list: tui.NewFilteredList(nil, "Filter executions..."),
|
||||
spinner: tui.NewSpinner("Loading history..."),
|
||||
styles: styles,
|
||||
opsDB: opsDB,
|
||||
pipelineNames: names,
|
||||
}
|
||||
}
|
||||
|
||||
func (m HistoryModel) Init() tea.Cmd {
|
||||
return tea.Batch(
|
||||
m.spinner.Init(),
|
||||
m.loadHistory(),
|
||||
)
|
||||
}
|
||||
|
||||
func (m HistoryModel) loadHistory() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
execs, err := m.opsDB.ListExecutions("", "", "")
|
||||
if err != nil {
|
||||
return historyLoadedMsg(nil)
|
||||
}
|
||||
return historyLoadedMsg(execs)
|
||||
}
|
||||
}
|
||||
|
||||
func (m HistoryModel) Update(msg tea.Msg) (HistoryModel, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case historyLoadedMsg:
|
||||
m.executions = []ops.Execution(msg)
|
||||
items := make([]tui.ListItem, len(m.executions))
|
||||
for i, e := range m.executions {
|
||||
icon := "●"
|
||||
switch e.Status {
|
||||
case ops.ExecSuccess:
|
||||
icon = "✓"
|
||||
case ops.ExecFailure:
|
||||
icon = "✗"
|
||||
case ops.ExecPartial:
|
||||
icon = "~"
|
||||
}
|
||||
name := e.PipelineID
|
||||
if n, ok := m.pipelineNames[e.PipelineID]; ok {
|
||||
name = n
|
||||
}
|
||||
dur := ""
|
||||
if e.DurationMs != nil {
|
||||
dur = fmt.Sprintf("%dms", *e.DurationMs)
|
||||
}
|
||||
items[i] = tui.ListItem{
|
||||
Title: fmt.Sprintf("%s %s", icon, name),
|
||||
Description: fmt.Sprintf("%s — %s — %s", string(e.Status), dur, e.StartedAt.Format("2006-01-02 15:04:05")),
|
||||
Value: e,
|
||||
}
|
||||
}
|
||||
m.list.SetItems(items)
|
||||
m.state = historyList
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch m.state {
|
||||
case historyList:
|
||||
switch msg.String() {
|
||||
case "r":
|
||||
m.state = historyLoading
|
||||
m.spinner = tui.NewSpinner("Loading history...")
|
||||
return m, tea.Batch(m.spinner.Init(), m.loadHistory())
|
||||
case "enter":
|
||||
if item := m.list.SelectedItem(); item != nil {
|
||||
e := item.Value.(ops.Execution)
|
||||
m.detail = formatExecution(e)
|
||||
m.state = historyDetail
|
||||
m.scrollOff = 0
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
case historyDetail:
|
||||
switch msg.String() {
|
||||
case "j", "down":
|
||||
m.scrollOff++
|
||||
case "k", "up":
|
||||
if m.scrollOff > 0 {
|
||||
m.scrollOff--
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Delegate to sub-components
|
||||
var cmd tea.Cmd
|
||||
switch m.state {
|
||||
case historyLoading:
|
||||
var spinnerModel tea.Model
|
||||
spinnerModel, cmd = m.spinner.Update(msg)
|
||||
m.spinner = spinnerModel.(tui.SpinnerModel)
|
||||
case historyList:
|
||||
var listModel tea.Model
|
||||
listModel, cmd = m.list.Update(msg)
|
||||
m.list = listModel.(tui.FilteredListModel)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// HandleBack retrocede un nivel. Retorna true si ya en estado base.
|
||||
func (m *HistoryModel) HandleBack() bool {
|
||||
switch m.state {
|
||||
case historyDetail:
|
||||
m.state = historyList
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func (m HistoryModel) View() string {
|
||||
switch m.state {
|
||||
case historyLoading:
|
||||
return m.spinner.View()
|
||||
case historyList:
|
||||
if len(m.executions) == 0 {
|
||||
return m.styles.Muted.Render("No executions found. Launch a pipeline first.")
|
||||
}
|
||||
help := m.styles.Muted.Render(" Enter: details │ r: refresh │ /: filter")
|
||||
return m.list.View() + "\n" + help
|
||||
case historyDetail:
|
||||
return m.renderDetail()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m HistoryModel) renderDetail() string {
|
||||
lines := splitLines(m.detail)
|
||||
maxLines := 20
|
||||
if m.scrollOff >= len(lines) {
|
||||
m.scrollOff = max(0, len(lines)-1)
|
||||
}
|
||||
end := min(m.scrollOff+maxLines, len(lines))
|
||||
visible := lines[m.scrollOff:end]
|
||||
|
||||
header := m.styles.Header.Render("Execution Detail")
|
||||
content := lipgloss.JoinVertical(lipgloss.Left, visible...)
|
||||
help := m.styles.Muted.Render(" j/k: scroll │ Esc: back")
|
||||
|
||||
return header + "\n" + content + "\n" + help
|
||||
}
|
||||
|
||||
func formatExecution(e ops.Execution) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("ID: %s\n", e.ID))
|
||||
sb.WriteString(fmt.Sprintf("Pipeline: %s\n", e.PipelineID))
|
||||
sb.WriteString(fmt.Sprintf("Status: %s\n", e.Status))
|
||||
sb.WriteString(fmt.Sprintf("Started: %s\n", e.StartedAt.Format("2006-01-02 15:04:05")))
|
||||
if e.EndedAt != nil {
|
||||
sb.WriteString(fmt.Sprintf("Ended: %s\n", e.EndedAt.Format("2006-01-02 15:04:05")))
|
||||
}
|
||||
if e.DurationMs != nil {
|
||||
sb.WriteString(fmt.Sprintf("Duration: %dms\n", *e.DurationMs))
|
||||
}
|
||||
if e.RecordsIn != nil {
|
||||
sb.WriteString(fmt.Sprintf("Records In: %d\n", *e.RecordsIn))
|
||||
}
|
||||
if e.RecordsOut != nil {
|
||||
sb.WriteString(fmt.Sprintf("Records Out: %d\n", *e.RecordsOut))
|
||||
}
|
||||
if e.Error != "" {
|
||||
sb.WriteString(fmt.Sprintf("\n--- Error ---\n%s\n", e.Error))
|
||||
}
|
||||
if len(e.Metrics) > 0 {
|
||||
sb.WriteString("\n--- Metrics ---\n")
|
||||
b, _ := json.MarshalIndent(e.Metrics, "", " ")
|
||||
sb.WriteString(string(b))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
Reference in New Issue
Block a user