feat: externalize apps/analysis to Gitea repos, add analysis table
- Migration 007: repo_url on apps table + analysis table with FTS5 - Analysis struct, parser, CRUD, validation, hash computation - Selective purge: remote-only apps/analysis preserved across fn index - CLI: fn app list/clone/pull, fn analysis list/clone/pull - search/show/list now include analysis results - Apps removed from git tracking (content lives in Gitea repos) - .gitkeep for apps/ and analysis/ dirs - Bash functions: jupyter analysis pipeline, shell utilities - Browser domain: CDP functions moved from infra to browser Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +0,0 @@
|
||||
build/
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
@@ -1,19 +0,0 @@
|
||||
.PHONY: run build clean install tidy help
|
||||
|
||||
run: ## Ejecuta la TUI
|
||||
go run .
|
||||
|
||||
build: ## Compila el binario
|
||||
go build -trimpath -ldflags='-s -w' -o build/docker-tui .
|
||||
|
||||
clean: ## Limpia artefactos
|
||||
rm -rf build/
|
||||
|
||||
install: build ## Instala en ~/.local/bin
|
||||
cp build/docker-tui ~/.local/bin/docker-tui
|
||||
|
||||
tidy: ## go mod tidy
|
||||
go mod tidy
|
||||
|
||||
help: ## Muestra esta ayuda
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}'
|
||||
@@ -1,28 +0,0 @@
|
||||
---
|
||||
name: docker_tui
|
||||
lang: go
|
||||
domain: infra
|
||||
description: "TUI interactiva para gestion de contenedores, imagenes, volumenes y redes Docker."
|
||||
tags: [docker, tui, bubbletea, containers]
|
||||
uses_functions:
|
||||
- docker_pull_image_go_infra
|
||||
- docker_list_containers_go_infra
|
||||
- docker_remove_container_go_infra
|
||||
- docker_stop_container_go_infra
|
||||
- docker_start_container_go_infra
|
||||
- docker_list_images_go_infra
|
||||
- docker_remove_image_go_infra
|
||||
- docker_remove_network_go_infra
|
||||
- docker_create_network_go_infra
|
||||
- docker_inspect_container_go_infra
|
||||
- docker_run_container_go_infra
|
||||
- docker_container_logs_go_infra
|
||||
uses_types: []
|
||||
framework: bubbletea
|
||||
entry_point: "main.go"
|
||||
dir_path: "apps/docker_tui"
|
||||
---
|
||||
|
||||
## Notas
|
||||
|
||||
Aplicacion TUI con pestanas para contenedores, imagenes, volumenes, redes y compose. Construida con Bubble Tea (Charmbracelet).
|
||||
@@ -1,175 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"docker-tui/views"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/lucasdataproyects/devfactory/tui"
|
||||
)
|
||||
|
||||
type View int
|
||||
|
||||
const (
|
||||
ViewContainers View = iota
|
||||
ViewImages
|
||||
ViewVolumes
|
||||
ViewNetworks
|
||||
ViewCompose
|
||||
)
|
||||
|
||||
var tabNames = []string{"Containers", "Images", "Volumes", "Networks", "Compose"}
|
||||
|
||||
type Model struct {
|
||||
tui.BaseModel
|
||||
activeTab int
|
||||
containers views.ContainersModel
|
||||
images views.ImagesModel
|
||||
volumes views.VolumesModel
|
||||
networks views.NetworksModel
|
||||
compose views.ComposeModel
|
||||
ready bool
|
||||
}
|
||||
|
||||
func New() Model {
|
||||
styles := tui.DefaultStyles()
|
||||
return Model{
|
||||
BaseModel: tui.NewBaseModel().WithStyles(styles),
|
||||
containers: views.NewContainersModel(styles),
|
||||
images: views.NewImagesModel(styles),
|
||||
volumes: views.NewVolumesModel(styles),
|
||||
networks: views.NewNetworksModel(styles),
|
||||
compose: views.NewComposeModel(styles),
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return m.containers.Init()
|
||||
}
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case views.KeyQuit:
|
||||
return m, tea.Quit
|
||||
case "q", "0", "esc":
|
||||
updated, atBase := m.handleBack()
|
||||
if atBase {
|
||||
return updated, tea.Quit
|
||||
}
|
||||
return updated, nil
|
||||
case views.KeyTab:
|
||||
m.activeTab = (m.activeTab + 1) % len(tabNames)
|
||||
return m, m.initActiveView()
|
||||
case "shift+tab":
|
||||
m.activeTab = (m.activeTab - 1 + len(tabNames)) % len(tabNames)
|
||||
return m, m.initActiveView()
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
m.HandleWindowSize(msg)
|
||||
m.ready = true
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
switch View(m.activeTab) {
|
||||
case ViewContainers:
|
||||
m.containers, cmd = m.containers.Update(msg)
|
||||
case ViewImages:
|
||||
m.images, cmd = m.images.Update(msg)
|
||||
case ViewVolumes:
|
||||
m.volumes, cmd = m.volumes.Update(msg)
|
||||
case ViewNetworks:
|
||||
m.networks, cmd = m.networks.Update(msg)
|
||||
case ViewCompose:
|
||||
m.compose, cmd = m.compose.Update(msg)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m Model) View() string {
|
||||
if !m.ready {
|
||||
return "Loading..."
|
||||
}
|
||||
|
||||
// Tab bar
|
||||
tabs := m.renderTabs()
|
||||
|
||||
// Active view content
|
||||
var content string
|
||||
switch View(m.activeTab) {
|
||||
case ViewContainers:
|
||||
content = m.containers.View()
|
||||
case ViewImages:
|
||||
content = m.images.View()
|
||||
case ViewVolumes:
|
||||
content = m.volumes.View()
|
||||
case ViewNetworks:
|
||||
content = m.networks.View()
|
||||
case ViewCompose:
|
||||
content = m.compose.View()
|
||||
}
|
||||
|
||||
// Status bar
|
||||
status := m.Styles.StatusBar.Render(" Tab: switch view │ Ctrl+C: quit │ Enter: action │ r: refresh")
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left,
|
||||
tabs,
|
||||
"",
|
||||
content,
|
||||
"",
|
||||
status,
|
||||
)
|
||||
}
|
||||
|
||||
func (m Model) renderTabs() string {
|
||||
var tabs []string
|
||||
for i, name := range tabNames {
|
||||
if i == m.activeTab {
|
||||
tabs = append(tabs, m.Styles.Selected.Render(" "+name+" "))
|
||||
} else {
|
||||
tabs = append(tabs, m.Styles.Muted.Render(" "+name+" "))
|
||||
}
|
||||
}
|
||||
row := lipgloss.JoinHorizontal(lipgloss.Top, tabs...)
|
||||
return m.Styles.Header.Render("Docker TUI") + " " + row
|
||||
}
|
||||
|
||||
// handleBack asks the active view to go back one level.
|
||||
// Returns the updated model and true if the view was already at base level (app should quit).
|
||||
func (m Model) handleBack() (Model, bool) {
|
||||
switch View(m.activeTab) {
|
||||
case ViewContainers:
|
||||
atBase := m.containers.HandleBack()
|
||||
return m, atBase
|
||||
case ViewImages:
|
||||
atBase := m.images.HandleBack()
|
||||
return m, atBase
|
||||
case ViewVolumes:
|
||||
atBase := m.volumes.HandleBack()
|
||||
return m, atBase
|
||||
case ViewNetworks:
|
||||
atBase := m.networks.HandleBack()
|
||||
return m, atBase
|
||||
case ViewCompose:
|
||||
atBase := m.compose.HandleBack()
|
||||
return m, atBase
|
||||
}
|
||||
return m, true
|
||||
}
|
||||
|
||||
func (m Model) initActiveView() tea.Cmd {
|
||||
switch View(m.activeTab) {
|
||||
case ViewContainers:
|
||||
return m.containers.Init()
|
||||
case ViewImages:
|
||||
return m.images.Init()
|
||||
case ViewVolumes:
|
||||
return m.volumes.Init()
|
||||
case ViewNetworks:
|
||||
return m.networks.Init()
|
||||
case ViewCompose:
|
||||
return m.compose.Init()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "==> Tidying modules..."
|
||||
go mod tidy
|
||||
|
||||
echo "==> Building docker-tui..."
|
||||
mkdir -p build
|
||||
go build -trimpath -ldflags='-s -w' -o build/docker-tui .
|
||||
|
||||
echo "==> Done: build/docker-tui ($(du -h build/docker-tui | cut -f1))"
|
||||
echo " Run with: ./build/docker-tui"
|
||||
@@ -1,15 +0,0 @@
|
||||
package config
|
||||
|
||||
// Config holds Docker TUI configuration.
|
||||
type Config struct {
|
||||
ComposeFile string
|
||||
RefreshInterval int // seconds, 0 = manual
|
||||
}
|
||||
|
||||
// Default returns sensible defaults.
|
||||
func Default() Config {
|
||||
return Config{
|
||||
ComposeFile: "docker-compose.yml",
|
||||
RefreshInterval: 0,
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
module docker-tui
|
||||
|
||||
go 1.22.2
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/bubbletea v0.25.0
|
||||
github.com/charmbracelet/lipgloss v0.9.1
|
||||
github.com/lucasdataproyects/devfactory v0.0.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/apache/arrow/go/v14 v14.0.2 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/bubbles v0.18.0 // indirect
|
||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/google/flatbuffers v23.5.26+incompatible // indirect
|
||||
github.com/klauspost/compress v1.16.7 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/marcboeker/go-duckdb v1.6.5 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/reflow v0.3.0 // indirect
|
||||
github.com/muesli/termenv v0.15.2 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.18 // indirect
|
||||
github.com/rivo/uniseg v0.4.6 // indirect
|
||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||
golang.org/x/mod v0.13.0 // indirect
|
||||
golang.org/x/sync v0.4.0 // indirect
|
||||
golang.org/x/sys v0.13.0 // indirect
|
||||
golang.org/x/term v0.6.0 // indirect
|
||||
golang.org/x/text v0.13.0 // indirect
|
||||
golang.org/x/tools v0.14.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
|
||||
)
|
||||
|
||||
replace github.com/lucasdataproyects/devfactory => /home/lucas/.local_agentes/backend
|
||||
@@ -1,84 +0,0 @@
|
||||
github.com/apache/arrow/go/v14 v14.0.2 h1:N8OkaJEOfI3mEZt07BIkvo4sC6XDbL+48MBPWO5IONw=
|
||||
github.com/apache/arrow/go/v14 v14.0.2/go.mod h1:u3fgh3EdgN/YQ8cVQRguVW3R+seMybFg8QBQ5LU+eBY=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0=
|
||||
github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw=
|
||||
github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM=
|
||||
github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg=
|
||||
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
||||
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
||||
github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg=
|
||||
github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I=
|
||||
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
|
||||
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/google/flatbuffers v23.5.26+incompatible h1:M9dgRyhJemaM4Sw8+66GHBu8ioaQmyPLg1b8VwK5WJg=
|
||||
github.com/google/flatbuffers v23.5.26+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
|
||||
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
|
||||
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/marcboeker/go-duckdb v1.6.5 h1:XCfR1JVZxsemcSPxRQKK0R0ESfgRMHTEqh3Y+dv40SI=
|
||||
github.com/marcboeker/go-duckdb v1.6.5/go.mod h1:WtWeqqhZoTke/Nbd7V9lnBx7I2/A/q0SAq/urGzPCMs=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
|
||||
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
||||
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
||||
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
|
||||
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
|
||||
github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ=
|
||||
github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.6 h1:Sovz9sDSwbOz9tgUy8JpT+KgCkPYJEN/oYzlJiYTNLg=
|
||||
github.com/rivo/uniseg v0.4.6/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY=
|
||||
golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ=
|
||||
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw=
|
||||
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
||||
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc=
|
||||
golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=
|
||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
|
||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
|
||||
gonum.org/v1/gonum v0.12.0 h1:xKuo6hzt+gMav00meVPUlXwSdoEJP46BR+wdxQEFK2o=
|
||||
gonum.org/v1/gonum v0.12.0/go.mod h1:73TDxJfAAHeA8Mk9mf8NlIppyhQNo5GLTcYeqgo2lvY=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -1,6 +0,0 @@
|
||||
go 1.22.2
|
||||
|
||||
use (
|
||||
.
|
||||
/home/lucas/.local_agentes/backend
|
||||
)
|
||||
@@ -1,40 +0,0 @@
|
||||
github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0CRv0ky0k6m906ixxpzmDRLvX58TFUKS2eePweuyxk=
|
||||
github.com/alecthomas/participle/v2 v2.1.0/go.mod h1:Y1+hAs8DHPmc3YUFzqllV+eSQ9ljPTk0ZkPMtEdAx2c=
|
||||
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
github.com/apache/thrift v0.17.0/go.mod h1:OLxhMRJxomX+1I/KUw03qoV3mMz16BwaKI+d4fPBx7Q=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
|
||||
github.com/goccy/go-yaml v1.11.0/go.mod h1:H+mJrWtjPTJAHvRbV09MCK9xYwODM+wRTVFFTWckfng=
|
||||
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||
github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY=
|
||||
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/substrait-io/substrait-go v0.4.2/go.mod h1:qhpnLmrcvAnlZsUyPXZRqldiHapPTXC3t7xFgDi3aQg=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20231002182017-d307bd883b97/go.mod h1:v7nGkzlmW8P3n/bKmWBn2WpBjpOEx8Q6gMueudAmKfY=
|
||||
google.golang.org/grpc v1.58.2/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0=
|
||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
||||
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
|
||||
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
|
||||
modernc.org/libc v1.22.4/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sqlite v1.21.2/go.mod h1:cxbLkB5WS32DnQqeH4h4o1B0eMr8W/y8/RGuxQ3JsC0=
|
||||
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -1,20 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"docker-tui/app"
|
||||
|
||||
"github.com/lucasdataproyects/devfactory/tui"
|
||||
)
|
||||
|
||||
func main() {
|
||||
model := app.New()
|
||||
result := tui.RunFullscreen(model)
|
||||
|
||||
if result.IsErr() {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", result.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
package views
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/lucasdataproyects/devfactory/tui"
|
||||
)
|
||||
|
||||
type composeState int
|
||||
|
||||
const (
|
||||
composeLoading composeState = iota
|
||||
composeList
|
||||
composeAction
|
||||
composeLogs
|
||||
)
|
||||
|
||||
type composeLoadedMsg []ComposeService
|
||||
type composeActionMsg struct{ output string; err error }
|
||||
type composeLogsMsg struct{ output string; err error }
|
||||
|
||||
type ComposeModel struct {
|
||||
state composeState
|
||||
list tui.ListModel
|
||||
spinner tui.SpinnerModel
|
||||
styles tui.Styles
|
||||
services []ComposeService
|
||||
output string
|
||||
scrollOff int
|
||||
err error
|
||||
}
|
||||
|
||||
func NewComposeModel(styles tui.Styles) ComposeModel {
|
||||
return ComposeModel{
|
||||
state: composeLoading,
|
||||
list: tui.NewList(nil),
|
||||
spinner: tui.NewSpinner("Loading compose services..."),
|
||||
styles: styles,
|
||||
}
|
||||
}
|
||||
|
||||
func (m ComposeModel) Init() tea.Cmd {
|
||||
return tea.Batch(m.spinner.Init(), loadCompose)
|
||||
}
|
||||
|
||||
func loadCompose() tea.Msg {
|
||||
services, err := ComposePS()
|
||||
if err != nil {
|
||||
return composeLoadedMsg(nil)
|
||||
}
|
||||
return composeLoadedMsg(services)
|
||||
}
|
||||
|
||||
func (m ComposeModel) Update(msg tea.Msg) (ComposeModel, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case composeLoadedMsg:
|
||||
m.services = []ComposeService(msg)
|
||||
items := make([]tui.ListItem, 0, len(m.services)+2)
|
||||
// Add action items at the top
|
||||
items = append(items,
|
||||
tui.ListItem{Title: "▶ Compose Up", Description: "docker compose up -d", Value: "up"},
|
||||
tui.ListItem{Title: "■ Compose Down", Description: "docker compose down", Value: "down"},
|
||||
)
|
||||
for _, s := range m.services {
|
||||
stateIcon := "●"
|
||||
if s.State == "running" {
|
||||
stateIcon = "▶"
|
||||
}
|
||||
items = append(items, tui.ListItem{
|
||||
Title: fmt.Sprintf("%s %s", stateIcon, s.Name),
|
||||
Description: fmt.Sprintf("Service: %s — %s", s.Service, s.Status),
|
||||
Value: s,
|
||||
})
|
||||
}
|
||||
m.list.SetItems(items)
|
||||
m.state = composeList
|
||||
return m, nil
|
||||
|
||||
case composeActionMsg:
|
||||
m.output = msg.output
|
||||
if msg.err != nil {
|
||||
m.output = fmt.Sprintf("Error: %v", msg.err)
|
||||
}
|
||||
m.state = composeList
|
||||
return m, loadCompose
|
||||
|
||||
case composeLogsMsg:
|
||||
m.output = msg.output
|
||||
if msg.err != nil {
|
||||
m.output = fmt.Sprintf("Error: %v", msg.err)
|
||||
}
|
||||
m.state = composeLogs
|
||||
m.scrollOff = 0
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch m.state {
|
||||
case composeList:
|
||||
switch msg.String() {
|
||||
case "r":
|
||||
m.state = composeLoading
|
||||
return m, tea.Batch(m.spinner.Init(), loadCompose)
|
||||
case "l":
|
||||
m.state = composeAction
|
||||
return m, func() tea.Msg {
|
||||
output, err := ComposeLogs(100)
|
||||
return composeLogsMsg{output: output, err: err}
|
||||
}
|
||||
case "enter":
|
||||
if item := m.list.SelectedItem(); item != nil {
|
||||
switch v := item.Value.(type) {
|
||||
case string:
|
||||
m.state = composeAction
|
||||
if v == "up" {
|
||||
return m, func() tea.Msg {
|
||||
output, err := ComposeUp()
|
||||
return composeActionMsg{output: output, err: err}
|
||||
}
|
||||
}
|
||||
return m, func() tea.Msg {
|
||||
output, err := ComposeDown()
|
||||
return composeActionMsg{output: output, err: err}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case composeLogs:
|
||||
switch msg.String() {
|
||||
case "j", "down":
|
||||
m.scrollOff++
|
||||
case "k", "up":
|
||||
if m.scrollOff > 0 {
|
||||
m.scrollOff--
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
switch m.state {
|
||||
case composeLoading, composeAction:
|
||||
var model tea.Model
|
||||
model, cmd = m.spinner.Update(msg)
|
||||
m.spinner = model.(tui.SpinnerModel)
|
||||
case composeList:
|
||||
var model tea.Model
|
||||
model, cmd = m.list.Update(msg)
|
||||
m.list = model.(tui.ListModel)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// HandleBack retrocede un nivel. Retorna true si ya estaba en estado base.
|
||||
func (m *ComposeModel) HandleBack() bool {
|
||||
switch m.state {
|
||||
case composeLogs:
|
||||
m.state = composeList
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func (m ComposeModel) View() string {
|
||||
switch m.state {
|
||||
case composeLoading, composeAction:
|
||||
return m.spinner.View()
|
||||
case composeList:
|
||||
if len(m.services) == 0 {
|
||||
help := m.styles.Muted.Render(" No compose services. Use Enter on 'Compose Up' or press 'r' to refresh.")
|
||||
return m.list.View() + "\n" + help
|
||||
}
|
||||
help := m.styles.Muted.Render(" Enter: up/down │ l: logs │ r: refresh")
|
||||
return m.list.View() + "\n" + help
|
||||
case composeLogs:
|
||||
return m.renderLogs()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m ComposeModel) renderLogs() string {
|
||||
lines := strings.Split(m.output, "\n")
|
||||
if len(lines) == 0 {
|
||||
lines = []string{"(empty)"}
|
||||
}
|
||||
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("Compose Logs")
|
||||
content := strings.Join(visible, "\n")
|
||||
help := m.styles.Muted.Render(" j/k: scroll │ Esc: back")
|
||||
|
||||
return header + "\n" + content + "\n" + help
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
package views
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/lucasdataproyects/devfactory/tui"
|
||||
)
|
||||
|
||||
type containersState int
|
||||
|
||||
const (
|
||||
containersLoading containersState = iota
|
||||
containersList
|
||||
containersAction
|
||||
containersLogs
|
||||
)
|
||||
|
||||
type containersLoadedMsg []DockerContainer
|
||||
type containersActionMsg struct{ output string; err error }
|
||||
type containersLogsMsg struct{ output string; err error }
|
||||
|
||||
type ContainersModel struct {
|
||||
state containersState
|
||||
list tui.FilteredListModel
|
||||
spinner tui.SpinnerModel
|
||||
styles tui.Styles
|
||||
containers []DockerContainer
|
||||
output string
|
||||
scrollOff int
|
||||
err error
|
||||
}
|
||||
|
||||
func NewContainersModel(styles tui.Styles) ContainersModel {
|
||||
return ContainersModel{
|
||||
state: containersLoading,
|
||||
list: tui.NewFilteredList(nil, "Filter containers..."),
|
||||
spinner: tui.NewSpinner("Loading containers..."),
|
||||
styles: styles,
|
||||
}
|
||||
}
|
||||
|
||||
func (m ContainersModel) Init() tea.Cmd {
|
||||
return tea.Batch(
|
||||
m.spinner.Init(),
|
||||
loadContainers,
|
||||
)
|
||||
}
|
||||
|
||||
func loadContainers() tea.Msg {
|
||||
containers, err := ListContainers()
|
||||
if err != nil {
|
||||
return containersLoadedMsg(nil)
|
||||
}
|
||||
return containersLoadedMsg(containers)
|
||||
}
|
||||
|
||||
func (m ContainersModel) Update(msg tea.Msg) (ContainersModel, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case containersLoadedMsg:
|
||||
m.containers = []DockerContainer(msg)
|
||||
items := make([]tui.ListItem, len(m.containers))
|
||||
for i, c := range m.containers {
|
||||
stateIcon := "●"
|
||||
if c.State == "running" {
|
||||
stateIcon = "▶"
|
||||
} else if c.State == "exited" {
|
||||
stateIcon = "■"
|
||||
}
|
||||
items[i] = tui.ListItem{
|
||||
Title: fmt.Sprintf("%s %s", stateIcon, c.Names),
|
||||
Description: fmt.Sprintf("%s — %s", c.Image, c.Status),
|
||||
Value: c,
|
||||
}
|
||||
}
|
||||
m.list.SetItems(items)
|
||||
m.state = containersList
|
||||
return m, nil
|
||||
|
||||
case containersActionMsg:
|
||||
m.output = msg.output
|
||||
if msg.err != nil {
|
||||
m.output = fmt.Sprintf("Error: %v", msg.err)
|
||||
}
|
||||
m.state = containersList
|
||||
// Refresh after action
|
||||
return m, loadContainers
|
||||
|
||||
case containersLogsMsg:
|
||||
m.output = msg.output
|
||||
if msg.err != nil {
|
||||
m.output = fmt.Sprintf("Error: %v", msg.err)
|
||||
}
|
||||
m.state = containersLogs
|
||||
m.scrollOff = 0
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch m.state {
|
||||
case containersList:
|
||||
switch msg.String() {
|
||||
case "r":
|
||||
m.state = containersLoading
|
||||
return m, tea.Batch(m.spinner.Init(), loadContainers)
|
||||
case "enter":
|
||||
if item := m.list.SelectedItem(); item != nil {
|
||||
c := item.Value.(DockerContainer)
|
||||
if c.State == "running" {
|
||||
return m, stopContainerCmd(c.ID)
|
||||
}
|
||||
return m, startContainerCmd(c.ID)
|
||||
}
|
||||
case "l":
|
||||
if item := m.list.SelectedItem(); item != nil {
|
||||
c := item.Value.(DockerContainer)
|
||||
m.state = containersAction
|
||||
return m, logsContainerCmd(c.ID)
|
||||
}
|
||||
case "x":
|
||||
if item := m.list.SelectedItem(); item != nil {
|
||||
c := item.Value.(DockerContainer)
|
||||
return m, restartContainerCmd(c.ID)
|
||||
}
|
||||
}
|
||||
case containersLogs:
|
||||
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 containersLoading:
|
||||
var spinnerModel tea.Model
|
||||
spinnerModel, cmd = m.spinner.Update(msg)
|
||||
m.spinner = spinnerModel.(tui.SpinnerModel)
|
||||
case containersList:
|
||||
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 estaba en estado base (el caller debe salir).
|
||||
func (m *ContainersModel) HandleBack() bool {
|
||||
switch m.state {
|
||||
case containersLogs:
|
||||
m.state = containersList
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func (m ContainersModel) View() string {
|
||||
switch m.state {
|
||||
case containersLoading:
|
||||
return m.spinner.View()
|
||||
case containersList:
|
||||
if len(m.containers) == 0 {
|
||||
return m.styles.Muted.Render("No containers found. Press 'r' to refresh.")
|
||||
}
|
||||
help := m.styles.Muted.Render(" Enter: start/stop │ l: logs │ x: restart │ r: refresh │ /: filter")
|
||||
return m.list.View() + "\n" + help
|
||||
case containersAction:
|
||||
return m.spinner.View()
|
||||
case containersLogs:
|
||||
return m.renderOutput()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m ContainersModel) 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("Container Logs")
|
||||
content := lipgloss.JoinVertical(lipgloss.Left, visible...)
|
||||
help := m.styles.Muted.Render(" j/k: scroll │ Esc: back")
|
||||
|
||||
return header + "\n" + content + "\n" + help
|
||||
}
|
||||
|
||||
func startContainerCmd(id string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := StartContainer(id)
|
||||
return containersActionMsg{output: "Started " + id, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func stopContainerCmd(id string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := StopContainer(id)
|
||||
return containersActionMsg{output: "Stopped " + id, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func restartContainerCmd(id string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := RestartContainer(id)
|
||||
return containersActionMsg{output: "Restarted " + id, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func logsContainerCmd(id string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
output, err := ContainerLogs(id, 100)
|
||||
return containersLogsMsg{output: output, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
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 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
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
package views
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/lucasdataproyects/devfactory/shell"
|
||||
)
|
||||
|
||||
const dockerTimeout = 15 * time.Second
|
||||
|
||||
// --- Containers ---
|
||||
|
||||
func ListContainers() ([]DockerContainer, error) {
|
||||
result := shell.RunWithTimeout("docker", dockerTimeout, "ps", "-a", "--format", "{{json .}}")
|
||||
stdout, err := result.Both()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseJSONLines[DockerContainer](stdout.Stdout)
|
||||
}
|
||||
|
||||
func StartContainer(id string) error {
|
||||
_, err := shell.RunWithTimeout("docker", dockerTimeout, "start", id).Both()
|
||||
return err
|
||||
}
|
||||
|
||||
func StopContainer(id string) error {
|
||||
_, err := shell.RunWithTimeout("docker", dockerTimeout, "stop", id).Both()
|
||||
return err
|
||||
}
|
||||
|
||||
func RestartContainer(id string) error {
|
||||
_, err := shell.RunWithTimeout("docker", dockerTimeout, "restart", id).Both()
|
||||
return err
|
||||
}
|
||||
|
||||
func ContainerLogs(id string, lines int) (string, error) {
|
||||
result := shell.RunWithTimeout("docker", dockerTimeout, "logs", "--tail", itoa(lines), id)
|
||||
out, err := result.Both()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// docker logs writes to both stdout and stderr
|
||||
output := out.Stdout
|
||||
if out.Stderr != "" {
|
||||
if output != "" {
|
||||
output += "\n"
|
||||
}
|
||||
output += out.Stderr
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// --- Images ---
|
||||
|
||||
func ListImages() ([]DockerImage, error) {
|
||||
result := shell.RunWithTimeout("docker", dockerTimeout, "image", "ls", "--format", "{{json .}}")
|
||||
stdout, err := result.Both()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseJSONLines[DockerImage](stdout.Stdout)
|
||||
}
|
||||
|
||||
func PullImage(name string) (string, error) {
|
||||
result := shell.RunWithTimeout("docker", 120*time.Second, "pull", name)
|
||||
out, err := result.Both()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return out.Stdout, nil
|
||||
}
|
||||
|
||||
func RemoveImage(id string) error {
|
||||
_, err := shell.RunWithTimeout("docker", dockerTimeout, "rmi", id).Both()
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Volumes ---
|
||||
|
||||
func ListVolumes() ([]DockerVolume, error) {
|
||||
result := shell.RunWithTimeout("docker", dockerTimeout, "volume", "ls", "--format", "{{json .}}")
|
||||
stdout, err := result.Both()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseJSONLines[DockerVolume](stdout.Stdout)
|
||||
}
|
||||
|
||||
func CreateVolume(name string) error {
|
||||
args := []string{"volume", "create"}
|
||||
if name != "" {
|
||||
args = append(args, name)
|
||||
}
|
||||
_, err := shell.RunWithTimeout("docker", dockerTimeout, args...).Both()
|
||||
return err
|
||||
}
|
||||
|
||||
func RemoveVolume(name string) error {
|
||||
_, err := shell.RunWithTimeout("docker", dockerTimeout, "volume", "rm", name).Both()
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Networks ---
|
||||
|
||||
func ListNetworks() ([]DockerNetwork, error) {
|
||||
result := shell.RunWithTimeout("docker", dockerTimeout, "network", "ls", "--format", "{{json .}}")
|
||||
stdout, err := result.Both()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseJSONLines[DockerNetwork](stdout.Stdout)
|
||||
}
|
||||
|
||||
func CreateNetwork(name string) error {
|
||||
_, err := shell.RunWithTimeout("docker", dockerTimeout, "network", "create", name).Both()
|
||||
return err
|
||||
}
|
||||
|
||||
func RemoveNetwork(name string) error {
|
||||
_, err := shell.RunWithTimeout("docker", dockerTimeout, "network", "rm", name).Both()
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Compose ---
|
||||
|
||||
func ComposePS() ([]ComposeService, error) {
|
||||
result := shell.RunWithTimeout("docker", dockerTimeout, "compose", "ps", "--format", "json")
|
||||
stdout, err := result.Both()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// docker compose ps --format json returns a JSON array
|
||||
var services []ComposeService
|
||||
if err := json.Unmarshal([]byte(stdout.Stdout), &services); err != nil {
|
||||
// Try line-by-line as fallback
|
||||
return parseJSONLines[ComposeService](stdout.Stdout)
|
||||
}
|
||||
return services, nil
|
||||
}
|
||||
|
||||
func ComposeUp() (string, error) {
|
||||
result := shell.RunWithTimeout("docker", 120*time.Second, "compose", "up", "-d")
|
||||
out, err := result.Both()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return out.Stdout + out.Stderr, nil
|
||||
}
|
||||
|
||||
func ComposeDown() (string, error) {
|
||||
result := shell.RunWithTimeout("docker", 60*time.Second, "compose", "down")
|
||||
out, err := result.Both()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return out.Stdout + out.Stderr, nil
|
||||
}
|
||||
|
||||
func ComposeLogs(lines int) (string, error) {
|
||||
result := shell.RunWithTimeout("docker", dockerTimeout, "compose", "logs", "--tail", itoa(lines))
|
||||
out, err := result.Both()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return out.Stdout + out.Stderr, nil
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func parseJSONLines[T any](s string) ([]T, error) {
|
||||
var result []T
|
||||
for _, line := range strings.Split(strings.TrimSpace(s), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var item T
|
||||
if err := json.Unmarshal([]byte(line), &item); err != nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
return fmt.Sprintf("%d", n)
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
package views
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/lucasdataproyects/devfactory/tui"
|
||||
)
|
||||
|
||||
type imagesState int
|
||||
|
||||
const (
|
||||
imagesLoading imagesState = iota
|
||||
imagesList
|
||||
imagesAction
|
||||
)
|
||||
|
||||
type imagesLoadedMsg []DockerImage
|
||||
type imagesActionMsg struct{ output string; err error }
|
||||
|
||||
type ImagesModel struct {
|
||||
state imagesState
|
||||
list tui.FilteredListModel
|
||||
spinner tui.SpinnerModel
|
||||
styles tui.Styles
|
||||
images []DockerImage
|
||||
err error
|
||||
}
|
||||
|
||||
func NewImagesModel(styles tui.Styles) ImagesModel {
|
||||
return ImagesModel{
|
||||
state: imagesLoading,
|
||||
list: tui.NewFilteredList(nil, "Filter images..."),
|
||||
spinner: tui.NewSpinner("Loading images..."),
|
||||
styles: styles,
|
||||
}
|
||||
}
|
||||
|
||||
func (m ImagesModel) Init() tea.Cmd {
|
||||
return tea.Batch(m.spinner.Init(), loadImages)
|
||||
}
|
||||
|
||||
func loadImages() tea.Msg {
|
||||
images, err := ListImages()
|
||||
if err != nil {
|
||||
return imagesLoadedMsg(nil)
|
||||
}
|
||||
return imagesLoadedMsg(images)
|
||||
}
|
||||
|
||||
func (m ImagesModel) Update(msg tea.Msg) (ImagesModel, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case imagesLoadedMsg:
|
||||
m.images = []DockerImage(msg)
|
||||
items := make([]tui.ListItem, len(m.images))
|
||||
for i, img := range m.images {
|
||||
tag := img.Tag
|
||||
if tag == "" {
|
||||
tag = "latest"
|
||||
}
|
||||
items[i] = tui.ListItem{
|
||||
Title: fmt.Sprintf("%s:%s", img.Repository, tag),
|
||||
Description: fmt.Sprintf("Size: %s — %s", img.Size, img.ID[:12]),
|
||||
Value: img,
|
||||
}
|
||||
}
|
||||
m.list.SetItems(items)
|
||||
m.state = imagesList
|
||||
return m, nil
|
||||
|
||||
case imagesActionMsg:
|
||||
if msg.err != nil {
|
||||
m.err = msg.err
|
||||
}
|
||||
m.state = imagesList
|
||||
return m, loadImages
|
||||
|
||||
case tea.KeyMsg:
|
||||
if m.state == imagesList {
|
||||
switch msg.String() {
|
||||
case "r":
|
||||
m.state = imagesLoading
|
||||
return m, tea.Batch(m.spinner.Init(), loadImages)
|
||||
case "d", "delete":
|
||||
if item := m.list.SelectedItem(); item != nil {
|
||||
img := item.Value.(DockerImage)
|
||||
m.state = imagesAction
|
||||
return m, func() tea.Msg {
|
||||
err := RemoveImage(img.ID)
|
||||
return imagesActionMsg{output: "Removed", err: err}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
switch m.state {
|
||||
case imagesLoading, imagesAction:
|
||||
var model tea.Model
|
||||
model, cmd = m.spinner.Update(msg)
|
||||
m.spinner = model.(tui.SpinnerModel)
|
||||
case imagesList:
|
||||
var model tea.Model
|
||||
model, cmd = m.list.Update(msg)
|
||||
m.list = model.(tui.FilteredListModel)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// HandleBack retrocede un nivel. Retorna true si ya estaba en estado base.
|
||||
func (m *ImagesModel) HandleBack() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (m ImagesModel) View() string {
|
||||
switch m.state {
|
||||
case imagesLoading, imagesAction:
|
||||
return m.spinner.View()
|
||||
case imagesList:
|
||||
if len(m.images) == 0 {
|
||||
return m.styles.Muted.Render("No images found. Press 'r' to refresh.")
|
||||
}
|
||||
help := m.styles.Muted.Render(" d: remove │ r: refresh │ /: filter")
|
||||
view := m.list.View() + "\n" + help
|
||||
if m.err != nil {
|
||||
view += "\n" + m.styles.Error.Render(fmt.Sprintf(" Error: %v", m.err))
|
||||
}
|
||||
return view
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package views
|
||||
|
||||
// Navigation key constants.
|
||||
const (
|
||||
KeyQuit = "ctrl+c"
|
||||
KeyEsc = "esc"
|
||||
KeyBack = "0"
|
||||
KeyTab = "tab"
|
||||
)
|
||||
|
||||
// IsBack returns true if the key should trigger back navigation.
|
||||
func IsBack(key string) bool {
|
||||
return key == KeyEsc || key == KeyBack
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
package views
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/lucasdataproyects/devfactory/tui"
|
||||
)
|
||||
|
||||
type networksState int
|
||||
|
||||
const (
|
||||
networksLoading networksState = iota
|
||||
networksList
|
||||
networksAction
|
||||
)
|
||||
|
||||
type networksLoadedMsg []DockerNetwork
|
||||
type networksActionMsg struct{ output string; err error }
|
||||
|
||||
type NetworksModel struct {
|
||||
state networksState
|
||||
list tui.ListModel
|
||||
spinner tui.SpinnerModel
|
||||
styles tui.Styles
|
||||
networks []DockerNetwork
|
||||
err error
|
||||
}
|
||||
|
||||
func NewNetworksModel(styles tui.Styles) NetworksModel {
|
||||
return NetworksModel{
|
||||
state: networksLoading,
|
||||
list: tui.NewList(nil),
|
||||
spinner: tui.NewSpinner("Loading networks..."),
|
||||
styles: styles,
|
||||
}
|
||||
}
|
||||
|
||||
func (m NetworksModel) Init() tea.Cmd {
|
||||
return tea.Batch(m.spinner.Init(), loadNetworks)
|
||||
}
|
||||
|
||||
func loadNetworks() tea.Msg {
|
||||
networks, err := ListNetworks()
|
||||
if err != nil {
|
||||
return networksLoadedMsg(nil)
|
||||
}
|
||||
return networksLoadedMsg(networks)
|
||||
}
|
||||
|
||||
func (m NetworksModel) Update(msg tea.Msg) (NetworksModel, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case networksLoadedMsg:
|
||||
m.networks = []DockerNetwork(msg)
|
||||
items := make([]tui.ListItem, len(m.networks))
|
||||
for i, n := range m.networks {
|
||||
items[i] = tui.ListItem{
|
||||
Title: n.Name,
|
||||
Description: fmt.Sprintf("Driver: %s — Scope: %s", n.Driver, n.Scope),
|
||||
Value: n,
|
||||
}
|
||||
}
|
||||
m.list.SetItems(items)
|
||||
m.state = networksList
|
||||
return m, nil
|
||||
|
||||
case networksActionMsg:
|
||||
if msg.err != nil {
|
||||
m.err = msg.err
|
||||
}
|
||||
m.state = networksList
|
||||
return m, loadNetworks
|
||||
|
||||
case tea.KeyMsg:
|
||||
if m.state == networksList {
|
||||
switch msg.String() {
|
||||
case "r":
|
||||
m.state = networksLoading
|
||||
return m, tea.Batch(m.spinner.Init(), loadNetworks)
|
||||
case "d", "delete":
|
||||
if item := m.list.SelectedItem(); item != nil {
|
||||
net := item.Value.(DockerNetwork)
|
||||
m.state = networksAction
|
||||
return m, func() tea.Msg {
|
||||
err := RemoveNetwork(net.Name)
|
||||
return networksActionMsg{output: "Removed", err: err}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
switch m.state {
|
||||
case networksLoading, networksAction:
|
||||
var model tea.Model
|
||||
model, cmd = m.spinner.Update(msg)
|
||||
m.spinner = model.(tui.SpinnerModel)
|
||||
case networksList:
|
||||
var model tea.Model
|
||||
model, cmd = m.list.Update(msg)
|
||||
m.list = model.(tui.ListModel)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// HandleBack retrocede un nivel. Retorna true si ya estaba en estado base.
|
||||
func (m *NetworksModel) HandleBack() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (m NetworksModel) View() string {
|
||||
switch m.state {
|
||||
case networksLoading, networksAction:
|
||||
return m.spinner.View()
|
||||
case networksList:
|
||||
if len(m.networks) == 0 {
|
||||
return m.styles.Muted.Render("No networks found. Press 'r' to refresh.")
|
||||
}
|
||||
help := m.styles.Muted.Render(" d: remove │ r: refresh")
|
||||
view := m.list.View() + "\n" + help
|
||||
if m.err != nil {
|
||||
view += "\n" + m.styles.Error.Render(fmt.Sprintf(" Error: %v", m.err))
|
||||
}
|
||||
return view
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package views
|
||||
|
||||
// DockerContainer represents a container from docker ps --format json.
|
||||
type DockerContainer struct {
|
||||
ID string `json:"ID"`
|
||||
Names string `json:"Names"`
|
||||
Image string `json:"Image"`
|
||||
Status string `json:"Status"`
|
||||
State string `json:"State"`
|
||||
Ports string `json:"Ports"`
|
||||
}
|
||||
|
||||
// DockerImage represents an image from docker image ls --format json.
|
||||
type DockerImage struct {
|
||||
ID string `json:"ID"`
|
||||
Repository string `json:"Repository"`
|
||||
Tag string `json:"Tag"`
|
||||
Size string `json:"Size"`
|
||||
CreatedAt string `json:"CreatedAt"`
|
||||
}
|
||||
|
||||
// DockerVolume represents a volume from docker volume ls --format json.
|
||||
type DockerVolume struct {
|
||||
Name string `json:"Name"`
|
||||
Driver string `json:"Driver"`
|
||||
Mountpoint string `json:"Mountpoint"`
|
||||
}
|
||||
|
||||
// DockerNetwork represents a network from docker network ls --format json.
|
||||
type DockerNetwork struct {
|
||||
ID string `json:"ID"`
|
||||
Name string `json:"Name"`
|
||||
Driver string `json:"Driver"`
|
||||
Scope string `json:"Scope"`
|
||||
}
|
||||
|
||||
// ComposeService represents a compose service from docker compose ps --format json.
|
||||
type ComposeService struct {
|
||||
ID string `json:"ID"`
|
||||
Name string `json:"Name"`
|
||||
Service string `json:"Service"`
|
||||
State string `json:"State"`
|
||||
Status string `json:"Status"`
|
||||
Ports string `json:"Ports"`
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
package views
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/lucasdataproyects/devfactory/tui"
|
||||
)
|
||||
|
||||
type volumesState int
|
||||
|
||||
const (
|
||||
volumesLoading volumesState = iota
|
||||
volumesList
|
||||
volumesAction
|
||||
)
|
||||
|
||||
type volumesLoadedMsg []DockerVolume
|
||||
type volumesActionMsg struct{ output string; err error }
|
||||
|
||||
type VolumesModel struct {
|
||||
state volumesState
|
||||
list tui.ListModel
|
||||
spinner tui.SpinnerModel
|
||||
styles tui.Styles
|
||||
volumes []DockerVolume
|
||||
err error
|
||||
}
|
||||
|
||||
func NewVolumesModel(styles tui.Styles) VolumesModel {
|
||||
return VolumesModel{
|
||||
state: volumesLoading,
|
||||
list: tui.NewList(nil),
|
||||
spinner: tui.NewSpinner("Loading volumes..."),
|
||||
styles: styles,
|
||||
}
|
||||
}
|
||||
|
||||
func (m VolumesModel) Init() tea.Cmd {
|
||||
return tea.Batch(m.spinner.Init(), loadVolumes)
|
||||
}
|
||||
|
||||
func loadVolumes() tea.Msg {
|
||||
volumes, err := ListVolumes()
|
||||
if err != nil {
|
||||
return volumesLoadedMsg(nil)
|
||||
}
|
||||
return volumesLoadedMsg(volumes)
|
||||
}
|
||||
|
||||
func (m VolumesModel) Update(msg tea.Msg) (VolumesModel, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case volumesLoadedMsg:
|
||||
m.volumes = []DockerVolume(msg)
|
||||
items := make([]tui.ListItem, len(m.volumes))
|
||||
for i, v := range m.volumes {
|
||||
items[i] = tui.ListItem{
|
||||
Title: v.Name,
|
||||
Description: fmt.Sprintf("Driver: %s", v.Driver),
|
||||
Value: v,
|
||||
}
|
||||
}
|
||||
m.list.SetItems(items)
|
||||
m.state = volumesList
|
||||
return m, nil
|
||||
|
||||
case volumesActionMsg:
|
||||
if msg.err != nil {
|
||||
m.err = msg.err
|
||||
}
|
||||
m.state = volumesList
|
||||
return m, loadVolumes
|
||||
|
||||
case tea.KeyMsg:
|
||||
if m.state == volumesList {
|
||||
switch msg.String() {
|
||||
case "r":
|
||||
m.state = volumesLoading
|
||||
return m, tea.Batch(m.spinner.Init(), loadVolumes)
|
||||
case "d", "delete":
|
||||
if item := m.list.SelectedItem(); item != nil {
|
||||
vol := item.Value.(DockerVolume)
|
||||
m.state = volumesAction
|
||||
return m, func() tea.Msg {
|
||||
err := RemoveVolume(vol.Name)
|
||||
return volumesActionMsg{output: "Removed", err: err}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
switch m.state {
|
||||
case volumesLoading, volumesAction:
|
||||
var model tea.Model
|
||||
model, cmd = m.spinner.Update(msg)
|
||||
m.spinner = model.(tui.SpinnerModel)
|
||||
case volumesList:
|
||||
var model tea.Model
|
||||
model, cmd = m.list.Update(msg)
|
||||
m.list = model.(tui.ListModel)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// HandleBack retrocede un nivel. Retorna true si ya estaba en estado base.
|
||||
func (m *VolumesModel) HandleBack() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (m VolumesModel) View() string {
|
||||
switch m.state {
|
||||
case volumesLoading, volumesAction:
|
||||
return m.spinner.View()
|
||||
case volumesList:
|
||||
if len(m.volumes) == 0 {
|
||||
return m.styles.Muted.Render("No volumes found. Press 'r' to refresh.")
|
||||
}
|
||||
help := m.styles.Muted.Render(" d: remove │ r: refresh")
|
||||
view := m.list.View() + "\n" + help
|
||||
if m.err != nil {
|
||||
view += "\n" + m.styles.Error.Render(fmt.Sprintf(" Error: %v", m.err))
|
||||
}
|
||||
return view
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
---
|
||||
name: metabase_registry
|
||||
lang: py
|
||||
domain: analytics
|
||||
description: "Setup y dashboards automaticos de Metabase para visualizar metricas del fn-registry y operations.db de cada app."
|
||||
tags: [metabase, dashboard, analytics, visualization, operations]
|
||||
uses_functions:
|
||||
- metabase_auth_py_infra
|
||||
- metabase_create_card_py_infra
|
||||
- metabase_create_dashboard_py_infra
|
||||
- metabase_update_dashboard_py_infra
|
||||
- metabase_list_databases_py_infra
|
||||
- metabase_add_database_py_infra
|
||||
- metabase_list_dashboards_py_infra
|
||||
- metabase_delete_dashboard_py_infra
|
||||
- metabase_create_user_py_infra
|
||||
uses_types: []
|
||||
framework: httpx
|
||||
entry_point: "main.py"
|
||||
dir_path: "apps/metabase_registry"
|
||||
---
|
||||
|
||||
## Arquitectura
|
||||
|
||||
Metabase corre en Docker (`fn_registry-metabase`) con Postgres como backend interno.
|
||||
Las bases de datos SQLite del proyecto se montan como bind mounts RW en `/data/`:
|
||||
|
||||
| Database | Mount en container | Contenido |
|
||||
|----------|-------------------|-----------|
|
||||
| registry.db | `/data/registry/registry.db` | functions, types, proposals, apps |
|
||||
| ops-docker-tui | `/data/ops-docker-tui/operations.db` | entities, relations, executions |
|
||||
| ops-metabase-registry | `/data/ops-metabase-registry/operations.db` | entities, relations, executions |
|
||||
| ops-pipeline-launcher | `/data/ops-pipeline-launcher/operations.db` | entities, relations, executions |
|
||||
|
||||
## Dashboards
|
||||
|
||||
| Dashboard | Contenido |
|
||||
|-----------|-----------|
|
||||
| fn-registry Overview | KPIs, distribucion y analisis del registry |
|
||||
| fn-registry Apps | Apps por lenguaje, dominio, dependencias |
|
||||
| ops: \<app-name\> | Dashboard operativo por app (entities, relations, executions, assertions) |
|
||||
|
||||
## Permisos SQLite en Docker
|
||||
|
||||
Metabase corre Java como UID 2000 (usuario `metabase`). SQLite necesita crear journal/WAL
|
||||
files en el mismo directorio que la BD. Reglas:
|
||||
|
||||
- **NUNCA** hacer `chown` dentro del container: se propaga al host via bind mount y rompe permisos locales.
|
||||
- **Usar `chmod`**: `chmod 777` en directorios, `chmod 666` en archivos `.db`.
|
||||
- **Pipeline automatico**: `./fn run metabase_fix_permissions` arregla todos los permisos.
|
||||
- **Ejecutar despues de**: recrear container, añadir nueva database, o ver error `SQLITE_READONLY_DIRECTORY`.
|
||||
|
||||
## Flujo para app nueva
|
||||
|
||||
```bash
|
||||
# 1. Crear operations.db
|
||||
./fn ops init apps/nueva_app
|
||||
|
||||
# 2. Recrear container con el nuevo mount
|
||||
docker stop fn_registry-metabase && docker rm fn_registry-metabase
|
||||
docker run -d \
|
||||
--name fn_registry-metabase \
|
||||
--network fn_registry-net \
|
||||
-p 3000:3000 \
|
||||
-e MB_DB_TYPE=postgres -e MB_DB_DBNAME=metabase \
|
||||
-e MB_DB_PORT=5432 -e MB_DB_USER=metabase \
|
||||
-e MB_DB_PASS=metabase -e MB_DB_HOST=fn_registry-postgres \
|
||||
-v /home/lucas/fn_registry:/registry:ro \
|
||||
-v /home/lucas/fn_registry/registry.db:/data/registry/registry.db \
|
||||
-v /home/lucas/fn_registry/apps/docker_tui:/data/ops-docker-tui \
|
||||
-v /home/lucas/fn_registry/apps/metabase_registry:/data/ops-metabase-registry \
|
||||
-v /home/lucas/fn_registry/apps/pipeline_launcher:/data/ops-pipeline-launcher \
|
||||
-v /home/lucas/fn_registry/apps/nueva_app:/data/ops-nueva-app \
|
||||
metabase/metabase:latest
|
||||
|
||||
# 3. Fix permisos
|
||||
./fn run metabase_fix_permissions
|
||||
|
||||
# 4. Registrar database en Metabase
|
||||
./fn run metabase_add_ops_db nueva_app
|
||||
|
||||
# 5. Crear dashboard operativo
|
||||
./fn run metabase_create_ops_dashboard nueva_app
|
||||
```
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Funcion |
|
||||
|--------|---------|
|
||||
| `main.py` | Setup inicial: datasource + cards basicas + dashboard Overview |
|
||||
| `create_registry_dashboard.py` | Dashboard fn-registry Overview (18 cards) |
|
||||
| `create_apps_dashboard.py` | Dashboard fn-registry Apps (10 cards) |
|
||||
|
||||
## Pipelines relacionados
|
||||
|
||||
| Pipeline | ID | Funcion |
|
||||
|----------|-----|---------|
|
||||
| `metabase_add_ops_db` | `metabase_add_ops_db_py_pipelines` | Registra operations.db de una app |
|
||||
| `metabase_create_ops_dashboard` | `metabase_create_ops_dashboard_py_pipelines` | Crea dashboard operativo para una app |
|
||||
| `metabase_fix_permissions` | `metabase_fix_permissions_py_pipelines` | Arregla SQLITE_READONLY_DIRECTORY |
|
||||
|
||||
## Credenciales
|
||||
|
||||
En `.env` local (NO commitear).
|
||||
@@ -1,219 +0,0 @@
|
||||
"""Crea un dashboard en Metabase con metricas de la tabla apps del fn-registry."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "python", "functions"))
|
||||
|
||||
from metabase.client import metabase_auth
|
||||
from metabase import (
|
||||
metabase_list_databases,
|
||||
metabase_create_card,
|
||||
metabase_create_dashboard,
|
||||
metabase_update_dashboard,
|
||||
metabase_list_dashboards,
|
||||
)
|
||||
|
||||
# --- Config ---
|
||||
METABASE_URL = "http://localhost:3000"
|
||||
EMAIL = "admin@fnregistry.local"
|
||||
PASSWORD = "FnRegistry2024!"
|
||||
|
||||
# --- Layout ---
|
||||
# Grid de 24 unidades de ancho (estandar Metabase).
|
||||
# Fila 0 (h=5): 4 scalars de 6 unidades → KPIs
|
||||
# Fila 5 (h=8): 3 graficas de 8 unidades → distribucion general
|
||||
# Fila 13 (h=9): 2 graficas de 12 unidades → uso de funciones + frameworks
|
||||
# Fila 22 (h=8): tabla completa de apps → catalogo completo
|
||||
|
||||
CARDS = [
|
||||
# ---- Fila 0: KPIs escalares (h=5) ----
|
||||
{
|
||||
"name": "Total de Apps",
|
||||
"display": "scalar",
|
||||
"sql": "SELECT COUNT(*) AS total FROM apps;",
|
||||
"size_x": 6, "size_y": 5, "col": 0, "row": 0,
|
||||
},
|
||||
{
|
||||
"name": "Apps Go",
|
||||
"display": "scalar",
|
||||
"sql": "SELECT COUNT(*) AS apps_go FROM apps WHERE lang = 'go';",
|
||||
"size_x": 6, "size_y": 5, "col": 6, "row": 0,
|
||||
},
|
||||
{
|
||||
"name": "Apps Python",
|
||||
"display": "scalar",
|
||||
"sql": "SELECT COUNT(*) AS apps_python FROM apps WHERE lang = 'py';",
|
||||
"size_x": 6, "size_y": 5, "col": 12, "row": 0,
|
||||
},
|
||||
{
|
||||
"name": "Dominios con Apps",
|
||||
"display": "scalar",
|
||||
"sql": "SELECT COUNT(DISTINCT domain) AS dominios FROM apps;",
|
||||
"size_x": 6, "size_y": 5, "col": 18, "row": 0,
|
||||
},
|
||||
# ---- Fila 5: Distribucion general (h=8) ----
|
||||
{
|
||||
"name": "Apps por Lenguaje",
|
||||
"display": "bar",
|
||||
"sql": "SELECT lang, COUNT(*) AS cantidad FROM apps GROUP BY lang ORDER BY cantidad DESC;",
|
||||
"size_x": 8, "size_y": 8, "col": 0, "row": 5,
|
||||
},
|
||||
{
|
||||
"name": "Apps por Dominio",
|
||||
"display": "pie",
|
||||
"sql": "SELECT domain, COUNT(*) AS cantidad FROM apps GROUP BY domain ORDER BY cantidad DESC;",
|
||||
"size_x": 8, "size_y": 8, "col": 8, "row": 5,
|
||||
},
|
||||
{
|
||||
"name": "Apps con Framework",
|
||||
"display": "bar",
|
||||
"sql": """
|
||||
SELECT
|
||||
CASE WHEN framework = '' OR framework IS NULL THEN '(sin framework)' ELSE framework END AS framework,
|
||||
COUNT(*) AS cantidad
|
||||
FROM apps
|
||||
GROUP BY framework
|
||||
ORDER BY cantidad DESC;
|
||||
""",
|
||||
"size_x": 8, "size_y": 8, "col": 16, "row": 5,
|
||||
},
|
||||
# ---- Fila 13: Analisis de dependencias (h=9) ----
|
||||
{
|
||||
"name": "Apps con Mas Dependencias de Funciones",
|
||||
"display": "row",
|
||||
"sql": """
|
||||
SELECT
|
||||
name || ' (' || lang || ')' AS app,
|
||||
(LENGTH(uses_functions) - LENGTH(REPLACE(uses_functions, ',', ''))
|
||||
+ CASE WHEN uses_functions != '[]' AND uses_functions != '' THEN 1 ELSE 0 END) AS num_funciones_usadas
|
||||
FROM apps
|
||||
WHERE uses_functions != '[]' AND uses_functions != ''
|
||||
ORDER BY num_funciones_usadas DESC
|
||||
LIMIT 15;
|
||||
""",
|
||||
"size_x": 12, "size_y": 9, "col": 0, "row": 13,
|
||||
},
|
||||
{
|
||||
"name": "Funciones del Registry Mas Usadas en Apps",
|
||||
"display": "row",
|
||||
"sql": """
|
||||
WITH RECURSIVE split_uses(app_id, rest, val) AS (
|
||||
SELECT id, uses_functions || ',', NULL
|
||||
FROM apps
|
||||
WHERE uses_functions != '[]' AND uses_functions != ''
|
||||
UNION ALL
|
||||
SELECT app_id,
|
||||
SUBSTR(rest, INSTR(rest, ',') + 1),
|
||||
TRIM(SUBSTR(rest, 1, INSTR(rest, ',') - 1), ' "[]')
|
||||
FROM split_uses WHERE rest != ''
|
||||
)
|
||||
SELECT val AS funcion_usada, COUNT(*) AS veces_usada
|
||||
FROM split_uses
|
||||
WHERE val IS NOT NULL AND val != '' AND val != ']'
|
||||
GROUP BY val
|
||||
ORDER BY veces_usada DESC
|
||||
LIMIT 15;
|
||||
""",
|
||||
"size_x": 12, "size_y": 9, "col": 12, "row": 13,
|
||||
},
|
||||
# ---- Fila 22: Catalogo completo (h=9) ----
|
||||
{
|
||||
"name": "Catalogo de Apps",
|
||||
"display": "table",
|
||||
"sql": """
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
lang,
|
||||
domain,
|
||||
CASE WHEN framework = '' THEN '-' ELSE framework END AS framework,
|
||||
description,
|
||||
entry_point,
|
||||
updated_at
|
||||
FROM apps
|
||||
ORDER BY domain, name;
|
||||
""",
|
||||
"size_x": 24, "size_y": 9, "col": 0, "row": 22,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
print("Autenticando en Metabase...")
|
||||
client = metabase_auth(METABASE_URL, EMAIL, PASSWORD)
|
||||
|
||||
# Encontrar la database registry.db
|
||||
dbs = metabase_list_databases(client)
|
||||
registry_db_id = None
|
||||
for db in dbs:
|
||||
if "registry" in db.get("name", "").lower() or (
|
||||
db.get("engine") == "sqlite"
|
||||
and "registry" in db.get("details", {}).get("db", "")
|
||||
):
|
||||
registry_db_id = db["id"]
|
||||
print(f" Database encontrada: {db['name']} (id={db['id']})")
|
||||
break
|
||||
|
||||
if not registry_db_id:
|
||||
print("ERROR: No se encontro registry.db en Metabase.")
|
||||
print("Databases disponibles:")
|
||||
for db in dbs:
|
||||
print(f" - {db['id']}: {db['name']} ({db['engine']})")
|
||||
sys.exit(1)
|
||||
|
||||
# Verificar si ya existe un dashboard con este nombre
|
||||
existing = metabase_list_dashboards(client)
|
||||
for d in existing:
|
||||
if d.get("name") == "fn-registry Apps":
|
||||
print(f" Dashboard ya existe (id={d['id']}), recreando...")
|
||||
from metabase import metabase_delete_dashboard
|
||||
metabase_delete_dashboard(client, d["id"])
|
||||
|
||||
# Crear cards
|
||||
print("Creando cards...")
|
||||
created_cards = []
|
||||
for i, card_def in enumerate(CARDS):
|
||||
card = metabase_create_card(
|
||||
client,
|
||||
name=card_def["name"],
|
||||
dataset_query={
|
||||
"database": registry_db_id,
|
||||
"type": "native",
|
||||
"native": {"query": card_def["sql"]},
|
||||
},
|
||||
display=card_def["display"],
|
||||
description=f"fn-registry apps: {card_def['name']}",
|
||||
)
|
||||
created_cards.append((card, card_def))
|
||||
print(f" [{i+1}/{len(CARDS)}] {card_def['name']} (id={card['id']})")
|
||||
|
||||
# Crear dashboard
|
||||
print("Creando dashboard...")
|
||||
dashboard = metabase_create_dashboard(
|
||||
client,
|
||||
name="fn-registry Apps",
|
||||
description="Dashboard de apps del registry: distribucion por lenguaje, dominio, dependencias y catalogo completo.",
|
||||
)
|
||||
dash_id = dashboard["id"]
|
||||
print(f" Dashboard creado: id={dash_id}")
|
||||
|
||||
# Agregar cards al dashboard con posiciones
|
||||
dashcards = []
|
||||
for idx, (card, card_def) in enumerate(created_cards):
|
||||
dashcards.append({
|
||||
"id": -(idx + 1),
|
||||
"card_id": card["id"],
|
||||
"size_x": card_def["size_x"],
|
||||
"size_y": card_def["size_y"],
|
||||
"col": card_def["col"],
|
||||
"row": card_def["row"],
|
||||
})
|
||||
|
||||
metabase_update_dashboard(client, dash_id, dashcards=dashcards)
|
||||
print(f"\nDashboard listo: {METABASE_URL}/dashboard/{dash_id}")
|
||||
client.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,265 +0,0 @@
|
||||
"""Crea un dashboard en Metabase con metricas del fn-registry."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "python", "functions"))
|
||||
|
||||
from metabase.client import metabase_auth
|
||||
from metabase import (
|
||||
metabase_list_databases,
|
||||
metabase_create_card,
|
||||
metabase_create_dashboard,
|
||||
metabase_update_dashboard,
|
||||
metabase_list_dashboards,
|
||||
)
|
||||
|
||||
# --- Config ---
|
||||
METABASE_URL = "http://localhost:3000"
|
||||
EMAIL = "admin@fnregistry.local"
|
||||
PASSWORD = "FnRegistry2024!"
|
||||
|
||||
# --- Layout ---
|
||||
# Grid de 24 unidades de ancho (estandar Metabase).
|
||||
# Fila 0 (h=5): 5 scalars de 4-5 unidades cada uno → fila de KPIs
|
||||
# Fila 5 (h=8): 3 graficas de 8 unidades → distribucion general
|
||||
# Fila 13 (h=9): 3 graficas de 8 unidades → analisis profundo
|
||||
# Fila 22 (h=8): 2 tablas de 12 unidades → cobertura + x lenguaje
|
||||
# Fila 30 (h=8): 2 tablas de 12 unidades → tipos + recientes
|
||||
|
||||
# --- SQL Queries ---
|
||||
CARDS = [
|
||||
# ---- Fila 0: KPIs escalares (h=5) ----
|
||||
{
|
||||
"name": "Total de Funciones",
|
||||
"display": "scalar",
|
||||
"sql": "SELECT COUNT(*) AS total FROM functions;",
|
||||
"size_x": 5, "size_y": 5, "col": 0, "row": 0,
|
||||
},
|
||||
{
|
||||
"name": "Funciones con Tests",
|
||||
"display": "scalar",
|
||||
"sql": "SELECT COUNT(*) AS con_tests FROM functions WHERE tested = 1;",
|
||||
"size_x": 5, "size_y": 5, "col": 5, "row": 0,
|
||||
},
|
||||
{
|
||||
"name": "Funciones sin Tests",
|
||||
"display": "scalar",
|
||||
"sql": "SELECT COUNT(*) AS sin_tests FROM functions WHERE tested = 0;",
|
||||
"size_x": 4, "size_y": 5, "col": 10, "row": 0,
|
||||
},
|
||||
{
|
||||
"name": "Total de Tipos",
|
||||
"display": "scalar",
|
||||
"sql": "SELECT COUNT(*) AS total FROM types;",
|
||||
"size_x": 5, "size_y": 5, "col": 14, "row": 0,
|
||||
},
|
||||
{
|
||||
"name": "Proposals Pendientes",
|
||||
"display": "scalar",
|
||||
"sql": "SELECT COUNT(*) AS pendientes FROM proposals WHERE status = 'pending';",
|
||||
"size_x": 5, "size_y": 5, "col": 19, "row": 0,
|
||||
},
|
||||
# ---- Fila 5: Distribucion general (h=8) ----
|
||||
{
|
||||
"name": "Funciones por Lenguaje",
|
||||
"display": "bar",
|
||||
"sql": "SELECT lang, COUNT(*) AS cantidad FROM functions GROUP BY lang ORDER BY cantidad DESC;",
|
||||
"size_x": 8, "size_y": 8, "col": 0, "row": 5,
|
||||
},
|
||||
{
|
||||
"name": "Funciones por Dominio",
|
||||
"display": "pie",
|
||||
"sql": "SELECT domain, COUNT(*) AS cantidad FROM functions GROUP BY domain ORDER BY cantidad DESC;",
|
||||
"size_x": 8, "size_y": 8, "col": 8, "row": 5,
|
||||
},
|
||||
{
|
||||
"name": "Funciones por Kind",
|
||||
"display": "bar",
|
||||
"sql": "SELECT kind, COUNT(*) AS cantidad FROM functions GROUP BY kind ORDER BY cantidad DESC;",
|
||||
"size_x": 8, "size_y": 8, "col": 16, "row": 5,
|
||||
},
|
||||
# ---- Fila 13: Analisis profundo (h=9) ----
|
||||
{
|
||||
"name": "Puras vs Impuras",
|
||||
"display": "pie",
|
||||
"sql": "SELECT purity, COUNT(*) AS cantidad FROM functions GROUP BY purity ORDER BY cantidad DESC;",
|
||||
"size_x": 8, "size_y": 9, "col": 0, "row": 13,
|
||||
},
|
||||
{
|
||||
"name": "Funciones Mas Usadas por Otras",
|
||||
"display": "row",
|
||||
"sql": """
|
||||
WITH RECURSIVE split_uses(fn_id, rest, val) AS (
|
||||
SELECT id, uses_functions || ',', NULL FROM functions WHERE uses_functions != '[]' AND uses_functions != ''
|
||||
UNION ALL
|
||||
SELECT fn_id,
|
||||
SUBSTR(rest, INSTR(rest, ',') + 1),
|
||||
TRIM(SUBSTR(rest, 1, INSTR(rest, ',') - 1), ' "[]')
|
||||
FROM split_uses WHERE rest != ''
|
||||
)
|
||||
SELECT val AS funcion_usada, COUNT(*) AS veces_usada
|
||||
FROM split_uses
|
||||
WHERE val IS NOT NULL AND val != '' AND val != ']'
|
||||
GROUP BY val
|
||||
ORDER BY veces_usada DESC
|
||||
LIMIT 15;
|
||||
""",
|
||||
"size_x": 8, "size_y": 9, "col": 8, "row": 13,
|
||||
},
|
||||
{
|
||||
"name": "Funciones Mas Complejas (mas dependencias)",
|
||||
"display": "row",
|
||||
"sql": """
|
||||
SELECT
|
||||
name || ' (' || lang || ')' AS funcion,
|
||||
(LENGTH(uses_functions) - LENGTH(REPLACE(uses_functions, ',', ''))
|
||||
+ CASE WHEN uses_functions != '[]' AND uses_functions != '' THEN 1 ELSE 0 END) AS num_dependencias,
|
||||
(LENGTH(uses_types) - LENGTH(REPLACE(uses_types, ',', ''))
|
||||
+ CASE WHEN uses_types != '[]' AND uses_types != '' THEN 1 ELSE 0 END) AS num_tipos
|
||||
FROM functions
|
||||
WHERE uses_functions != '[]' AND uses_functions != ''
|
||||
ORDER BY num_dependencias DESC
|
||||
LIMIT 15;
|
||||
""",
|
||||
"size_x": 8, "size_y": 9, "col": 16, "row": 13,
|
||||
},
|
||||
# ---- Fila 22: Cobertura + cross-table (h=8) ----
|
||||
{
|
||||
"name": "Cobertura de Tests por Dominio",
|
||||
"display": "bar",
|
||||
"sql": """
|
||||
SELECT
|
||||
domain,
|
||||
SUM(CASE WHEN tested = 1 THEN 1 ELSE 0 END) AS con_tests,
|
||||
SUM(CASE WHEN tested = 0 THEN 1 ELSE 0 END) AS sin_tests
|
||||
FROM functions
|
||||
GROUP BY domain
|
||||
ORDER BY domain;
|
||||
""",
|
||||
"size_x": 12, "size_y": 8, "col": 0, "row": 22,
|
||||
},
|
||||
{
|
||||
"name": "Funciones por Lenguaje y Dominio",
|
||||
"display": "table",
|
||||
"sql": """
|
||||
SELECT
|
||||
domain,
|
||||
SUM(CASE WHEN lang = 'go' THEN 1 ELSE 0 END) AS go,
|
||||
SUM(CASE WHEN lang = 'py' THEN 1 ELSE 0 END) AS python,
|
||||
SUM(CASE WHEN lang = 'bash' THEN 1 ELSE 0 END) AS bash,
|
||||
SUM(CASE WHEN lang = 'ts' THEN 1 ELSE 0 END) AS typescript,
|
||||
COUNT(*) AS total
|
||||
FROM functions
|
||||
GROUP BY domain
|
||||
ORDER BY total DESC;
|
||||
""",
|
||||
"size_x": 12, "size_y": 8, "col": 12, "row": 22,
|
||||
},
|
||||
# ---- Fila 30: Tipos + recientes (h=8) ----
|
||||
{
|
||||
"name": "Tipos por Dominio y Algebraic",
|
||||
"display": "table",
|
||||
"sql": """
|
||||
SELECT
|
||||
domain,
|
||||
algebraic,
|
||||
COUNT(*) AS cantidad
|
||||
FROM types
|
||||
GROUP BY domain, algebraic
|
||||
ORDER BY domain, cantidad DESC;
|
||||
""",
|
||||
"size_x": 12, "size_y": 8, "col": 0, "row": 30,
|
||||
},
|
||||
{
|
||||
"name": "Funciones Recientes (ultimas 20 indexadas)",
|
||||
"display": "table",
|
||||
"sql": """
|
||||
SELECT name, lang, domain, kind, purity, tested
|
||||
FROM functions
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 20;
|
||||
""",
|
||||
"size_x": 12, "size_y": 8, "col": 12, "row": 30,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
print("Autenticando en Metabase...")
|
||||
client = metabase_auth(METABASE_URL, EMAIL, PASSWORD)
|
||||
|
||||
# Encontrar la database registry.db
|
||||
dbs = metabase_list_databases(client)
|
||||
registry_db_id = None
|
||||
for db in dbs:
|
||||
if "registry" in db.get("name", "").lower() or (
|
||||
db.get("engine") == "sqlite"
|
||||
and "registry" in db.get("details", {}).get("db", "")
|
||||
):
|
||||
registry_db_id = db["id"]
|
||||
print(f" Database encontrada: {db['name']} (id={db['id']})")
|
||||
break
|
||||
|
||||
if not registry_db_id:
|
||||
print("ERROR: No se encontro registry.db en Metabase.")
|
||||
print("Databases disponibles:")
|
||||
for db in dbs:
|
||||
print(f" - {db['id']}: {db['name']} ({db['engine']})")
|
||||
sys.exit(1)
|
||||
|
||||
# Verificar si ya existe un dashboard con este nombre
|
||||
existing = metabase_list_dashboards(client)
|
||||
for d in existing:
|
||||
if d.get("name") == "fn-registry Overview":
|
||||
print(f" Dashboard ya existe (id={d['id']}), recreando...")
|
||||
from metabase import metabase_delete_dashboard
|
||||
metabase_delete_dashboard(client, d["id"])
|
||||
|
||||
# Crear cards
|
||||
print("Creando cards...")
|
||||
created_cards = []
|
||||
for i, card_def in enumerate(CARDS):
|
||||
card = metabase_create_card(
|
||||
client,
|
||||
name=card_def["name"],
|
||||
dataset_query={
|
||||
"database": registry_db_id,
|
||||
"type": "native",
|
||||
"native": {"query": card_def["sql"]},
|
||||
},
|
||||
display=card_def["display"],
|
||||
description=f"fn-registry: {card_def['name']}",
|
||||
)
|
||||
created_cards.append((card, card_def))
|
||||
print(f" [{i+1}/{len(CARDS)}] {card_def['name']} (id={card['id']})")
|
||||
|
||||
# Crear dashboard
|
||||
print("Creando dashboard...")
|
||||
dashboard = metabase_create_dashboard(
|
||||
client,
|
||||
name="fn-registry Overview",
|
||||
description="Dashboard de metricas del registry: funciones, tipos, tests, dependencias y complejidad.",
|
||||
)
|
||||
dash_id = dashboard["id"]
|
||||
print(f" Dashboard creado: id={dash_id}")
|
||||
|
||||
# Agregar cards al dashboard con posiciones
|
||||
dashcards = []
|
||||
for idx, (card, card_def) in enumerate(created_cards):
|
||||
dashcards.append({
|
||||
"id": -(idx + 1),
|
||||
"card_id": card["id"],
|
||||
"size_x": card_def["size_x"],
|
||||
"size_y": card_def["size_y"],
|
||||
"col": card_def["col"],
|
||||
"row": card_def["row"],
|
||||
})
|
||||
|
||||
metabase_update_dashboard(client, dash_id, dashcards=dashcards)
|
||||
print(f"\nDashboard listo: {METABASE_URL}/dashboard/{dash_id}")
|
||||
client.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,195 +0,0 @@
|
||||
"""Crea un dashboard en Metabase para monitorear operations de script_navegador."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "python", "functions"))
|
||||
|
||||
from metabase.client import metabase_auth
|
||||
from metabase import (
|
||||
metabase_list_databases,
|
||||
metabase_create_card,
|
||||
metabase_create_dashboard,
|
||||
metabase_update_dashboard,
|
||||
metabase_list_dashboards,
|
||||
)
|
||||
from metabase.databases import metabase_add_database
|
||||
|
||||
# --- Config ---
|
||||
METABASE_URL = "http://localhost:3000"
|
||||
EMAIL = "admin@fnregistry.local"
|
||||
PASSWORD = "FnRegistry2024!"
|
||||
|
||||
# Path de operations.db dentro del contenedor Docker
|
||||
# Copiar con: docker exec metabase mkdir -p /data/ops-script-navegador && docker cp apps/script_navegador/operations.db metabase:/data/ops-script-navegador/operations.db
|
||||
OPS_DB_PATH = "/data/ops-script-navegador/operations.db"
|
||||
DB_NAME = "ops-script-navegador"
|
||||
|
||||
CARDS = [
|
||||
# ---- Fila 0: KPIs (h=5) ----
|
||||
{
|
||||
"name": "Total Ejecuciones",
|
||||
"display": "scalar",
|
||||
"sql": "SELECT COUNT(*) AS total FROM executions;",
|
||||
"size_x": 6, "size_y": 5, "col": 0, "row": 0,
|
||||
},
|
||||
{
|
||||
"name": "Ejecuciones Exitosas",
|
||||
"display": "scalar",
|
||||
"sql": "SELECT COUNT(*) AS exitosas FROM executions WHERE status = 'success';",
|
||||
"size_x": 6, "size_y": 5, "col": 6, "row": 0,
|
||||
},
|
||||
{
|
||||
"name": "Ejecuciones Fallidas",
|
||||
"display": "scalar",
|
||||
"sql": "SELECT COUNT(*) AS fallidas FROM executions WHERE status = 'failure';",
|
||||
"size_x": 6, "size_y": 5, "col": 12, "row": 0,
|
||||
},
|
||||
{
|
||||
"name": "Duracion Promedio (ms)",
|
||||
"display": "scalar",
|
||||
"sql": "SELECT ROUND(AVG(duration_ms)) AS avg_ms FROM executions WHERE status = 'success';",
|
||||
"size_x": 6, "size_y": 5, "col": 18, "row": 0,
|
||||
},
|
||||
# ---- Fila 5: Tendencias (h=8) ----
|
||||
{
|
||||
"name": "Ejecuciones por Estado",
|
||||
"display": "pie",
|
||||
"sql": "SELECT status, COUNT(*) AS cantidad FROM executions GROUP BY status;",
|
||||
"size_x": 8, "size_y": 8, "col": 0, "row": 5,
|
||||
},
|
||||
{
|
||||
"name": "Duracion por Ejecucion (timeline)",
|
||||
"display": "line",
|
||||
"sql": """
|
||||
SELECT
|
||||
started_at,
|
||||
duration_ms,
|
||||
status
|
||||
FROM executions
|
||||
ORDER BY started_at;
|
||||
""",
|
||||
"size_x": 16, "size_y": 8, "col": 8, "row": 5,
|
||||
},
|
||||
# ---- Fila 13: Detalle de pasos (h=9) ----
|
||||
{
|
||||
"name": "Pasos por Script (metricas)",
|
||||
"display": "table",
|
||||
"sql": """
|
||||
SELECT
|
||||
id,
|
||||
status,
|
||||
records_in AS pasos_total,
|
||||
records_out AS pasos_exitosos,
|
||||
duration_ms,
|
||||
CASE WHEN error = '' THEN '-' ELSE error END AS error,
|
||||
json_extract(metrics, '$.script_name') AS script,
|
||||
started_at
|
||||
FROM executions
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 20;
|
||||
""",
|
||||
"size_x": 24, "size_y": 9, "col": 0, "row": 13,
|
||||
},
|
||||
# ---- Fila 22: Logs (h=9) ----
|
||||
{
|
||||
"name": "Logs Recientes",
|
||||
"display": "table",
|
||||
"sql": """
|
||||
SELECT
|
||||
level,
|
||||
source,
|
||||
message,
|
||||
json_extract(metadata, '$.action') AS action,
|
||||
json_extract(metadata, '$.elapsed_ms') AS elapsed_ms,
|
||||
created_at
|
||||
FROM logs
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50;
|
||||
""",
|
||||
"size_x": 24, "size_y": 9, "col": 0, "row": 22,
|
||||
},
|
||||
]
|
||||
|
||||
DASHBOARD_NAME = "script_navegador Operations"
|
||||
|
||||
|
||||
def main():
|
||||
print("Autenticando en Metabase...")
|
||||
client = metabase_auth(METABASE_URL, EMAIL, PASSWORD)
|
||||
|
||||
# Buscar si ya existe la database
|
||||
dbs = metabase_list_databases(client)
|
||||
ops_db_id = None
|
||||
for db in dbs:
|
||||
if db.get("name") == DB_NAME:
|
||||
ops_db_id = db["id"]
|
||||
print(f" Database ya existe: {DB_NAME} (id={ops_db_id})")
|
||||
break
|
||||
|
||||
if not ops_db_id:
|
||||
print(f"Registrando {DB_NAME} como datasource SQLite ({OPS_DB_PATH})...")
|
||||
new_db = metabase_add_database(
|
||||
client=client,
|
||||
name=DB_NAME,
|
||||
engine="sqlite",
|
||||
details={"db": OPS_DB_PATH},
|
||||
)
|
||||
ops_db_id = new_db["id"]
|
||||
print(f" Database registrada: id={ops_db_id}")
|
||||
|
||||
# Eliminar dashboard existente si lo hay
|
||||
existing = metabase_list_dashboards(client)
|
||||
for d in existing:
|
||||
if d.get("name") == DASHBOARD_NAME:
|
||||
print(f" Dashboard ya existe (id={d['id']}), recreando...")
|
||||
from metabase import metabase_delete_dashboard
|
||||
metabase_delete_dashboard(client, d["id"])
|
||||
|
||||
# Crear cards
|
||||
print("Creando cards...")
|
||||
created_cards = []
|
||||
for i, card_def in enumerate(CARDS):
|
||||
card = metabase_create_card(
|
||||
client,
|
||||
name=card_def["name"],
|
||||
dataset_query={
|
||||
"database": ops_db_id,
|
||||
"type": "native",
|
||||
"native": {"query": card_def["sql"]},
|
||||
},
|
||||
display=card_def["display"],
|
||||
description=f"script_navegador: {card_def['name']}",
|
||||
)
|
||||
created_cards.append((card, card_def))
|
||||
print(f" [{i+1}/{len(CARDS)}] {card_def['name']} (id={card['id']})")
|
||||
|
||||
# Crear dashboard
|
||||
print("Creando dashboard...")
|
||||
dashboard = metabase_create_dashboard(
|
||||
client,
|
||||
name=DASHBOARD_NAME,
|
||||
description="Monitoreo de ejecuciones de script_navegador: KPIs, tendencias, detalle de pasos y logs.",
|
||||
)
|
||||
dash_id = dashboard["id"]
|
||||
print(f" Dashboard creado: id={dash_id}")
|
||||
|
||||
# Agregar cards al dashboard
|
||||
dashcards = []
|
||||
for idx, (card, card_def) in enumerate(created_cards):
|
||||
dashcards.append({
|
||||
"id": -(idx + 1),
|
||||
"card_id": card["id"],
|
||||
"size_x": card_def["size_x"],
|
||||
"size_y": card_def["size_y"],
|
||||
"col": card_def["col"],
|
||||
"row": card_def["row"],
|
||||
})
|
||||
|
||||
metabase_update_dashboard(client, dash_id, dashcards=dashcards)
|
||||
print(f"\nDashboard listo: {METABASE_URL}/dashboard/{dash_id}")
|
||||
client.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,411 +0,0 @@
|
||||
"""
|
||||
apps/metabase_registry/main.py
|
||||
==============================
|
||||
|
||||
Setup completo de fn-registry en Metabase: datasource, cards y dashboard.
|
||||
|
||||
USO
|
||||
---
|
||||
Via variables de entorno:
|
||||
|
||||
METABASE_URL=http://localhost:3000 \
|
||||
METABASE_ADMIN_EMAIL=admin@example.com \
|
||||
METABASE_ADMIN_PASSWORD=secret \
|
||||
REGISTRY_DB_PATH=/data/registry/registry.db \
|
||||
python main.py
|
||||
|
||||
Via argumentos CLI:
|
||||
|
||||
python main.py \
|
||||
--url http://localhost:3000 \
|
||||
--admin-email admin@example.com \
|
||||
--admin-password secret \
|
||||
--registry-db-path /registry.db
|
||||
|
||||
Para crear un usuario nuevo (opcional):
|
||||
|
||||
python main.py ... \
|
||||
--new-user-email dev@example.com \
|
||||
--new-user-first-name Dev \
|
||||
--new-user-last-name User \
|
||||
--new-user-password devpass
|
||||
|
||||
NOTA SOBRE registry.db EN DOCKER
|
||||
----------------------------------
|
||||
Metabase corre en Docker y necesita acceder a registry.db. El path que
|
||||
se configura en Metabase (--registry-db-path) debe ser la ruta DENTRO
|
||||
del contenedor. Usa setup_volume.sh para copiar el archivo al contenedor
|
||||
antes de ejecutar este script.
|
||||
|
||||
./setup_volume.sh /home/lucas/fn_registry/registry.db
|
||||
|
||||
Tras copiar, el archivo queda en /registry.db dentro del contenedor,
|
||||
que es el valor por defecto de --registry-db-path.
|
||||
|
||||
DEPENDENCIAS
|
||||
------------
|
||||
Instalar con: pip install -r requirements.txt
|
||||
O con uv: uv pip install -r requirements.txt
|
||||
|
||||
Las funciones metabase_add_database y metabase_list_databases deben
|
||||
existir en python/functions/metabase/databases.py (creadas por el
|
||||
fn-constructor).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
|
||||
# Agregar el directorio de funciones Python al path
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "python", "functions"))
|
||||
|
||||
from metabase import (
|
||||
MetabaseClient,
|
||||
metabase_create_user,
|
||||
metabase_create_card,
|
||||
metabase_create_dashboard,
|
||||
metabase_update_dashboard,
|
||||
)
|
||||
from metabase.client import metabase_auth
|
||||
from metabase.databases import metabase_add_database, metabase_list_databases
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuracion de cards de ejemplo
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CARDS_CONFIG = [
|
||||
{
|
||||
"name": "Funciones por dominio",
|
||||
"description": "Cuenta de funciones agrupadas por dominio del registry",
|
||||
"sql": "SELECT domain, count(*) AS total FROM functions GROUP BY domain ORDER BY total DESC",
|
||||
"display": "bar",
|
||||
},
|
||||
{
|
||||
"name": "Funciones puras vs impuras",
|
||||
"description": "Distribucion de pureza funcional en el registry",
|
||||
"sql": "SELECT purity, count(*) AS total FROM functions GROUP BY purity ORDER BY total DESC",
|
||||
"display": "pie",
|
||||
},
|
||||
{
|
||||
"name": "Funciones por kind",
|
||||
"description": "Distribucion por tipo: function, pipeline, component",
|
||||
"sql": "SELECT kind, count(*) AS total FROM functions GROUP BY kind ORDER BY total DESC",
|
||||
"display": "bar",
|
||||
},
|
||||
{
|
||||
"name": "Buscar funciones",
|
||||
"description": "Lista completa de funciones con id, dominio, pureza y descripcion",
|
||||
"sql": (
|
||||
"SELECT id, domain, kind, purity, signature, description "
|
||||
"FROM functions ORDER BY domain, name"
|
||||
),
|
||||
"display": "table",
|
||||
},
|
||||
{
|
||||
"name": "Tipos por dominio",
|
||||
"description": "Cuenta de tipos registrados por dominio",
|
||||
"sql": "SELECT domain, count(*) AS total FROM types GROUP BY domain ORDER BY total DESC",
|
||||
"display": "bar",
|
||||
},
|
||||
{
|
||||
"name": "Proposals pendientes",
|
||||
"description": "Proposals del registry que estan pendientes de revision",
|
||||
"sql": (
|
||||
"SELECT id, kind, title, created_by, created_at "
|
||||
"FROM proposals WHERE status = 'pending' ORDER BY created_at DESC"
|
||||
),
|
||||
"display": "table",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def log(msg: str) -> None:
|
||||
print(f"[metabase_registry] {msg}", flush=True)
|
||||
|
||||
|
||||
def log_ok(msg: str) -> None:
|
||||
print(f"[metabase_registry] OK {msg}", flush=True)
|
||||
|
||||
|
||||
def log_err(msg: str) -> None:
|
||||
print(f"[metabase_registry] ERR {msg}", file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def find_existing_database(client: MetabaseClient, name: str) -> dict | None:
|
||||
"""Busca una database por nombre en Metabase. Retorna None si no existe."""
|
||||
try:
|
||||
databases = metabase_list_databases(client)
|
||||
for db in databases:
|
||||
if db.get("name") == name:
|
||||
return db
|
||||
except Exception as e:
|
||||
log(f"No se pudo listar databases: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def build_dataset_query(database_id: int, sql: str) -> dict:
|
||||
"""Construye el dataset_query para una card SQL nativa."""
|
||||
return {
|
||||
"type": "native",
|
||||
"database": database_id,
|
||||
"native": {
|
||||
"query": sql,
|
||||
"template-tags": {},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def create_cards(client: MetabaseClient, database_id: int) -> list[dict]:
|
||||
"""Crea todas las cards de ejemplo. Retorna lista de cards creadas."""
|
||||
created = []
|
||||
for cfg in CARDS_CONFIG:
|
||||
log(f"Creando card: {cfg['name']!r} ...")
|
||||
try:
|
||||
card = metabase_create_card(
|
||||
client=client,
|
||||
name=cfg["name"],
|
||||
dataset_query=build_dataset_query(database_id, cfg["sql"]),
|
||||
display=cfg["display"],
|
||||
description=cfg["description"],
|
||||
)
|
||||
log_ok(f"Card creada: id={card['id']} nombre={card['name']!r}")
|
||||
created.append(card)
|
||||
except Exception as e:
|
||||
log_err(f"No se pudo crear card {cfg['name']!r}: {e}")
|
||||
return created
|
||||
|
||||
|
||||
def create_overview_dashboard(client: MetabaseClient, cards: list[dict]) -> dict | None:
|
||||
"""Crea el dashboard 'fn-registry Overview' con todas las cards."""
|
||||
log("Creando dashboard 'fn-registry Overview' ...")
|
||||
try:
|
||||
dashboard = metabase_create_dashboard(
|
||||
client=client,
|
||||
name="fn-registry Overview",
|
||||
description="Vista general del function registry: funciones, tipos, proposals y metricas.",
|
||||
)
|
||||
log_ok(f"Dashboard creado: id={dashboard['id']}")
|
||||
except Exception as e:
|
||||
log_err(f"No se pudo crear dashboard: {e}")
|
||||
return None
|
||||
|
||||
if not cards:
|
||||
log("Sin cards para agregar al dashboard.")
|
||||
return dashboard
|
||||
|
||||
# Posicionar cards en una grilla de 3 columnas, 24 unidades de ancho total.
|
||||
# Cada card ocupa 8 columnas x 6 filas.
|
||||
cols = 3
|
||||
card_w = 8
|
||||
card_h = 6
|
||||
|
||||
dashcards = []
|
||||
for idx, card in enumerate(cards):
|
||||
col = (idx % cols) * card_w
|
||||
row = (idx // cols) * card_h
|
||||
dashcards.append({
|
||||
"id": -(idx + 1), # ID negativo = nueva dashcard
|
||||
"card_id": card["id"],
|
||||
"size_x": card_w,
|
||||
"size_y": card_h,
|
||||
"col": col,
|
||||
"row": row,
|
||||
"parameter_mappings": [],
|
||||
"visualization_settings": {},
|
||||
})
|
||||
|
||||
try:
|
||||
metabase_update_dashboard(
|
||||
client=client,
|
||||
dashboard_id=dashboard["id"],
|
||||
dashcards=dashcards,
|
||||
)
|
||||
log_ok(f"Dashboard actualizado con {len(dashcards)} cards.")
|
||||
except Exception as e:
|
||||
log_err(f"No se pudo poblar el dashboard con cards: {e}")
|
||||
|
||||
return dashboard
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Flujo principal
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run(args: argparse.Namespace) -> int:
|
||||
"""Ejecuta el setup completo. Retorna 0 en exito, 1 en fallo critico."""
|
||||
|
||||
# 1. Autenticar como admin
|
||||
log(f"Autenticando en {args.url} como {args.admin_email} ...")
|
||||
try:
|
||||
client = metabase_auth(args.url, args.admin_email, args.admin_password)
|
||||
log_ok("Autenticacion exitosa.")
|
||||
except Exception as e:
|
||||
log_err(f"Fallo de autenticacion: {e}")
|
||||
return 1
|
||||
|
||||
with client:
|
||||
# 2. Crear usuario nuevo (opcional)
|
||||
if args.new_user_email:
|
||||
if not args.new_user_first_name or not args.new_user_last_name:
|
||||
log_err("Para crear usuario se requieren --new-user-first-name y --new-user-last-name.")
|
||||
return 1
|
||||
log(f"Creando usuario {args.new_user_email} ...")
|
||||
try:
|
||||
user = metabase_create_user(
|
||||
client=client,
|
||||
first_name=args.new_user_first_name,
|
||||
last_name=args.new_user_last_name,
|
||||
email=args.new_user_email,
|
||||
password=args.new_user_password or "",
|
||||
)
|
||||
log_ok(f"Usuario creado: id={user['id']} email={user['email']}")
|
||||
except Exception as e:
|
||||
log_err(f"No se pudo crear usuario: {e}")
|
||||
# No es critico, continuar
|
||||
|
||||
# 3. Agregar registry.db como datasource SQLite
|
||||
db_name = "fn-registry"
|
||||
log(f"Verificando si ya existe la database {db_name!r} en Metabase ...")
|
||||
existing_db = find_existing_database(client, db_name)
|
||||
|
||||
if existing_db:
|
||||
db_id = existing_db["id"]
|
||||
log_ok(f"Database ya existe: id={db_id} nombre={db_name!r}. Se reutiliza.")
|
||||
else:
|
||||
log(f"Registrando registry.db ({args.registry_db_path}) como datasource SQLite ...")
|
||||
try:
|
||||
new_db = metabase_add_database(
|
||||
client=client,
|
||||
name=db_name,
|
||||
engine="sqlite",
|
||||
details={"db": args.registry_db_path},
|
||||
)
|
||||
db_id = new_db["id"]
|
||||
log_ok(f"Database registrada: id={db_id} path={args.registry_db_path}")
|
||||
except Exception as e:
|
||||
log_err(f"No se pudo registrar la database: {e}")
|
||||
log_err(
|
||||
"Verifica que registry.db este accesible desde el contenedor Docker. "
|
||||
"Usa setup_volume.sh para copiar el archivo."
|
||||
)
|
||||
return 1
|
||||
|
||||
# 4. Crear cards de ejemplo
|
||||
log(f"Creando {len(CARDS_CONFIG)} cards con database_id={db_id} ...")
|
||||
cards = create_cards(client, db_id)
|
||||
log(f"Cards creadas: {len(cards)}/{len(CARDS_CONFIG)}")
|
||||
|
||||
# 5. Crear dashboard
|
||||
dashboard = create_overview_dashboard(client, cards)
|
||||
if dashboard:
|
||||
log_ok(
|
||||
f"Dashboard disponible en: {args.url}/dashboard/{dashboard['id']}"
|
||||
)
|
||||
else:
|
||||
log_err("El dashboard no pudo crearse.")
|
||||
|
||||
summary = {
|
||||
"url": args.url,
|
||||
"database_id": db_id,
|
||||
"cards_created": len(cards),
|
||||
"dashboard_id": dashboard["id"] if dashboard else None,
|
||||
"dashboard_url": f"{args.url}/dashboard/{dashboard['id']}" if dashboard else None,
|
||||
}
|
||||
print("\n--- Resumen ---")
|
||||
print(json.dumps(summary, indent=2))
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI / env vars
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(
|
||||
prog="metabase_registry",
|
||||
description="Setup de fn-registry como datasource en Metabase.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
|
||||
# Conexion Metabase
|
||||
p.add_argument(
|
||||
"--url",
|
||||
default=os.environ.get("METABASE_URL", "http://localhost:3000"),
|
||||
help="URL base de Metabase (env: METABASE_URL). Default: http://localhost:3000",
|
||||
)
|
||||
p.add_argument(
|
||||
"--admin-email",
|
||||
default=os.environ.get("METABASE_ADMIN_EMAIL", "admin@example.com"),
|
||||
dest="admin_email",
|
||||
help="Email del admin (env: METABASE_ADMIN_EMAIL).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--admin-password",
|
||||
default=os.environ.get("METABASE_ADMIN_PASSWORD", ""),
|
||||
dest="admin_password",
|
||||
help="Password del admin (env: METABASE_ADMIN_PASSWORD).",
|
||||
)
|
||||
|
||||
# Registry DB path (ruta dentro del contenedor Docker)
|
||||
p.add_argument(
|
||||
"--registry-db-path",
|
||||
default=os.environ.get("REGISTRY_DB_PATH", "/data/registry/registry.db"),
|
||||
dest="registry_db_path",
|
||||
help=(
|
||||
"Ruta al registry.db DENTRO del contenedor Docker "
|
||||
"(env: REGISTRY_DB_PATH). Default: /registry.db"
|
||||
),
|
||||
)
|
||||
|
||||
# Nuevo usuario (todos opcionales)
|
||||
p.add_argument(
|
||||
"--new-user-email",
|
||||
default=os.environ.get("NEW_USER_EMAIL", ""),
|
||||
dest="new_user_email",
|
||||
help="Email del nuevo usuario a crear (env: NEW_USER_EMAIL). Opcional.",
|
||||
)
|
||||
p.add_argument(
|
||||
"--new-user-first-name",
|
||||
default=os.environ.get("NEW_USER_FIRST_NAME", ""),
|
||||
dest="new_user_first_name",
|
||||
help="Nombre del nuevo usuario (env: NEW_USER_FIRST_NAME).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--new-user-last-name",
|
||||
default=os.environ.get("NEW_USER_LAST_NAME", ""),
|
||||
dest="new_user_last_name",
|
||||
help="Apellido del nuevo usuario (env: NEW_USER_LAST_NAME).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--new-user-password",
|
||||
default=os.environ.get("NEW_USER_PASSWORD", ""),
|
||||
dest="new_user_password",
|
||||
help="Password del nuevo usuario (env: NEW_USER_PASSWORD). Opcional.",
|
||||
)
|
||||
|
||||
return p
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.admin_password:
|
||||
parser.error(
|
||||
"Se requiere la password del admin. "
|
||||
"Usa --admin-password o la variable de entorno METABASE_ADMIN_PASSWORD."
|
||||
)
|
||||
|
||||
sys.exit(run(args))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,13 +0,0 @@
|
||||
# Dependencias de apps/metabase_registry
|
||||
#
|
||||
# Instalar con pip:
|
||||
# pip install -r requirements.txt
|
||||
#
|
||||
# O con uv (recomendado, desde la raiz del repo):
|
||||
# cd /home/lucas/fn_registry/python && uv pip install -r ../apps/metabase_registry/requirements.txt
|
||||
#
|
||||
# O directamente desde el directorio python (que ya tiene httpx):
|
||||
# cd /home/lucas/fn_registry/python && uv run python ../apps/metabase_registry/main.py
|
||||
|
||||
# HTTP client — mismo que usa el paquete python/functions/metabase
|
||||
httpx>=0.27.0
|
||||
@@ -1,16 +0,0 @@
|
||||
---
|
||||
name: pipeline_launcher
|
||||
lang: go
|
||||
domain: tools
|
||||
description: "TUI para lanzar y monitorear pipelines del fn-registry con historial de ejecuciones."
|
||||
tags: [pipeline, tui, bubbletea, runner, launcher]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
framework: bubbletea
|
||||
entry_point: "main.go"
|
||||
dir_path: "apps/pipeline_launcher"
|
||||
---
|
||||
|
||||
## Notas
|
||||
|
||||
Aplicacion TUI que lista pipelines con tag `launcher` del registry, permite ejecutarlos y muestra historial de ejecuciones desde operations.db.
|
||||
@@ -1,181 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
ops "fn-registry/fn_operations"
|
||||
"fn-registry/registry"
|
||||
"pipeline-launcher/config"
|
||||
"pipeline-launcher/views"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/lucasdataproyects/devfactory/tui"
|
||||
)
|
||||
|
||||
// View identifies which tab is active.
|
||||
type View int
|
||||
|
||||
const (
|
||||
ViewPipelines View = iota
|
||||
ViewHistory
|
||||
)
|
||||
|
||||
var tabNames = []string{"Pipelines", "History"}
|
||||
|
||||
// Model is the top-level TUI model with two tabs.
|
||||
type Model struct {
|
||||
tui.BaseModel
|
||||
activeTab int
|
||||
pipelines views.PipelinesModel
|
||||
history views.HistoryModel
|
||||
ready bool
|
||||
registryDB *registry.DB
|
||||
opsDB *ops.DB
|
||||
}
|
||||
|
||||
// New creates the Model, opening both databases.
|
||||
func New(cfg config.Config) (Model, error) {
|
||||
regDB, err := registry.Open(cfg.RegistryDB)
|
||||
if err != nil {
|
||||
return Model{}, fmt.Errorf("opening registry: %w", err)
|
||||
}
|
||||
|
||||
opsDB, err := ops.Open(cfg.OperationsDB)
|
||||
if err != nil {
|
||||
regDB.Close()
|
||||
return Model{}, fmt.Errorf("opening operations: %w", err)
|
||||
}
|
||||
|
||||
// Build pipeline name map for history view
|
||||
fns, _ := regDB.SearchFunctions("", registry.KindPipeline, "", "", "")
|
||||
names := make(map[string]string, len(fns))
|
||||
for _, f := range fns {
|
||||
names[f.ID] = f.Name
|
||||
}
|
||||
|
||||
styles := tui.DarkStyles()
|
||||
|
||||
return Model{
|
||||
BaseModel: tui.NewBaseModel().WithStyles(styles),
|
||||
pipelines: views.NewPipelinesModel(styles, regDB, opsDB, cfg.RegistryRoot),
|
||||
history: views.NewHistoryModel(styles, opsDB, names),
|
||||
registryDB: regDB,
|
||||
opsDB: opsDB,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close closes both database connections.
|
||||
func (m Model) Close() {
|
||||
if m.registryDB != nil {
|
||||
m.registryDB.Close()
|
||||
}
|
||||
if m.opsDB != nil {
|
||||
m.opsDB.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return m.pipelines.Init()
|
||||
}
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case views.KeyQuit:
|
||||
return m, tea.Quit
|
||||
case "q":
|
||||
updated, atBase := m.handleBack()
|
||||
if atBase {
|
||||
return updated, tea.Quit
|
||||
}
|
||||
return updated, nil
|
||||
case views.KeyEsc, views.KeyBack:
|
||||
updated, atBase := m.handleBack()
|
||||
if atBase {
|
||||
return updated, nil
|
||||
}
|
||||
return updated, nil
|
||||
case views.KeyTab:
|
||||
m.activeTab = (m.activeTab + 1) % len(tabNames)
|
||||
return m, m.initActiveView()
|
||||
case "shift+tab":
|
||||
m.activeTab = (m.activeTab - 1 + len(tabNames)) % len(tabNames)
|
||||
return m, m.initActiveView()
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
m.HandleWindowSize(msg)
|
||||
m.ready = true
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
switch View(m.activeTab) {
|
||||
case ViewPipelines:
|
||||
m.pipelines, cmd = m.pipelines.Update(msg)
|
||||
case ViewHistory:
|
||||
m.history, cmd = m.history.Update(msg)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m Model) View() string {
|
||||
if !m.ready {
|
||||
return "Loading..."
|
||||
}
|
||||
|
||||
tabs := m.renderTabs()
|
||||
|
||||
var content string
|
||||
switch View(m.activeTab) {
|
||||
case ViewPipelines:
|
||||
content = m.pipelines.View()
|
||||
case ViewHistory:
|
||||
content = m.history.View()
|
||||
}
|
||||
|
||||
status := m.Styles.StatusBar.Render(" Tab: switch view │ Ctrl+C: quit │ Enter: action │ r: refresh")
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left,
|
||||
tabs,
|
||||
"",
|
||||
content,
|
||||
"",
|
||||
status,
|
||||
)
|
||||
}
|
||||
|
||||
func (m Model) renderTabs() string {
|
||||
var tabs []string
|
||||
for i, name := range tabNames {
|
||||
if i == m.activeTab {
|
||||
tabs = append(tabs, m.Styles.Selected.Render(" "+name+" "))
|
||||
} else {
|
||||
tabs = append(tabs, m.Styles.Muted.Render(" "+name+" "))
|
||||
}
|
||||
}
|
||||
row := lipgloss.JoinHorizontal(lipgloss.Top, tabs...)
|
||||
return m.Styles.Header.Render("Pipeline Launcher") + " " + row
|
||||
}
|
||||
|
||||
func (m Model) handleBack() (Model, bool) {
|
||||
switch View(m.activeTab) {
|
||||
case ViewPipelines:
|
||||
atBase := m.pipelines.HandleBack()
|
||||
return m, atBase
|
||||
case ViewHistory:
|
||||
atBase := m.history.HandleBack()
|
||||
return m, atBase
|
||||
}
|
||||
return m, true
|
||||
}
|
||||
|
||||
func (m Model) initActiveView() tea.Cmd {
|
||||
switch View(m.activeTab) {
|
||||
case ViewPipelines:
|
||||
return m.pipelines.Init()
|
||||
case ViewHistory:
|
||||
return m.history.Init()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Config holds paths to databases.
|
||||
type Config struct {
|
||||
RegistryDB string // Path to registry.db
|
||||
OperationsDB string // Path to operations.db
|
||||
RegistryRoot string // Root directory of the registry (for resolving file paths)
|
||||
}
|
||||
|
||||
// Default returns a Config resolved from environment or sensible defaults.
|
||||
func Default() Config {
|
||||
root := os.Getenv("FN_REGISTRY_ROOT")
|
||||
if root == "" {
|
||||
root = "."
|
||||
}
|
||||
|
||||
return Config{
|
||||
RegistryDB: filepath.Join(root, "registry.db"),
|
||||
OperationsDB: filepath.Join(root, "apps", "pipeline_launcher", "operations.db"),
|
||||
RegistryRoot: root,
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
module pipeline-launcher
|
||||
|
||||
go 1.22.2
|
||||
|
||||
require (
|
||||
fn-registry v0.0.0
|
||||
github.com/charmbracelet/bubbles v0.18.0
|
||||
github.com/charmbracelet/bubbletea v0.25.0
|
||||
github.com/charmbracelet/lipgloss v0.9.1
|
||||
github.com/lucasdataproyects/devfactory v0.0.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.37 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/reflow v0.3.0 // indirect
|
||||
github.com/muesli/termenv v0.15.2 // indirect
|
||||
github.com/rivo/uniseg v0.4.6 // indirect
|
||||
golang.org/x/sync v0.4.0 // indirect
|
||||
golang.org/x/sys v0.13.0 // indirect
|
||||
golang.org/x/term v0.6.0 // indirect
|
||||
golang.org/x/text v0.13.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
replace (
|
||||
fn-registry => /home/lucas/fn_registry
|
||||
github.com/lucasdataproyects/devfactory => /home/lucas/.local_agentes/backend
|
||||
)
|
||||
@@ -1,51 +0,0 @@
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0=
|
||||
github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw=
|
||||
github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM=
|
||||
github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg=
|
||||
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
||||
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
||||
github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg=
|
||||
github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I=
|
||||
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
|
||||
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
|
||||
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
|
||||
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
||||
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
||||
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
|
||||
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.6 h1:Sovz9sDSwbOz9tgUy8JpT+KgCkPYJEN/oYzlJiYTNLg=
|
||||
github.com/rivo/uniseg v0.4.6/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ=
|
||||
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw=
|
||||
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
||||
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -1,29 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"pipeline-launcher/app"
|
||||
"pipeline-launcher/config"
|
||||
|
||||
"github.com/lucasdataproyects/devfactory/tui"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := config.Default()
|
||||
|
||||
model, err := app.New(cfg)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer model.Close()
|
||||
|
||||
result := tui.RunFullscreen(model)
|
||||
|
||||
if result.IsErr() {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", result.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
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":
|
||||
// Delegate enter to list first so it selects the cursor item
|
||||
updated, _ := m.list.Update(msg)
|
||||
m.list = updated.(tui.FilteredListModel)
|
||||
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()
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package views
|
||||
|
||||
// Navigation key constants.
|
||||
const (
|
||||
KeyQuit = "ctrl+c"
|
||||
KeyEsc = "esc"
|
||||
KeyBack = "0"
|
||||
KeyTab = "tab"
|
||||
)
|
||||
|
||||
// IsBack returns true if the key should trigger back navigation.
|
||||
func IsBack(key string) bool {
|
||||
return key == KeyEsc || key == KeyBack
|
||||
}
|
||||
@@ -1,398 +0,0 @@
|
||||
package views
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
ops "fn-registry/fn_operations"
|
||||
"fn-registry/registry"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/lucasdataproyects/devfactory/tui"
|
||||
)
|
||||
|
||||
type pipelinesState int
|
||||
|
||||
const (
|
||||
pipelinesLoading pipelinesState = iota
|
||||
pipelinesList
|
||||
pipelinesArgs
|
||||
pipelinesRunning
|
||||
pipelinesOutput
|
||||
)
|
||||
|
||||
type pipelinesLoadedMsg []registry.Function
|
||||
type pipelineFinishedMsg RunResult
|
||||
type pipelineFlagsMsg []PipelineFlag
|
||||
|
||||
// PipelinesModel lists and launches pipelines.
|
||||
type PipelinesModel struct {
|
||||
state pipelinesState
|
||||
list tui.FilteredListModel
|
||||
spinner tui.SpinnerModel
|
||||
styles tui.Styles
|
||||
pipelines []registry.Function
|
||||
selectedFn *registry.Function
|
||||
flags []PipelineFlag
|
||||
inputs []textinput.Model
|
||||
focusIdx int
|
||||
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)
|
||||
}
|
||||
// Only show pipelines tagged with "launcher"
|
||||
var launchable []registry.Function
|
||||
for _, f := range fns {
|
||||
for _, t := range f.Tags {
|
||||
if t == "launcher" {
|
||||
launchable = append(launchable, f)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return pipelinesLoadedMsg(launchable)
|
||||
}
|
||||
}
|
||||
|
||||
// buildInputs creates a textinput for each flag, pre-filled with defaults.
|
||||
func (m *PipelinesModel) buildInputs() tea.Cmd {
|
||||
m.inputs = make([]textinput.Model, len(m.flags))
|
||||
for i, f := range m.flags {
|
||||
ti := textinput.New()
|
||||
ti.CharLimit = 256
|
||||
ti.Width = 40
|
||||
if f.Default != "" {
|
||||
ti.SetValue(f.Default)
|
||||
}
|
||||
if f.Required {
|
||||
ti.Placeholder = "(requerido)"
|
||||
}
|
||||
m.inputs[i] = ti
|
||||
}
|
||||
m.focusIdx = 0
|
||||
if len(m.inputs) > 0 {
|
||||
m.inputs[0].Focus()
|
||||
return textinput.Blink
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *PipelinesModel) focusInput(idx int) tea.Cmd {
|
||||
if idx < 0 || idx >= len(m.inputs) {
|
||||
return nil
|
||||
}
|
||||
for i := range m.inputs {
|
||||
m.inputs[i].Blur()
|
||||
}
|
||||
m.focusIdx = idx
|
||||
m.inputs[idx].Focus()
|
||||
return textinput.Blink
|
||||
}
|
||||
|
||||
// collectArgs builds CLI args from the form inputs.
|
||||
func (m PipelinesModel) collectArgs() []string {
|
||||
var args []string
|
||||
for i, f := range m.flags {
|
||||
val := strings.TrimSpace(m.inputs[i].Value())
|
||||
if val != "" {
|
||||
args = append(args, "--"+f.Name, val)
|
||||
}
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
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 pipelineFlagsMsg:
|
||||
m.flags = []PipelineFlag(msg)
|
||||
cmd := m.buildInputs()
|
||||
return m, cmd
|
||||
|
||||
case pipelineFinishedMsg:
|
||||
result := RunResult(msg)
|
||||
m.lastResult = &result
|
||||
var sb strings.Builder
|
||||
if result.Status == ops.ExecSuccess {
|
||||
sb.WriteString("[OK] ")
|
||||
} else {
|
||||
sb.WriteString("[FAIL] ")
|
||||
}
|
||||
fmt.Fprintf(&sb, "Pipeline: %s\n", result.PipelineID)
|
||||
fmt.Fprintf(&sb, "Execution: %s\n", result.ExecID)
|
||||
fmt.Fprintf(&sb, "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 {
|
||||
fmt.Fprintf(&sb, "\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":
|
||||
updated, _ := m.list.Update(msg)
|
||||
m.list = updated.(tui.FilteredListModel)
|
||||
if item := m.list.SelectedItem(); item != nil {
|
||||
fn := item.Value.(registry.Function)
|
||||
m.selectedFn = &fn
|
||||
m.flags = nil
|
||||
m.inputs = nil
|
||||
m.state = pipelinesArgs
|
||||
root := m.registryRoot
|
||||
fnCopy := fn
|
||||
return m, func() tea.Msg {
|
||||
return pipelineFlagsMsg(GetPipelineFlags(&fnCopy, root))
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
case pipelinesArgs:
|
||||
switch msg.String() {
|
||||
case "tab", "down":
|
||||
cmd := m.focusInput((m.focusIdx + 1) % max(len(m.inputs), 1))
|
||||
return m, cmd
|
||||
case "shift+tab", "up":
|
||||
idx := m.focusIdx - 1
|
||||
if idx < 0 {
|
||||
idx = max(len(m.inputs)-1, 0)
|
||||
}
|
||||
cmd := m.focusInput(idx)
|
||||
return m, cmd
|
||||
case "ctrl+enter", "ctrl+s":
|
||||
args := m.collectArgs()
|
||||
m.state = pipelinesRunning
|
||||
m.spinner = tui.NewSpinner(fmt.Sprintf("Running %s...", m.selectedFn.Name))
|
||||
return m, tea.Batch(m.spinner.Init(), m.runPipelineCmd(m.selectedFn, args))
|
||||
case "esc":
|
||||
m.state = pipelinesList
|
||||
return m, nil
|
||||
}
|
||||
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)
|
||||
case pipelinesArgs:
|
||||
if m.focusIdx >= 0 && m.focusIdx < len(m.inputs) {
|
||||
m.inputs[m.focusIdx], cmd = m.inputs[m.focusIdx].Update(msg)
|
||||
}
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m PipelinesModel) runPipelineCmd(fn *registry.Function, args []string) tea.Cmd {
|
||||
regRoot := m.registryRoot
|
||||
opsDB := m.opsDB
|
||||
fnCopy := *fn
|
||||
return func() tea.Msg {
|
||||
result := RunPipeline(&fnCopy, regRoot, opsDB, args)
|
||||
return pipelineFinishedMsg(result)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleBack retrocede un nivel. Retorna true si ya en estado base.
|
||||
func (m *PipelinesModel) HandleBack() bool {
|
||||
switch m.state {
|
||||
case pipelinesArgs:
|
||||
m.state = pipelinesList
|
||||
return false
|
||||
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 pipelinesArgs:
|
||||
return m.renderArgsForm()
|
||||
case pipelinesRunning:
|
||||
return m.spinner.View()
|
||||
case pipelinesOutput:
|
||||
return m.renderOutput()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m PipelinesModel) renderArgsForm() string {
|
||||
header := m.styles.Header.Render(m.selectedFn.Name)
|
||||
|
||||
var parts []string
|
||||
parts = append(parts, header, "")
|
||||
|
||||
if len(m.flags) == 0 {
|
||||
parts = append(parts, m.styles.Muted.Render(" Loading flags..."))
|
||||
} else if len(m.inputs) == 0 {
|
||||
parts = append(parts, m.styles.Muted.Render(" No flags available. Ctrl+S to run."))
|
||||
} else {
|
||||
for i, f := range m.flags {
|
||||
marker := " "
|
||||
if f.Required {
|
||||
marker = m.styles.Error.Render("* ")
|
||||
}
|
||||
|
||||
name := fmt.Sprintf("--%-16s", f.Name)
|
||||
cursor := " "
|
||||
if i == m.focusIdx {
|
||||
cursor = m.styles.Info.Render("> ")
|
||||
}
|
||||
|
||||
label := fmt.Sprintf("%s%s%s", cursor, marker, m.styles.Label.Render(name))
|
||||
input := m.inputs[i].View()
|
||||
|
||||
desc := f.Desc
|
||||
if f.Default != "" {
|
||||
desc += m.styles.Muted.Render(fmt.Sprintf(" (default: %s)", f.Default))
|
||||
}
|
||||
|
||||
parts = append(parts, label+input)
|
||||
parts = append(parts, " "+m.styles.Muted.Render(desc))
|
||||
}
|
||||
}
|
||||
|
||||
parts = append(parts, "")
|
||||
parts = append(parts, m.styles.Muted.Render(" ↑/↓: navigate │ Ctrl+S: run │ Esc: cancel"))
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, parts...)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
package views
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
ops "fn-registry/fn_operations"
|
||||
"fn-registry/registry"
|
||||
)
|
||||
|
||||
// PipelineFlag describes a CLI flag parsed from -help output.
|
||||
type PipelineFlag struct {
|
||||
Name string // e.g. "project"
|
||||
Type string // e.g. "string"
|
||||
Desc string // description text
|
||||
Default string // default value, empty if none
|
||||
Required bool // true if no default
|
||||
}
|
||||
|
||||
var flagLineRe = regexp.MustCompile(`^\s+-(\S+)\s+(\S+)$`)
|
||||
var defaultRe = regexp.MustCompile(`\(default "(.*)"\)`)
|
||||
|
||||
// GetPipelineFlags runs `go run . -help` and parses the flag output.
|
||||
func GetPipelineFlags(fn *registry.Function, registryRoot string) []PipelineFlag {
|
||||
absPath := filepath.Join(registryRoot, fn.FilePath)
|
||||
dir := filepath.Dir(absPath)
|
||||
|
||||
cmd := exec.Command("go", "run", ".", "-help")
|
||||
cmd.Dir = dir
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
cmd.Run() // -help exits with code 2, ignore error
|
||||
|
||||
return parseFlags(stderr.String())
|
||||
}
|
||||
|
||||
func parseFlags(output string) []PipelineFlag {
|
||||
var flags []PipelineFlag
|
||||
lines := strings.Split(output, "\n")
|
||||
|
||||
for i := 0; i < len(lines); i++ {
|
||||
m := flagLineRe.FindStringSubmatch(lines[i])
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
f := PipelineFlag{Name: m[1], Type: m[2]}
|
||||
|
||||
// Next line is the description
|
||||
if i+1 < len(lines) {
|
||||
desc := strings.TrimSpace(lines[i+1])
|
||||
if dm := defaultRe.FindStringSubmatch(desc); dm != nil {
|
||||
f.Default = dm[1]
|
||||
f.Desc = strings.TrimSpace(defaultRe.ReplaceAllString(desc, ""))
|
||||
} else {
|
||||
f.Desc = desc
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
f.Required = f.Default == "" && !strings.Contains(strings.ToLower(f.Desc), "opcional")
|
||||
flags = append(flags, f)
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
// RunResult holds the outcome of a pipeline execution.
|
||||
type RunResult struct {
|
||||
Stdout string
|
||||
Stderr string
|
||||
ExecID string
|
||||
PipelineID string
|
||||
Status ops.ExecutionStatus
|
||||
DurationMs int64
|
||||
Err error
|
||||
}
|
||||
|
||||
// RunPipeline executes a pipeline as a subprocess and records the execution.
|
||||
func RunPipeline(fn *registry.Function, registryRoot string, opsDB *ops.DB, args []string) RunResult {
|
||||
absPath := filepath.Join(registryRoot, fn.FilePath)
|
||||
dir := filepath.Dir(absPath)
|
||||
|
||||
startedAt := time.Now().UTC()
|
||||
|
||||
cmdArgs := append([]string{"run", "."}, args...)
|
||||
cmd := exec.Command("go", cmdArgs...)
|
||||
cmd.Dir = dir
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
endedAt := time.Now().UTC()
|
||||
|
||||
status := ops.ExecSuccess
|
||||
var execErr string
|
||||
if err != nil {
|
||||
status = ops.ExecFailure
|
||||
execErr = err.Error()
|
||||
if stderr.Len() > 0 {
|
||||
execErr = stderr.String()
|
||||
}
|
||||
}
|
||||
|
||||
execID := fmt.Sprintf("exec_%d", time.Now().UnixNano())
|
||||
durationMs := endedAt.Sub(startedAt).Milliseconds()
|
||||
|
||||
execution := &ops.Execution{
|
||||
ID: execID,
|
||||
PipelineID: fn.ID,
|
||||
Status: status,
|
||||
StartedAt: startedAt,
|
||||
EndedAt: &endedAt,
|
||||
DurationMs: &durationMs,
|
||||
Error: execErr,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
insertErr := ops.InsertExecutionSafe(opsDB, execution)
|
||||
if insertErr != nil {
|
||||
return RunResult{
|
||||
Stdout: stdout.String(),
|
||||
Stderr: stderr.String(),
|
||||
ExecID: execID,
|
||||
PipelineID: fn.ID,
|
||||
Status: status,
|
||||
DurationMs: durationMs,
|
||||
Err: fmt.Errorf("pipeline ran but failed to record: %w", insertErr),
|
||||
}
|
||||
}
|
||||
|
||||
return RunResult{
|
||||
Stdout: stdout.String(),
|
||||
Stderr: stderr.String(),
|
||||
ExecID: execID,
|
||||
PipelineID: fn.ID,
|
||||
Status: status,
|
||||
DurationMs: durationMs,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
operations.db
|
||||
operations.db-wal
|
||||
operations.db-shm
|
||||
build/
|
||||
*.exe
|
||||
script_navegador
|
||||
@@ -1,100 +0,0 @@
|
||||
---
|
||||
name: script_navegador
|
||||
lang: go
|
||||
domain: infra
|
||||
description: "Ejecutor de scripts de navegador CDP sobre Chrome. Lee pasos desde YAML y los ejecuta en secuencia registrando cada resultado en operations.db."
|
||||
tags: [cdp, chrome, browser, automation, yaml]
|
||||
uses_functions:
|
||||
- chrome_launch_go_infra
|
||||
- cdp_connect_go_infra
|
||||
- cdp_navigate_go_infra
|
||||
- cdp_click_go_infra
|
||||
- cdp_type_text_go_infra
|
||||
- cdp_wait_element_go_infra
|
||||
- cdp_evaluate_go_infra
|
||||
- cdp_get_html_go_infra
|
||||
- cdp_screenshot_go_infra
|
||||
- cdp_close_go_infra
|
||||
uses_types: []
|
||||
framework: ""
|
||||
entry_point: "main.go"
|
||||
dir_path: "apps/script_navegador"
|
||||
---
|
||||
|
||||
## Descripcion
|
||||
|
||||
CLI Go que lee un archivo YAML con pasos de navegacion CDP y los ejecuta sobre Chrome, registrando cada paso y su resultado en `operations.db`.
|
||||
|
||||
## Uso
|
||||
|
||||
```bash
|
||||
# Conectarse a Chrome ya corriendo en puerto 9222
|
||||
go run . --script examples/busqueda_google.yaml
|
||||
|
||||
# Lanzar Chrome nuevo (headless)
|
||||
go run . --script examples/busqueda_google.yaml --launch --headless
|
||||
|
||||
# Puerto personalizado
|
||||
go run . --script examples/busqueda_google.yaml --port 9333
|
||||
```
|
||||
|
||||
## Formato del script YAML
|
||||
|
||||
```yaml
|
||||
name: "nombre_del_script"
|
||||
steps:
|
||||
- action: navigate
|
||||
url: "https://ejemplo.com"
|
||||
|
||||
- action: wait
|
||||
selector: "#elemento"
|
||||
timeout_ms: 5000 # opcional, default 10000
|
||||
|
||||
- action: click
|
||||
selector: "#boton"
|
||||
continue_on_error: true # opcional, default false
|
||||
|
||||
- action: type
|
||||
selector: "input[name=q]" # hace click primero para enfocar
|
||||
text: "texto a escribir"
|
||||
|
||||
- action: screenshot
|
||||
path: "/tmp/captura.png"
|
||||
full_page: false # opcional, default false
|
||||
|
||||
- action: evaluate
|
||||
expr: "document.title"
|
||||
|
||||
- action: get_html
|
||||
# sin parametros adicionales
|
||||
|
||||
- action: sleep
|
||||
ms: 500 # pausa en milisegundos
|
||||
```
|
||||
|
||||
## Acciones soportadas
|
||||
|
||||
| Accion | Parametros obligatorios | Parametros opcionales |
|
||||
|-------------|-------------------------|-------------------------------|
|
||||
| `navigate` | `url` | |
|
||||
| `wait` | `selector` | `timeout_ms` (default 10000) |
|
||||
| `click` | `selector` | `continue_on_error` |
|
||||
| `type` | `selector`, `text` | `continue_on_error` |
|
||||
| `screenshot`| `path` | `full_page`, `continue_on_error` |
|
||||
| `evaluate` | `expr` | `continue_on_error` |
|
||||
| `get_html` | — | `continue_on_error` |
|
||||
| `sleep` | `ms` | |
|
||||
|
||||
## Registro en operations.db
|
||||
|
||||
- **Entity `script_run`**: una por ejecucion del script, con metadata del script y resultado final
|
||||
- **Execution**: una por ejecucion, con `pipeline_id = "script_navegador"`, duration_ms, records_in=pasos totales, records_out=pasos exitosos
|
||||
- **Logs**: un log por cada paso ejecutado con nivel info/error
|
||||
|
||||
## Notas
|
||||
|
||||
- Si Chrome no esta corriendo y no se pasa `--launch`, la conexion falla con error claro
|
||||
- `continue_on_error: true` por paso permite continuar aunque ese paso falle
|
||||
- Flag global `--abort-on-error` (default false) aborta todo el script al primer error
|
||||
- Al terminar (exito o error), siempre se ejecuta `cdp_close` para limpiar recursos
|
||||
- operations.db se inicializa automaticamente si no existe usando `fn ops init`
|
||||
@@ -1,28 +0,0 @@
|
||||
name: "busqueda_google"
|
||||
steps:
|
||||
- action: navigate
|
||||
url: "https://www.google.com"
|
||||
|
||||
- action: wait
|
||||
selector: "textarea[name=q]"
|
||||
timeout_ms: 8000
|
||||
|
||||
- action: type
|
||||
selector: "textarea[name=q]"
|
||||
text: "golang cdp automation"
|
||||
|
||||
- action: screenshot
|
||||
path: "/tmp/busqueda_antes.png"
|
||||
|
||||
- action: evaluate
|
||||
expr: "document.title"
|
||||
|
||||
- action: sleep
|
||||
ms: 500
|
||||
|
||||
- action: evaluate
|
||||
expr: "document.querySelector('textarea[name=q]').value"
|
||||
|
||||
- action: screenshot
|
||||
path: "/tmp/busqueda_despues.png"
|
||||
full_page: false
|
||||
@@ -1,20 +0,0 @@
|
||||
name: "demo_continue_on_error"
|
||||
steps:
|
||||
- action: navigate
|
||||
url: "https://example.com"
|
||||
|
||||
- action: wait
|
||||
selector: "h1"
|
||||
timeout_ms: 5000
|
||||
|
||||
# Este paso fallara porque el selector no existe, pero el script continua
|
||||
- action: click
|
||||
selector: "#boton-que-no-existe"
|
||||
continue_on_error: true
|
||||
|
||||
# Este paso se ejecuta aunque el anterior fallo
|
||||
- action: evaluate
|
||||
expr: "document.title"
|
||||
|
||||
- action: screenshot
|
||||
path: "/tmp/continue_on_error.png"
|
||||
@@ -1,16 +0,0 @@
|
||||
name: "scrape_titulo"
|
||||
steps:
|
||||
- action: navigate
|
||||
url: "https://example.com"
|
||||
|
||||
- action: wait
|
||||
selector: "h1"
|
||||
timeout_ms: 5000
|
||||
|
||||
- action: evaluate
|
||||
expr: "document.querySelector('h1').textContent"
|
||||
|
||||
- action: get_html
|
||||
|
||||
- action: screenshot
|
||||
path: "/tmp/example_com.png"
|
||||
@@ -1,6 +0,0 @@
|
||||
name: "navegar_youtube"
|
||||
steps:
|
||||
- action: navigate
|
||||
url: "https://www.youtube.com"
|
||||
- action: screenshot
|
||||
path: "/tmp/youtube.png"
|
||||
@@ -1,12 +0,0 @@
|
||||
module script-navegador
|
||||
|
||||
go 1.24.2
|
||||
|
||||
require (
|
||||
fn-registry v0.0.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require github.com/mattn/go-sqlite3 v1.14.37 // indirect
|
||||
|
||||
replace fn-registry => /home/lucas/fn_registry
|
||||
@@ -1,6 +0,0 @@
|
||||
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
|
||||
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -1,20 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// generateID genera un UUID v4 simple sin dependencias externas.
|
||||
func generateID() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
// Fallback con timestamp si rand falla (muy improbable)
|
||||
return fmt.Sprintf("fallback-%x", b)
|
||||
}
|
||||
// Ajustar bits para UUID v4
|
||||
b[6] = (b[6] & 0x0f) | 0x40
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
|
||||
b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
||||
"fn-registry/functions/infra"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Flags
|
||||
scriptPath := flag.String("script", "", "Ruta al archivo YAML con el script de navegacion (obligatorio)")
|
||||
port := flag.Int("port", 9222, "Puerto CDP de Chrome")
|
||||
launch := flag.Bool("launch", false, "Lanzar Chrome nuevo en vez de conectarse a uno existente")
|
||||
headless := flag.Bool("headless", false, "Lanzar Chrome en modo headless (requiere --launch)")
|
||||
chromePath := flag.String("chrome-path", "", "Ruta al ejecutable de Chrome (ej: '/mnt/c/Program Files/Google/Chrome/Application/chrome.exe')")
|
||||
userDataDir := flag.String("user-data-dir", "", "Directorio de perfil de Chrome (path WSL, se convierte a Windows automaticamente)")
|
||||
keepOpen := flag.Bool("keep-open", false, "No cerrar Chrome al terminar")
|
||||
abortOnError := flag.Bool("abort-on-error", false, "Abortar el script al primer error en cualquier paso")
|
||||
flag.Parse()
|
||||
|
||||
if *scriptPath == "" {
|
||||
fmt.Fprintln(os.Stderr, "error: --script es obligatorio")
|
||||
flag.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := run(*scriptPath, *port, *launch, *headless, *abortOnError, *userDataDir, *keepOpen, *chromePath); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(scriptPath string, port int, launch, headless, abortOnError bool, userDataDir string, keepOpen bool, chromePath string) error {
|
||||
// 1. Cargar y validar el script YAML
|
||||
script, err := LoadScript(scriptPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cargar script: %w", err)
|
||||
}
|
||||
fmt.Printf("[script_navegador] script: %q (%d pasos)\n", script.Name, len(script.Steps))
|
||||
|
||||
// 2. Inicializar operations.db
|
||||
appDir, err := filepath.Abs(filepath.Dir(os.Args[0]))
|
||||
if err != nil {
|
||||
// Fallback al directorio de trabajo
|
||||
appDir, _ = os.Getwd()
|
||||
}
|
||||
// Si estamos corriendo con `go run .`, os.Args[0] es un tmp, usar cwd
|
||||
if cwd, e := os.Getwd(); e == nil {
|
||||
if _, e2 := os.Stat(filepath.Join(cwd, "app.md")); e2 == nil {
|
||||
appDir = cwd
|
||||
}
|
||||
}
|
||||
|
||||
db, err := initOpsDB(appDir)
|
||||
if err != nil {
|
||||
// No es fatal: seguir sin operations.db, solo logear
|
||||
fmt.Fprintf(os.Stderr, "[ops] aviso: no se pudo inicializar operations.db: %v\n", err)
|
||||
}
|
||||
if db != nil {
|
||||
defer db.Close()
|
||||
}
|
||||
|
||||
// 3. Lanzar Chrome o conectarse al existente
|
||||
var pid int
|
||||
if launch {
|
||||
// Convertir path WSL a Windows para chrome.exe
|
||||
// Si empieza con / es un path Linux (WSL), convertir. Si empieza con letra:\ ya es Windows.
|
||||
winDataDir := userDataDir
|
||||
if winDataDir != "" && strings.HasPrefix(winDataDir, "/") {
|
||||
out, err := exec.Command("wslpath", "-w", winDataDir).Output()
|
||||
if err == nil {
|
||||
winDataDir = strings.TrimSpace(string(out))
|
||||
}
|
||||
}
|
||||
fmt.Printf("[chrome] lanzando Chrome en puerto %d (headless=%v, user-data-dir=%q)...\n", port, headless, winDataDir)
|
||||
pid, err = infra.ChromeLaunch(infra.ChromeLaunchOpts{
|
||||
Port: port,
|
||||
Headless: headless,
|
||||
UserDataDir: winDataDir,
|
||||
ChromePath: chromePath,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("lanzar Chrome: %w", err)
|
||||
}
|
||||
fmt.Printf("[chrome] Chrome lanzado (pid=%d)\n", pid)
|
||||
} else {
|
||||
fmt.Printf("[chrome] conectando a Chrome en localhost:%d...\n", port)
|
||||
}
|
||||
|
||||
// 4. Conectar CDP (con mirrored networking, localhost es compartido WSL<->Windows)
|
||||
fmt.Printf("[cdp] conectando a localhost:%d...\n", port)
|
||||
conn, err := infra.CdpConnect(port)
|
||||
if err != nil {
|
||||
// Si lanzamos Chrome, matar el proceso antes de salir
|
||||
if pid > 0 {
|
||||
_ = infra.CdpClose(nil, pid)
|
||||
}
|
||||
return fmt.Errorf("conectar CDP en localhost:%d: %w", port, err)
|
||||
}
|
||||
fmt.Printf("[cdp] conexion establecida\n")
|
||||
|
||||
// Asegurar cierre al salir (respetar --keep-open)
|
||||
defer func() {
|
||||
if keepOpen {
|
||||
fmt.Printf("[cdp] cerrando conexion CDP (Chrome sigue abierto, pid=%d, puerto=%d)\n", pid, port)
|
||||
// Solo cerrar la conexion WebSocket, no matar Chrome
|
||||
if err := infra.CdpClose(conn, 0); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[cdp] aviso al cerrar conexion: %v\n", err)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("[cdp] cerrando conexion y limpiando recursos...\n")
|
||||
if err := infra.CdpClose(conn, pid); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[cdp] aviso al cerrar: %v\n", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// 5. Registrar entities y relations en operations.db
|
||||
var relationID string
|
||||
if db != nil {
|
||||
_, _, _, err := EnsureEntities(db, port, chromePath, userDataDir, script.Name, scriptPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[ops] aviso: no se pudieron crear entities: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("[ops] entities registradas\n")
|
||||
}
|
||||
relationID, err = EnsureRelations(db, "chrome_instance", "cdp_session", fmt.Sprintf("script_%s", script.Name))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[ops] aviso: no se pudieron crear relations: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("[ops] relations registradas\n")
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Ejecutar el script
|
||||
runner := NewRunner(conn, RunnerOpts{AbortOnError: abortOnError})
|
||||
|
||||
startedAt := time.Now()
|
||||
fmt.Printf("[run] iniciando ejecucion: %s\n", startedAt.Format(time.RFC3339))
|
||||
|
||||
results, runErr := runner.Run(script)
|
||||
endedAt := time.Now()
|
||||
|
||||
// 7. Imprimir resumen de pasos
|
||||
printSummary(script, results, runErr, startedAt, endedAt)
|
||||
|
||||
// 8. Registrar execution y actualizar relation en operations.db
|
||||
if db != nil {
|
||||
execID, err := RecordRun(db, script, relationID, results, runErr, startedAt, endedAt)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[ops] aviso: no se pudo registrar ejecucion: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("[ops] ejecucion registrada en operations.db (id=%s)\n", execID[:8])
|
||||
}
|
||||
// Registrar cada paso como log
|
||||
for _, r := range results {
|
||||
if logErr := LogStep(db, execID, r); logErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "[ops] aviso: no se pudo registrar log step[%d]: %v\n", r.Index, logErr)
|
||||
}
|
||||
}
|
||||
// Actualizar relation status
|
||||
if relationID != "" {
|
||||
UpdateRelationAfterRun(db, relationID, runErr)
|
||||
}
|
||||
}
|
||||
|
||||
if runErr != nil {
|
||||
return runErr
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// printSummary imprime un resumen legible de la ejecucion.
|
||||
func printSummary(script *Script, results []StepResult, runErr error, startedAt, endedAt time.Time) {
|
||||
duration := endedAt.Sub(startedAt)
|
||||
success := 0
|
||||
for _, r := range results {
|
||||
if r.Err == nil {
|
||||
success++
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\n--- Resumen: %q ---\n", script.Name)
|
||||
fmt.Printf("Duracion: %v\n", duration.Round(time.Millisecond))
|
||||
fmt.Printf("Pasos: %d/%d exitosos\n", success, len(results))
|
||||
fmt.Println()
|
||||
|
||||
for _, r := range results {
|
||||
status := "ok"
|
||||
detail := ""
|
||||
if r.Err != nil {
|
||||
status = "ERROR"
|
||||
detail = fmt.Sprintf(" -> %v", r.Err)
|
||||
} else if r.Output != "" {
|
||||
detail = fmt.Sprintf(" -> %q", r.Output)
|
||||
}
|
||||
fmt.Printf(" [%d] %-12s %s (%dms)%s\n",
|
||||
r.Index, r.Action, status, r.Elapsed.Milliseconds(), detail)
|
||||
}
|
||||
|
||||
if runErr != nil {
|
||||
fmt.Printf("\nAbortado: %v\n", runErr)
|
||||
} else {
|
||||
fmt.Printf("\nScript completado.\n")
|
||||
}
|
||||
}
|
||||
@@ -1,333 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
fn_operations "fn-registry/fn_operations"
|
||||
)
|
||||
|
||||
const opsDBName = "operations.db"
|
||||
|
||||
// initOpsDB inicializa o abre operations.db en el directorio de la app.
|
||||
func initOpsDB(appDir string) (*fn_operations.DB, error) {
|
||||
dbPath := filepath.Join(appDir, opsDBName)
|
||||
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
if err := bootstrapOpsDB(appDir, dbPath); err != nil {
|
||||
return nil, fmt.Errorf("inicializar operations.db: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
db, err := fn_operations.Open(dbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("abrir operations.db: %w", err)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// bootstrapOpsDB intenta crear operations.db usando el CLI fn o directamente.
|
||||
func bootstrapOpsDB(appDir, dbPath string) error {
|
||||
registryRoot := os.Getenv("FN_REGISTRY_ROOT")
|
||||
if registryRoot == "" {
|
||||
registryRoot = filepath.Join(appDir, "..", "..")
|
||||
}
|
||||
|
||||
fnBin := filepath.Join(registryRoot, "fn")
|
||||
if _, err := os.Stat(fnBin); err == nil {
|
||||
cmd := exec.Command(fnBin, "ops", "init", appDir)
|
||||
cmd.Env = append(os.Environ(), "FN_REGISTRY_ROOT="+registryRoot)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("fn ops init: %w\n%s", err, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
db, err := fn_operations.Open(dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("crear operations.db directamente: %w", err)
|
||||
}
|
||||
return db.Close()
|
||||
}
|
||||
|
||||
// --- Entities ---
|
||||
|
||||
// EnsureEntities crea o actualiza las entities del pipeline de navegacion.
|
||||
// Entities:
|
||||
// - chrome_instance: la instancia de Chrome con CDP
|
||||
// - cdp_session: la sesion CDP activa
|
||||
// - script_file: el archivo YAML del script
|
||||
func EnsureEntities(db *fn_operations.DB, port int, chromePath, userDataDir, scriptName, scriptPath string) (chromeID, cdpID, scriptID string, err error) {
|
||||
now := time.Now()
|
||||
|
||||
chromeID = "chrome_instance"
|
||||
cdpID = "cdp_session"
|
||||
scriptID = fmt.Sprintf("script_%s", scriptName)
|
||||
|
||||
// Chrome instance
|
||||
existing, _ := db.GetEntity(chromeID)
|
||||
if existing == nil {
|
||||
err = db.InsertEntity(&fn_operations.Entity{
|
||||
ID: chromeID,
|
||||
Name: "Chrome Windows",
|
||||
TypeRef: "chrome_instance",
|
||||
Status: fn_operations.StatusActive,
|
||||
Description: "Instancia de Chrome con remote debugging habilitado",
|
||||
Domain: "infra",
|
||||
Tags: []string{"chrome", "cdp", "windows"},
|
||||
Source: "script_navegador",
|
||||
Metadata: map[string]any{
|
||||
"port": port,
|
||||
"chrome_path": chromePath,
|
||||
"user_data_dir": userDataDir,
|
||||
},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("insertar entity chrome_instance: %w", err)
|
||||
}
|
||||
} else if existing.Status != fn_operations.StatusActive {
|
||||
existing.Status = fn_operations.StatusActive
|
||||
existing.UpdatedAt = now
|
||||
db.UpdateEntity(existing)
|
||||
}
|
||||
|
||||
// CDP session
|
||||
existing, _ = db.GetEntity(cdpID)
|
||||
if existing == nil {
|
||||
err = db.InsertEntity(&fn_operations.Entity{
|
||||
ID: cdpID,
|
||||
Name: "CDP Session",
|
||||
TypeRef: "cdp_session",
|
||||
Status: fn_operations.StatusActive,
|
||||
Description: "Sesion CDP WebSocket activa contra Chrome",
|
||||
Domain: "infra",
|
||||
Tags: []string{"cdp", "websocket"},
|
||||
Source: "script_navegador",
|
||||
Metadata: map[string]any{
|
||||
"port": port,
|
||||
"protocol": "CDP 1.3",
|
||||
},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("insertar entity cdp_session: %w", err)
|
||||
}
|
||||
} else if existing.Status != fn_operations.StatusActive {
|
||||
existing.Status = fn_operations.StatusActive
|
||||
existing.UpdatedAt = now
|
||||
db.UpdateEntity(existing)
|
||||
}
|
||||
|
||||
// Script
|
||||
existing, _ = db.GetEntity(scriptID)
|
||||
if existing == nil {
|
||||
err = db.InsertEntity(&fn_operations.Entity{
|
||||
ID: scriptID,
|
||||
Name: scriptName,
|
||||
TypeRef: "nav_script",
|
||||
Status: fn_operations.StatusActive,
|
||||
Description: fmt.Sprintf("Script de navegacion: %s", scriptName),
|
||||
Domain: "automation",
|
||||
Tags: []string{"script", "yaml", "navegacion"},
|
||||
Source: scriptPath,
|
||||
Metadata: map[string]any{
|
||||
"script_name": scriptName,
|
||||
"file_path": scriptPath,
|
||||
},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("insertar entity script: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return chromeID, cdpID, scriptID, nil
|
||||
}
|
||||
|
||||
// --- Relations ---
|
||||
|
||||
// EnsureRelations crea las relaciones entre entities si no existen.
|
||||
// Relations:
|
||||
// - chrome_to_cdp: Chrome -> CDP Session (via chrome_launch + cdp_connect)
|
||||
// - cdp_to_script: CDP Session -> Script (via runner)
|
||||
func EnsureRelations(db *fn_operations.DB, chromeID, cdpID, scriptID string) (string, error) {
|
||||
now := time.Now()
|
||||
|
||||
// chrome -> cdp
|
||||
chromeToCDP := "chrome_to_cdp"
|
||||
existing, _ := db.GetRelation(chromeToCDP)
|
||||
if existing == nil {
|
||||
err := db.InsertRelation(&fn_operations.Relation{
|
||||
ID: chromeToCDP,
|
||||
Name: "chrome_to_cdp",
|
||||
FromEntity: chromeID,
|
||||
ToEntity: cdpID,
|
||||
Via: "cdp_connect_go_infra",
|
||||
Description: "Chrome expone CDP, la app se conecta via WebSocket",
|
||||
Purity: "impure",
|
||||
Direction: fn_operations.DirUnidirectional,
|
||||
Status: fn_operations.RelImplemented,
|
||||
Tags: []string{"cdp", "websocket"},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("insertar relation chrome_to_cdp: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// cdp -> script execution
|
||||
cdpToScript := fmt.Sprintf("cdp_to_%s", scriptID)
|
||||
existing, _ = db.GetRelation(cdpToScript)
|
||||
if existing == nil {
|
||||
startedAt := now
|
||||
err := db.InsertRelation(&fn_operations.Relation{
|
||||
ID: cdpToScript,
|
||||
Name: cdpToScript,
|
||||
FromEntity: cdpID,
|
||||
ToEntity: scriptID,
|
||||
Via: "script_navegador_runner",
|
||||
Description: fmt.Sprintf("CDP ejecuta pasos del script %s", scriptID),
|
||||
Purity: "impure",
|
||||
Direction: fn_operations.DirUnidirectional,
|
||||
Status: fn_operations.RelRunning,
|
||||
StartedAt: &startedAt,
|
||||
Tags: []string{"automation", "pipeline"},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("insertar relation cdp_to_script: %w", err)
|
||||
}
|
||||
} else {
|
||||
existing.Status = fn_operations.RelRunning
|
||||
existing.UpdatedAt = now
|
||||
db.UpdateRelation(existing)
|
||||
}
|
||||
|
||||
return cdpToScript, nil
|
||||
}
|
||||
|
||||
// UpdateRelationAfterRun actualiza el status de la relation segun el resultado.
|
||||
func UpdateRelationAfterRun(db *fn_operations.DB, relationID string, runErr error) {
|
||||
rel, err := db.GetRelation(relationID)
|
||||
if err != nil || rel == nil {
|
||||
return
|
||||
}
|
||||
if runErr != nil {
|
||||
rel.Status = fn_operations.RelImplemented
|
||||
} else {
|
||||
rel.Status = fn_operations.RelTested
|
||||
}
|
||||
now := time.Now()
|
||||
rel.EndedAt = &now
|
||||
rel.UpdatedAt = now
|
||||
db.UpdateRelation(rel)
|
||||
}
|
||||
|
||||
// --- Executions ---
|
||||
|
||||
// RecordRun registra una ejecucion completa del script en operations.db.
|
||||
func RecordRun(db *fn_operations.DB, script *Script, relationID string, results []StepResult, runErr error, startedAt, endedAt time.Time) (string, error) {
|
||||
totalSteps := int64(len(results))
|
||||
successSteps := int64(0)
|
||||
for _, r := range results {
|
||||
if r.Err == nil {
|
||||
successSteps++
|
||||
}
|
||||
}
|
||||
|
||||
status := fn_operations.ExecSuccess
|
||||
errMsg := ""
|
||||
if runErr != nil {
|
||||
status = fn_operations.ExecFailure
|
||||
errMsg = runErr.Error()
|
||||
} else if successSteps < totalSteps {
|
||||
status = fn_operations.ExecPartial
|
||||
}
|
||||
|
||||
durationMs := endedAt.Sub(startedAt).Milliseconds()
|
||||
|
||||
stepSummary := make([]map[string]any, 0, len(results))
|
||||
for _, r := range results {
|
||||
entry := map[string]any{
|
||||
"index": r.Index,
|
||||
"action": r.Action,
|
||||
"elapsed_ms": r.Elapsed.Milliseconds(),
|
||||
"ok": r.Err == nil,
|
||||
}
|
||||
if r.Output != "" {
|
||||
entry["output"] = r.Output
|
||||
}
|
||||
if r.Err != nil {
|
||||
entry["error"] = r.Err.Error()
|
||||
}
|
||||
stepSummary = append(stepSummary, entry)
|
||||
}
|
||||
|
||||
execID := generateID()
|
||||
execution := &fn_operations.Execution{
|
||||
ID: execID,
|
||||
PipelineID: "script_navegador",
|
||||
RelationID: relationID,
|
||||
Status: status,
|
||||
StartedAt: startedAt,
|
||||
EndedAt: &endedAt,
|
||||
DurationMs: &durationMs,
|
||||
RecordsIn: &totalSteps,
|
||||
RecordsOut: &successSteps,
|
||||
Error: errMsg,
|
||||
Metrics: map[string]any{
|
||||
"script_name": script.Name,
|
||||
"total_steps": totalSteps,
|
||||
"success_steps": successSteps,
|
||||
"steps": stepSummary,
|
||||
},
|
||||
}
|
||||
|
||||
if err := db.InsertExecution(execution); err != nil {
|
||||
return "", fmt.Errorf("insertar execution: %w", err)
|
||||
}
|
||||
|
||||
return execID, nil
|
||||
}
|
||||
|
||||
// --- Logs ---
|
||||
|
||||
// LogStep registra un paso individual como log en operations.db.
|
||||
func LogStep(db *fn_operations.DB, execID string, res StepResult) error {
|
||||
level := fn_operations.LogInfo
|
||||
msg := fmt.Sprintf("step[%d] %s: ok", res.Index, res.Action)
|
||||
if res.Err != nil {
|
||||
level = fn_operations.LogError
|
||||
msg = fmt.Sprintf("step[%d] %s: %v", res.Index, res.Action, res.Err)
|
||||
}
|
||||
|
||||
meta := map[string]any{
|
||||
"action": res.Action,
|
||||
"elapsed_ms": res.Elapsed.Milliseconds(),
|
||||
}
|
||||
if res.Output != "" {
|
||||
meta["output"] = res.Output
|
||||
}
|
||||
|
||||
log := &fn_operations.Log{
|
||||
ID: generateID(),
|
||||
Level: level,
|
||||
Source: "script_navegador",
|
||||
ExecutionID: execID,
|
||||
Message: msg,
|
||||
Metadata: meta,
|
||||
}
|
||||
|
||||
return db.InsertLog(log)
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"fn-registry/functions/infra"
|
||||
)
|
||||
|
||||
// StepResult es el resultado de ejecutar un paso.
|
||||
type StepResult struct {
|
||||
Index int
|
||||
Action string
|
||||
Output string // resultado de evaluate/get_html, path de screenshot, etc.
|
||||
Err error
|
||||
Elapsed time.Duration
|
||||
}
|
||||
|
||||
// RunnerOpts configura la ejecucion del runner.
|
||||
type RunnerOpts struct {
|
||||
AbortOnError bool
|
||||
}
|
||||
|
||||
// Runner ejecuta los pasos de un Script sobre una conexion CDP activa.
|
||||
type Runner struct {
|
||||
conn *infra.CDPConn
|
||||
opts RunnerOpts
|
||||
}
|
||||
|
||||
// NewRunner crea un Runner con la conexion CDP dada.
|
||||
func NewRunner(conn *infra.CDPConn, opts RunnerOpts) *Runner {
|
||||
return &Runner{conn: conn, opts: opts}
|
||||
}
|
||||
|
||||
// Run ejecuta todos los pasos del script y retorna los resultados de cada paso.
|
||||
// Siempre retorna todos los resultados procesados hasta el momento, incluso si aborta.
|
||||
func (r *Runner) Run(script *Script) ([]StepResult, error) {
|
||||
results := make([]StepResult, 0, len(script.Steps))
|
||||
|
||||
for i, step := range script.Steps {
|
||||
start := time.Now()
|
||||
output, err := r.runStep(step)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
res := StepResult{
|
||||
Index: i,
|
||||
Action: step.Action,
|
||||
Output: output,
|
||||
Err: err,
|
||||
Elapsed: elapsed,
|
||||
}
|
||||
results = append(results, res)
|
||||
|
||||
if err != nil {
|
||||
if step.ContinueOnError {
|
||||
// Continuar con el siguiente paso aunque este fallo
|
||||
continue
|
||||
}
|
||||
if r.opts.AbortOnError {
|
||||
return results, fmt.Errorf("step[%d] %s: %w", i, step.Action, err)
|
||||
}
|
||||
// Por defecto: abortar si el paso fallo y no tiene continue_on_error
|
||||
return results, fmt.Errorf("step[%d] %s: %w", i, step.Action, err)
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// runStep ejecuta un paso individual y retorna su output y error.
|
||||
func (r *Runner) runStep(step Step) (string, error) {
|
||||
switch step.Action {
|
||||
case "navigate":
|
||||
if err := infra.CdpNavigate(r.conn, step.URL); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Esperar a que la página cargue completamente
|
||||
timeout := time.Duration(step.TimeoutMs) * time.Millisecond
|
||||
if timeout <= 0 {
|
||||
timeout = 15 * time.Second
|
||||
}
|
||||
return "", infra.CdpWaitLoad(r.conn, timeout)
|
||||
|
||||
case "wait_load":
|
||||
timeout := time.Duration(step.TimeoutMs) * time.Millisecond
|
||||
if timeout <= 0 {
|
||||
timeout = 15 * time.Second
|
||||
}
|
||||
return "", infra.CdpWaitLoad(r.conn, timeout)
|
||||
|
||||
case "wait":
|
||||
timeout := time.Duration(step.TimeoutMs) * time.Millisecond
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
return "", infra.CdpWaitElement(r.conn, step.Selector, timeout)
|
||||
|
||||
case "click":
|
||||
return "", infra.CdpClick(r.conn, step.Selector)
|
||||
|
||||
case "type":
|
||||
// Hacer click primero para enfocar el elemento
|
||||
if err := infra.CdpClick(r.conn, step.Selector); err != nil {
|
||||
return "", fmt.Errorf("enfocar elemento para type: %w", err)
|
||||
}
|
||||
return "", infra.CdpTypeText(r.conn, step.Text)
|
||||
|
||||
case "screenshot":
|
||||
opts := infra.CdpScreenshotOpts{
|
||||
FullPage: step.FullPage,
|
||||
Format: "png",
|
||||
}
|
||||
if err := infra.CdpScreenshot(r.conn, step.Path, opts); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return step.Path, nil
|
||||
|
||||
case "evaluate":
|
||||
result, err := infra.CdpEvaluate(r.conn, step.Expr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return result, nil
|
||||
|
||||
case "get_html":
|
||||
html, err := infra.CdpGetHTML(r.conn)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Truncar para el log (el HTML puede ser muy largo)
|
||||
if len(html) > 200 {
|
||||
return html[:200] + "...", nil
|
||||
}
|
||||
return html, nil
|
||||
|
||||
case "sleep":
|
||||
time.Sleep(time.Duration(step.Ms) * time.Millisecond)
|
||||
return fmt.Sprintf("slept %dms", step.Ms), nil
|
||||
|
||||
default:
|
||||
return "", fmt.Errorf("accion desconocida: %q", step.Action)
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Script representa un archivo YAML de pasos de navegacion.
|
||||
type Script struct {
|
||||
Name string `yaml:"name"`
|
||||
Steps []Step `yaml:"steps"`
|
||||
}
|
||||
|
||||
// Step es un paso individual dentro del script.
|
||||
type Step struct {
|
||||
// Comun a todos los pasos
|
||||
Action string `yaml:"action"`
|
||||
ContinueOnError bool `yaml:"continue_on_error"`
|
||||
|
||||
// navigate
|
||||
URL string `yaml:"url"`
|
||||
|
||||
// wait
|
||||
Selector string `yaml:"selector"`
|
||||
TimeoutMs int `yaml:"timeout_ms"`
|
||||
|
||||
// type
|
||||
Text string `yaml:"text"`
|
||||
|
||||
// screenshot
|
||||
Path string `yaml:"path"`
|
||||
FullPage bool `yaml:"full_page"`
|
||||
|
||||
// evaluate
|
||||
Expr string `yaml:"expr"`
|
||||
|
||||
// sleep
|
||||
Ms int `yaml:"ms"`
|
||||
}
|
||||
|
||||
// Validate comprueba que el script tiene los campos minimos correctos.
|
||||
func (s *Script) Validate() error {
|
||||
if s.Name == "" {
|
||||
return fmt.Errorf("script: campo 'name' obligatorio")
|
||||
}
|
||||
if len(s.Steps) == 0 {
|
||||
return fmt.Errorf("script %q: sin pasos definidos", s.Name)
|
||||
}
|
||||
for i, step := range s.Steps {
|
||||
if err := step.Validate(i); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate comprueba que el paso tiene los campos requeridos segun su action.
|
||||
func (s *Step) Validate(idx int) error {
|
||||
prefix := fmt.Sprintf("step[%d] action=%q", idx, s.Action)
|
||||
switch s.Action {
|
||||
case "navigate":
|
||||
if s.URL == "" {
|
||||
return fmt.Errorf("%s: campo 'url' obligatorio", prefix)
|
||||
}
|
||||
case "wait":
|
||||
if s.Selector == "" {
|
||||
return fmt.Errorf("%s: campo 'selector' obligatorio", prefix)
|
||||
}
|
||||
case "click":
|
||||
if s.Selector == "" {
|
||||
return fmt.Errorf("%s: campo 'selector' obligatorio", prefix)
|
||||
}
|
||||
case "type":
|
||||
if s.Selector == "" {
|
||||
return fmt.Errorf("%s: campo 'selector' obligatorio", prefix)
|
||||
}
|
||||
if s.Text == "" {
|
||||
return fmt.Errorf("%s: campo 'text' obligatorio", prefix)
|
||||
}
|
||||
case "screenshot":
|
||||
if s.Path == "" {
|
||||
return fmt.Errorf("%s: campo 'path' obligatorio", prefix)
|
||||
}
|
||||
case "evaluate":
|
||||
if s.Expr == "" {
|
||||
return fmt.Errorf("%s: campo 'expr' obligatorio", prefix)
|
||||
}
|
||||
case "get_html":
|
||||
// sin parametros requeridos
|
||||
case "wait_load":
|
||||
// sin parametros requeridos (timeout_ms opcional)
|
||||
case "sleep":
|
||||
if s.Ms <= 0 {
|
||||
return fmt.Errorf("%s: campo 'ms' debe ser mayor que 0", prefix)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("%s: accion desconocida (navigate|wait|wait_load|click|type|screenshot|evaluate|get_html|sleep)", prefix)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadScript lee y parsea un archivo YAML de script de navegador.
|
||||
func LoadScript(path string) (*Script, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("leer script %q: %w", path, err)
|
||||
}
|
||||
|
||||
var s Script
|
||||
if err := yaml.Unmarshal(data, &s); err != nil {
|
||||
return nil, fmt.Errorf("parsear script %q: %w", path, err)
|
||||
}
|
||||
|
||||
if err := s.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &s, nil
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// getWindowsHostIP obtiene la IP del host Windows desde WSL2.
|
||||
// Lee /etc/resolv.conf que WSL2 configura con la IP del host.
|
||||
func getWindowsHostIP() string {
|
||||
data, err := os.ReadFile("/etc/resolv.conf")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "nameserver ") {
|
||||
ip := strings.TrimPrefix(line, "nameserver ")
|
||||
ip = strings.TrimSpace(ip)
|
||||
if ip != "" {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// getWindowsGatewayIP obtiene la IP del gateway (host Windows) desde la tabla de rutas.
|
||||
func getWindowsGatewayIP() string {
|
||||
data, err := os.ReadFile("/proc/net/route")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 3 && fields[1] == "00000000" { // default route
|
||||
hexIP := fields[2]
|
||||
if len(hexIP) == 8 {
|
||||
// /proc/net/route stores IPs as little-endian 32-bit hex
|
||||
// "011017AC" -> bytes [01,10,17,AC] -> IP 172.23.16.1 (reversed)
|
||||
var a, b, c, d uint8
|
||||
fmt.Sscanf(hexIP[0:2], "%02x", &a)
|
||||
fmt.Sscanf(hexIP[2:4], "%02x", &b)
|
||||
fmt.Sscanf(hexIP[4:6], "%02x", &c)
|
||||
fmt.Sscanf(hexIP[6:8], "%02x", &d)
|
||||
return fmt.Sprintf("%d.%d.%d.%d", d, c, b, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// waitForCDP espera a que el puerto CDP esté accesible desde WSL.
|
||||
func waitForCDP(host string, port int, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
addr := fmt.Sprintf("%s:%d", host, port)
|
||||
for time.Now().Before(deadline) {
|
||||
conn, err := net.DialTimeout("tcp", addr, 300*time.Millisecond)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
return nil
|
||||
}
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("CDP %s no disponible despues de %s", addr, timeout)
|
||||
}
|
||||
|
||||
// startCDPProxy levanta un proxy TCP local que reenvía conexiones al host Windows.
|
||||
// Chrome CDP solo acepta conexiones desde localhost, así que el proxy en WSL
|
||||
// conecta al host Windows vía portproxy/netsh y expone el puerto localmente.
|
||||
// Retorna el puerto local del proxy y una función para cerrarlo.
|
||||
func startCDPProxy(windowsHost string, remotePort, localPort int) (net.Listener, error) {
|
||||
ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", localPort))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("proxy listen: %w", err)
|
||||
}
|
||||
go func() {
|
||||
for {
|
||||
client, err := ln.Accept()
|
||||
if err != nil {
|
||||
return // listener cerrado
|
||||
}
|
||||
go proxyConn(client, windowsHost, remotePort)
|
||||
}
|
||||
}()
|
||||
return ln, nil
|
||||
}
|
||||
|
||||
func proxyConn(client net.Conn, host string, port int) {
|
||||
defer client.Close()
|
||||
remote, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", host, port), 5*time.Second)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer remote.Close()
|
||||
go io.Copy(remote, client)
|
||||
io.Copy(client, remote)
|
||||
}
|
||||
Reference in New Issue
Block a user