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,249 @@
|
||||
package views
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
ops "fn-registry/fn_operations"
|
||||
"fn-registry/registry"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/lucasdataproyects/devfactory/tui"
|
||||
)
|
||||
|
||||
type pipelinesState int
|
||||
|
||||
const (
|
||||
pipelinesLoading pipelinesState = iota
|
||||
pipelinesList
|
||||
pipelinesRunning
|
||||
pipelinesOutput
|
||||
)
|
||||
|
||||
type pipelinesLoadedMsg []registry.Function
|
||||
type pipelineFinishedMsg RunResult
|
||||
|
||||
// PipelinesModel lists and launches pipelines.
|
||||
type PipelinesModel struct {
|
||||
state pipelinesState
|
||||
list tui.FilteredListModel
|
||||
spinner tui.SpinnerModel
|
||||
styles tui.Styles
|
||||
pipelines []registry.Function
|
||||
output string
|
||||
lastResult *RunResult
|
||||
scrollOff int
|
||||
err error
|
||||
registryDB *registry.DB
|
||||
opsDB *ops.DB
|
||||
registryRoot string
|
||||
}
|
||||
|
||||
// NewPipelinesModel creates a new pipelines view.
|
||||
func NewPipelinesModel(styles tui.Styles, regDB *registry.DB, opsDB *ops.DB, root string) PipelinesModel {
|
||||
return PipelinesModel{
|
||||
state: pipelinesLoading,
|
||||
list: tui.NewFilteredList(nil, "Filter pipelines..."),
|
||||
spinner: tui.NewSpinner("Loading pipelines..."),
|
||||
styles: styles,
|
||||
registryDB: regDB,
|
||||
opsDB: opsDB,
|
||||
registryRoot: root,
|
||||
}
|
||||
}
|
||||
|
||||
func (m PipelinesModel) Init() tea.Cmd {
|
||||
return tea.Batch(
|
||||
m.spinner.Init(),
|
||||
m.loadPipelines(),
|
||||
)
|
||||
}
|
||||
|
||||
func (m PipelinesModel) loadPipelines() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
fns, err := m.registryDB.SearchFunctions("", registry.KindPipeline, "", "", "")
|
||||
if err != nil {
|
||||
return pipelinesLoadedMsg(nil)
|
||||
}
|
||||
return pipelinesLoadedMsg(fns)
|
||||
}
|
||||
}
|
||||
|
||||
func (m PipelinesModel) Update(msg tea.Msg) (PipelinesModel, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case pipelinesLoadedMsg:
|
||||
m.pipelines = []registry.Function(msg)
|
||||
items := make([]tui.ListItem, len(m.pipelines))
|
||||
for i, p := range m.pipelines {
|
||||
items[i] = tui.ListItem{
|
||||
Title: p.Name,
|
||||
Description: fmt.Sprintf("%s — %s", p.Domain, truncate(p.Description, 60)),
|
||||
Value: p,
|
||||
}
|
||||
}
|
||||
m.list.SetItems(items)
|
||||
m.state = pipelinesList
|
||||
return m, nil
|
||||
|
||||
case pipelineFinishedMsg:
|
||||
result := RunResult(msg)
|
||||
m.lastResult = &result
|
||||
// Build output
|
||||
var sb strings.Builder
|
||||
if result.Status == ops.ExecSuccess {
|
||||
sb.WriteString("[OK] ")
|
||||
} else {
|
||||
sb.WriteString("[FAIL] ")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("Pipeline: %s\n", result.PipelineID))
|
||||
sb.WriteString(fmt.Sprintf("Execution: %s\n", result.ExecID))
|
||||
sb.WriteString(fmt.Sprintf("Duration: %dms\n", result.DurationMs))
|
||||
sb.WriteString("\n--- stdout ---\n")
|
||||
if result.Stdout != "" {
|
||||
sb.WriteString(result.Stdout)
|
||||
} else {
|
||||
sb.WriteString("(empty)")
|
||||
}
|
||||
if result.Stderr != "" {
|
||||
sb.WriteString("\n--- stderr ---\n")
|
||||
sb.WriteString(result.Stderr)
|
||||
}
|
||||
if result.Err != nil {
|
||||
sb.WriteString(fmt.Sprintf("\n--- error ---\n%v", result.Err))
|
||||
}
|
||||
m.output = sb.String()
|
||||
m.state = pipelinesOutput
|
||||
m.scrollOff = 0
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch m.state {
|
||||
case pipelinesList:
|
||||
switch msg.String() {
|
||||
case "r":
|
||||
m.state = pipelinesLoading
|
||||
m.spinner = tui.NewSpinner("Loading pipelines...")
|
||||
return m, tea.Batch(m.spinner.Init(), m.loadPipelines())
|
||||
case "enter":
|
||||
if item := m.list.SelectedItem(); item != nil {
|
||||
fn := item.Value.(registry.Function)
|
||||
m.state = pipelinesRunning
|
||||
m.spinner = tui.NewSpinner(fmt.Sprintf("Running %s...", fn.Name))
|
||||
return m, tea.Batch(m.spinner.Init(), m.runPipelineCmd(&fn))
|
||||
}
|
||||
}
|
||||
case pipelinesOutput:
|
||||
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 pipelinesLoading, pipelinesRunning:
|
||||
var spinnerModel tea.Model
|
||||
spinnerModel, cmd = m.spinner.Update(msg)
|
||||
m.spinner = spinnerModel.(tui.SpinnerModel)
|
||||
case pipelinesList:
|
||||
var listModel tea.Model
|
||||
listModel, cmd = m.list.Update(msg)
|
||||
m.list = listModel.(tui.FilteredListModel)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m PipelinesModel) runPipelineCmd(fn *registry.Function) tea.Cmd {
|
||||
regRoot := m.registryRoot
|
||||
opsDB := m.opsDB
|
||||
fnCopy := *fn
|
||||
return func() tea.Msg {
|
||||
result := RunPipeline(&fnCopy, regRoot, opsDB)
|
||||
return pipelineFinishedMsg(result)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleBack retrocede un nivel. Retorna true si ya en estado base.
|
||||
func (m *PipelinesModel) HandleBack() bool {
|
||||
switch m.state {
|
||||
case pipelinesOutput:
|
||||
m.state = pipelinesList
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func (m PipelinesModel) View() string {
|
||||
switch m.state {
|
||||
case pipelinesLoading:
|
||||
return m.spinner.View()
|
||||
case pipelinesList:
|
||||
if len(m.pipelines) == 0 {
|
||||
return m.styles.Muted.Render("No pipelines found. Press 'r' to refresh.")
|
||||
}
|
||||
help := m.styles.Muted.Render(" Enter: launch │ r: refresh │ /: filter")
|
||||
return m.list.View() + "\n" + help
|
||||
case pipelinesRunning:
|
||||
return m.spinner.View()
|
||||
case pipelinesOutput:
|
||||
return m.renderOutput()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m PipelinesModel) renderOutput() string {
|
||||
lines := splitLines(m.output)
|
||||
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("Pipeline Output")
|
||||
content := lipgloss.JoinVertical(lipgloss.Left, visible...)
|
||||
help := m.styles.Muted.Render(" j/k: scroll │ Esc: back")
|
||||
|
||||
return header + "\n" + content + "\n" + help
|
||||
}
|
||||
|
||||
func splitLines(s string) []string {
|
||||
if s == "" {
|
||||
return []string{"(empty)"}
|
||||
}
|
||||
lines := strings.Split(s, "\n")
|
||||
if len(lines) == 0 {
|
||||
return []string{"(empty)"}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n-3] + "..."
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
Reference in New Issue
Block a user