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:
+80
-1
@@ -53,7 +53,7 @@ sqlite3 registry.db ".schema"
|
||||
|
||||
**functions** — columnas: `id, name, kind, lang, domain, version, purity, signature, description, tags, uses_functions, uses_types, returns, returns_optional, error_type, imports, example, tested, tests, test_file_path, file_path, created_at, updated_at, props, emits, has_state, framework, variant, notes, documentation, code, content_hash, source_repo, source_license, source_file`
|
||||
- Enums: `kind`(function|pipeline|component) `purity`(pure|impure) `lang`(go|py|bash|ps)
|
||||
- Dominios: core, infra, finance, datascience, cybersecurity, shell, tui, pipelines
|
||||
- Dominios: core, infra, finance, datascience, cybersecurity, shell, tui, pipelines, browser
|
||||
|
||||
**types** — columnas: `id, name, lang, domain, version, algebraic, definition, description, tags, uses_types, file_path, created_at, updated_at, examples, notes, documentation, code, content_hash, source_repo, source_license, source_file`
|
||||
- Enums: `algebraic`(product|sum)
|
||||
@@ -80,6 +80,7 @@ fn-registry/
|
||||
registry/ # Paquete Go: modelos, SQLite, parser, indexer, validacion, migraciones
|
||||
fn_operations/ # Paquete Go: operations database (libreria)
|
||||
apps/ # Apps ejecutables (TUIs, CLIs, scripts) — codigo NO reutilizable, cada una con su operations.db
|
||||
analysis/ # Exploraciones Jupyter independientes — cada una con su venv, MCP y kernel conectado al registry
|
||||
cmd/fn/ # CLI principal
|
||||
docs/ # Specs de diseño
|
||||
docs/templates/ # Plantillas de frontmatter
|
||||
@@ -193,6 +194,84 @@ Ver template en `docs/templates/`.
|
||||
|
||||
---
|
||||
|
||||
## Analysis (exploraciones Jupyter)
|
||||
|
||||
Carpeta `analysis/` para exploraciones de datos con Jupyter + agentes Claude. Mismo patron que `apps/` — cada analisis es independiente con su propio venv, MCP y kernel.
|
||||
|
||||
**NO es codigo reutilizable** — son investigaciones ad-hoc. Si algo de un analisis resulta util, se extrae como funcion al registry.
|
||||
|
||||
### Estructura
|
||||
|
||||
```
|
||||
analysis/
|
||||
{tema}/ # Cada analisis es autonomo
|
||||
.venv/ # Deps propias (gitignored)
|
||||
.mcp.json # MCP jupyter apuntando a SU venv (gitignored)
|
||||
.claude/CLAUDE.md # Reglas para agentes en este analisis
|
||||
.ipython/profile_default/startup/ # Kernel startup con acceso al registry
|
||||
00_fn_registry.py # Autocarga FN_REGISTRY_ROOT, helpers, sys.path
|
||||
notebooks/ # Notebooks de exploracion
|
||||
data/ # Datos locales (gitignored)
|
||||
run-jupyter-lab.sh # Launcher Jupyter colaborativo
|
||||
pyproject.toml # Deps gestionadas con uv
|
||||
```
|
||||
|
||||
### Crear un analisis nuevo
|
||||
|
||||
```bash
|
||||
# Basico
|
||||
fn run init_jupyter_analysis finanzas
|
||||
|
||||
# Con paquetes extra
|
||||
fn run init_jupyter_analysis ml scikit-learn torch
|
||||
fn run init_jupyter_analysis duckdb polars duckdb
|
||||
```
|
||||
|
||||
El pipeline `init_jupyter_analysis_bash_pipelines` compone 8 funciones atomicas del registry.
|
||||
|
||||
### Usar un analisis
|
||||
|
||||
```bash
|
||||
# Terminal 1: lanzar Jupyter
|
||||
cd analysis/{tema} && ./run-jupyter-lab.sh
|
||||
|
||||
# Terminal 2: abrir Claude con MCP jupyter
|
||||
cd analysis/{tema} && claude
|
||||
|
||||
# Navegador: http://localhost:8888
|
||||
```
|
||||
|
||||
### Acceso al registry desde notebooks
|
||||
|
||||
El kernel startup (`00_fn_registry.py`) se ejecuta automaticamente al abrir cualquier notebook y provee:
|
||||
|
||||
```python
|
||||
# Helpers disponibles sin importar nada:
|
||||
fn_search("slice") # Busca funciones y tipos por nombre/descripcion
|
||||
fn_query("SELECT ...") # SQL directo sobre registry.db
|
||||
fn_code("filter_list_py_core") # Codigo fuente de una funcion
|
||||
|
||||
# Importar funciones Python del registry directamente:
|
||||
from core import filter_list, map_list, reduce_list
|
||||
from finance import sma, ema, rsi
|
||||
from metabase import MetabaseClient
|
||||
|
||||
# Variable de entorno disponible:
|
||||
import os
|
||||
os.environ["FN_REGISTRY_ROOT"] # Raiz del registry
|
||||
```
|
||||
|
||||
### Reglas para agentes en analysis
|
||||
|
||||
Cada analisis tiene su `.claude/CLAUDE.md` con reglas especificas:
|
||||
- Celdas inmutables: nunca modificar celdas existentes, solo anadir nuevas
|
||||
- Programacion funcional obligatoria: funciones puras, sin mutacion
|
||||
- Usar MCP jupyter para ejecutar codigo, nunca bash
|
||||
- Notebooks en `notebooks/`, maximo 50 celdas por notebook
|
||||
- Dependencias con `uv add`, nunca pip directo
|
||||
|
||||
---
|
||||
|
||||
## Bucle reactivo: CONSTRUIR → EJECUTAR → RECOPILAR → ANALIZAR → MEJORAR
|
||||
|
||||
### 1. CONSTRUIR
|
||||
|
||||
@@ -34,6 +34,10 @@ registry.db-wal
|
||||
**/*.pyo
|
||||
python/.venv/
|
||||
|
||||
# Externalized apps and analysis (each is its own Gitea repo)
|
||||
apps/*/
|
||||
analysis/*/
|
||||
|
||||
# Node / pnpm
|
||||
**/node_modules/
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: init_uv_venv
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "init_uv_venv([project_dir: string]) -> string"
|
||||
description: "Crea un virtualenv Python con uv en el directorio dado si no existe. Fallback a python3 -m venv. Retorna la ruta del venv."
|
||||
tags: [python, venv, uv, setup, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/init_uv_venv.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source init_uv_venv.sh
|
||||
venv=$(init_uv_venv /home/lucas/analysis/finanzas)
|
||||
echo "Venv creado en: $venv"
|
||||
|
||||
# Idempotente — si ya existe, retorna la ruta sin recrear
|
||||
venv=$(init_uv_venv .)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Idempotente: si el venv ya existe con un python valido, retorna la ruta sin hacer nada. Prefiere uv por velocidad, usa python3 como fallback.
|
||||
@@ -0,0 +1,35 @@
|
||||
# init_uv_venv
|
||||
# -------------
|
||||
# Crea un venv con uv en el directorio especificado si no existe.
|
||||
# Fallback a python -m venv si uv no esta disponible.
|
||||
# Imprime la ruta del venv a stdout.
|
||||
#
|
||||
# USO (sourced):
|
||||
# source init_uv_venv.sh
|
||||
# venv_path=$(init_uv_venv /path/to/project)
|
||||
|
||||
init_uv_venv() {
|
||||
local project_dir="${1:-.}"
|
||||
local venv_path="${project_dir}/.venv"
|
||||
|
||||
if [ -d "$venv_path" ] && [ -f "$venv_path/bin/python" ]; then
|
||||
echo "$venv_path"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if command -v uv &>/dev/null; then
|
||||
(cd "$project_dir" && uv venv) >/dev/null 2>&1
|
||||
elif command -v python3 &>/dev/null; then
|
||||
python3 -m venv "$venv_path"
|
||||
else
|
||||
echo "init_uv_venv: ni uv ni python3 disponibles" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$venv_path/bin/python" ]; then
|
||||
echo "init_uv_venv: fallo al crear venv en $venv_path" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "$venv_path"
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: uv_add_packages
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "uv_add_packages(project_dir: string, ...packages: string) -> void"
|
||||
description: "Instala paquetes Python en un proyecto usando uv add con fallback a pip. Inicializa pyproject.toml si no existe."
|
||||
tags: [python, uv, pip, packages, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/uv_add_packages.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source uv_add_packages.sh
|
||||
uv_add_packages /home/lucas/analysis/finanzas jupyter jupyterlab pandas numpy
|
||||
|
||||
# Solo un paquete
|
||||
uv_add_packages . polars
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Requiere que el venv ya exista (usa `init_uv_venv` antes). Prefiere uv por velocidad y reproducibilidad (lockfile). Si uv no esta disponible, usa pip del venv directamente.
|
||||
@@ -0,0 +1,35 @@
|
||||
# uv_add_packages
|
||||
# -----------------
|
||||
# Instala paquetes Python en un proyecto con uv add.
|
||||
# Inicializa pyproject.toml si no existe.
|
||||
# Fallback a pip install si uv no esta disponible.
|
||||
#
|
||||
# USO (sourced):
|
||||
# source uv_add_packages.sh
|
||||
# uv_add_packages /path/to/project jupyter jupyterlab pandas
|
||||
|
||||
uv_add_packages() {
|
||||
local project_dir="$1"
|
||||
shift
|
||||
local packages=("$@")
|
||||
|
||||
if [ ${#packages[@]} -eq 0 ]; then
|
||||
echo "uv_add_packages: se requiere al menos un paquete" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ ! -d "$project_dir/.venv" ]; then
|
||||
echo "uv_add_packages: no existe .venv en $project_dir — ejecuta init_uv_venv primero" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if command -v uv &>/dev/null; then
|
||||
# Inicializar pyproject.toml si no existe
|
||||
if [ ! -f "$project_dir/pyproject.toml" ]; then
|
||||
(cd "$project_dir" && uv init 2>/dev/null) || true
|
||||
fi
|
||||
(cd "$project_dir" && uv add "${packages[@]}" 2>&1)
|
||||
else
|
||||
"$project_dir/.venv/bin/pip" install "${packages[@]}" 2>&1
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
name: write_claude_jupyter_rules
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "write_claude_jupyter_rules([project_dir: string]) -> string"
|
||||
description: "Genera o actualiza .claude/CLAUDE.md con reglas para agentes que trabajan con Jupyter: celdas inmutables, programacion funcional, uso de MCP, acceso al fn_registry."
|
||||
tags: [claude, jupyter, rules, setup, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/write_claude_jupyter_rules.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source write_claude_jupyter_rules.sh
|
||||
path=$(write_claude_jupyter_rules /home/lucas/analysis/finanzas)
|
||||
echo "Reglas escritas en: $path"
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Idempotente: si CLAUDE.md ya contiene las reglas (detecta "JUPYTER HABILITADO"), no las duplica. Si CLAUDE.md existe sin las reglas, las prepend al contenido existente. Incluye instrucciones de acceso al fn_registry via `FN_REGISTRY_ROOT`.
|
||||
@@ -0,0 +1,74 @@
|
||||
# write_claude_jupyter_rules
|
||||
# ----------------------------
|
||||
# Genera .claude/CLAUDE.md con reglas para agentes que trabajan con Jupyter.
|
||||
# Si ya existe CLAUDE.md y no tiene las reglas, las prepend.
|
||||
#
|
||||
# USO (sourced):
|
||||
# source write_claude_jupyter_rules.sh
|
||||
# write_claude_jupyter_rules /path/to/project
|
||||
|
||||
write_claude_jupyter_rules() {
|
||||
local project_dir="${1:-.}"
|
||||
local claude_dir="${project_dir}/.claude"
|
||||
local claude_md="${claude_dir}/CLAUDE.md"
|
||||
|
||||
mkdir -p "$claude_dir"
|
||||
|
||||
# Si ya tiene las reglas, no hacer nada
|
||||
if [ -f "$claude_md" ] && grep -q "JUPYTER HABILITADO" "$claude_md" 2>/dev/null; then
|
||||
echo "$claude_md"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local rules
|
||||
rules='# JUPYTER HABILITADO EN ESTE ANALISIS
|
||||
|
||||
## Reglas OBLIGATORIAS para Claude
|
||||
|
||||
### 1. CODIGO INMUTABLE — NUNCA MODIFICAR CELDAS EXISTENTES
|
||||
- **PROHIBIDO** usar NotebookEdit para reemplazar celdas existentes
|
||||
- **SIEMPRE** anadir celdas NUEVAS al final del notebook
|
||||
- Si hay un error en una celda, crear celda nueva con la correccion
|
||||
- El historial de trabajo debe quedar intacto para trazabilidad
|
||||
|
||||
### 2. PROGRAMACION FUNCIONAL OBLIGATORIA
|
||||
- **Funciones puras**: sin efectos secundarios, mismo input -> mismo output
|
||||
- **Inmutabilidad**: nunca mutar datos, crear copias transformadas
|
||||
- **Composicion**: funciones pequenas que se combinan
|
||||
- Preferir: `map`, `filter`, `reduce`, list comprehensions
|
||||
- Evitar: loops con mutacion, `global`, modificar argumentos in-place
|
||||
|
||||
### 3. SIEMPRE usar MCP jupyter para ejecutar codigo Python
|
||||
- Las ejecuciones se ven en tiempo real en Jupyter Lab del usuario
|
||||
- Compartimos variables y estado del kernel
|
||||
- **NUNCA usar bash para ejecutar Python en este analisis**
|
||||
|
||||
### 4. Verificar Jupyter activo ANTES de ejecutar
|
||||
- Si no esta activo: pedir al usuario que ejecute `./run-jupyter-lab.sh`
|
||||
|
||||
### 5. Gestion de notebooks
|
||||
- Notebooks en la carpeta `notebooks/` o subcarpetas
|
||||
- Si un notebook tiene >50 celdas, crear uno nuevo
|
||||
- Nombrar descriptivamente: `01_exploracion.ipynb`, `02_limpieza.ipynb`
|
||||
|
||||
### 6. Gestion de Python
|
||||
- **SIEMPRE usar `uv`** para gestionar dependencias
|
||||
- Anadir paquetes con `uv add nombre_paquete`
|
||||
|
||||
### 7. Acceso al fn_registry
|
||||
- `FN_REGISTRY_ROOT` apunta a la raiz del registry
|
||||
- Para importar funciones Python: `sys.path.insert(0, os.path.join(os.environ["FN_REGISTRY_ROOT"], "python", "functions"))`
|
||||
- Para consultar registry.db: `sqlite3` o `import sqlite3` con la ruta `$FN_REGISTRY_ROOT/registry.db`
|
||||
|
||||
'
|
||||
|
||||
if [ -f "$claude_md" ]; then
|
||||
local existing
|
||||
existing=$(cat "$claude_md")
|
||||
printf '%s\n%s' "$rules" "$existing" > "$claude_md"
|
||||
else
|
||||
echo "$rules" > "$claude_md"
|
||||
fi
|
||||
|
||||
echo "$claude_md"
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
name: write_jupyter_launcher
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "write_jupyter_launcher([project_dir: string]) -> string"
|
||||
description: "Genera un script run-jupyter-lab.sh que lanza Jupyter Lab en modo colaborativo con autodeteccion de puerto y token deshabilitado."
|
||||
tags: [jupyter, launcher, setup, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/write_jupyter_launcher.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source write_jupyter_launcher.sh
|
||||
path=$(write_jupyter_launcher /home/lucas/analysis/finanzas)
|
||||
echo "Launcher creado en: $path"
|
||||
|
||||
# Luego en otra terminal:
|
||||
# ./run-jupyter-lab.sh [puerto]
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
El launcher generado:
|
||||
- Autodetecta un puerto libre (8888-8899)
|
||||
- Guarda el puerto en `.jupyter-port` para que otros procesos lo lean
|
||||
- Activa el venv automaticamente
|
||||
- Lanza Jupyter Lab en modo `--collaborative` (requiere jupyter-collaboration)
|
||||
- Token y password deshabilitados para acceso local
|
||||
@@ -0,0 +1,65 @@
|
||||
# write_jupyter_launcher
|
||||
# -----------------------
|
||||
# Genera un script run-jupyter-lab.sh en el directorio dado.
|
||||
# El script lanza Jupyter Lab en modo colaborativo con autodeteccion de puerto.
|
||||
#
|
||||
# USO (sourced):
|
||||
# source write_jupyter_launcher.sh
|
||||
# write_jupyter_launcher /path/to/project
|
||||
|
||||
write_jupyter_launcher() {
|
||||
local project_dir="${1:-.}"
|
||||
local launcher="${project_dir}/run-jupyter-lab.sh"
|
||||
|
||||
cat > "$launcher" << 'LAUNCHER'
|
||||
#!/bin/bash
|
||||
# Jupyter Lab — modo colaborativo con autodeteccion de puerto
|
||||
# Generado por write_jupyter_launcher (fn_registry)
|
||||
|
||||
find_free_port() {
|
||||
for port in 8888 8889 8890 8891 8892 8893 8894 8895 8896 8897 8898 8899; do
|
||||
if ! ss -tln 2>/dev/null | grep -q ":${port} " && \
|
||||
! lsof -i:"$port" >/dev/null 2>&1; then
|
||||
echo $port
|
||||
return
|
||||
fi
|
||||
done
|
||||
echo 8888
|
||||
}
|
||||
|
||||
PORT=${1:-$(find_free_port)}
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo $PORT > .jupyter-port
|
||||
|
||||
source .venv/bin/activate 2>/dev/null || true
|
||||
|
||||
if ! python -c "import jupyter_collaboration" 2>/dev/null; then
|
||||
echo "ERROR: jupyter-collaboration no esta instalado"
|
||||
echo "Instala con: uv add jupyter-collaboration"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "════════════════════════════════════════════════"
|
||||
echo " Jupyter Lab + Colaboracion en puerto $PORT"
|
||||
echo "════════════════════════════════════════════════"
|
||||
echo ""
|
||||
echo " Abre: http://localhost:$PORT"
|
||||
echo " Ctrl+C para detener"
|
||||
echo ""
|
||||
|
||||
jupyter lab \
|
||||
--port=$PORT \
|
||||
--no-browser \
|
||||
--ServerApp.token='' \
|
||||
--ServerApp.password='' \
|
||||
--ServerApp.disable_check_xsrf=True \
|
||||
--ServerApp.allow_origin='*' \
|
||||
--ServerApp.root_dir="$(pwd)" \
|
||||
--YDocExtension.ystore_class='ypy_websocket.ystore.TempFileYStore' \
|
||||
--collaborative
|
||||
LAUNCHER
|
||||
|
||||
chmod +x "$launcher"
|
||||
echo "$launcher"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
name: write_jupyter_registry_kernel
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "write_jupyter_registry_kernel([project_dir: string]) -> string"
|
||||
description: "Genera un script de startup de IPython que autoconfigura FN_REGISTRY_ROOT, sys.path a python/functions del registry, y helpers fn_query/fn_search/fn_code para consultar registry.db desde notebooks."
|
||||
tags: [jupyter, ipython, kernel, registry, setup, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/write_jupyter_registry_kernel.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source write_jupyter_registry_kernel.sh
|
||||
path=$(write_jupyter_registry_kernel /home/lucas/analysis/finanzas)
|
||||
echo "Startup creado en: $path"
|
||||
```
|
||||
|
||||
Luego en cualquier notebook del proyecto:
|
||||
|
||||
```python
|
||||
# Ya disponible automaticamente al abrir el notebook:
|
||||
|
||||
# Buscar funciones
|
||||
fn_search("finance")
|
||||
|
||||
# Consultar registry.db directamente
|
||||
fn_query("SELECT id, signature FROM functions WHERE domain = ?", ("core",))
|
||||
|
||||
# Ver codigo de una funcion
|
||||
print(fn_code("filter_list_py_core"))
|
||||
|
||||
# Importar funciones Python del registry directamente
|
||||
from core import filter_list, map_list
|
||||
from finance import sma, ema, rsi
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Genera `.ipython/profile_default/startup/00_fn_registry.py` que se ejecuta automaticamente al iniciar cualquier kernel IPython en el proyecto. No requiere imports manuales — las funciones `fn_query`, `fn_search` y `fn_code` estan disponibles inmediatamente en cada notebook.
|
||||
|
||||
El `sys.path` se configura para que cada dominio de `python/functions/` sea importable directamente (`from core import filter_list`).
|
||||
|
||||
Detecta `FN_REGISTRY_ROOT` buscando la raiz del repo git o subiendo directorios hasta encontrar `registry.db`.
|
||||
@@ -0,0 +1,111 @@
|
||||
# write_jupyter_registry_kernel
|
||||
# -------------------------------
|
||||
# Genera un script de startup de IPython que autoconfigura el acceso
|
||||
# al fn_registry en cada notebook: FN_REGISTRY_ROOT, sys.path a
|
||||
# python/functions, y un helper fn_query() para consultar registry.db.
|
||||
#
|
||||
# USO (sourced):
|
||||
# source write_jupyter_registry_kernel.sh
|
||||
# write_jupyter_registry_kernel /path/to/project
|
||||
|
||||
write_jupyter_registry_kernel() {
|
||||
local project_dir="${1:-.}"
|
||||
local startup_dir="${project_dir}/.ipython/profile_default/startup"
|
||||
local registry_root
|
||||
registry_root="$(cd "$project_dir" && cd "$(git -C "$project_dir" rev-parse --show-toplevel 2>/dev/null || echo "../..")" && pwd)"
|
||||
|
||||
# Fallback: si no es git, buscar registry.db subiendo directorios
|
||||
if [ ! -f "$registry_root/registry.db" ] && [ -f "$project_dir/../../registry.db" ]; then
|
||||
registry_root="$(cd "$project_dir/../.." && pwd)"
|
||||
fi
|
||||
|
||||
mkdir -p "$startup_dir"
|
||||
|
||||
cat > "${startup_dir}/00_fn_registry.py" << STARTUP
|
||||
"""
|
||||
fn_registry kernel startup
|
||||
Autoconfigura acceso al registry en cada notebook.
|
||||
Generado por write_jupyter_registry_kernel (fn_registry).
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
# ── FN_REGISTRY_ROOT ────────────────────────────────────────
|
||||
FN_REGISTRY_ROOT = Path("${registry_root}")
|
||||
os.environ["FN_REGISTRY_ROOT"] = str(FN_REGISTRY_ROOT)
|
||||
|
||||
# ── sys.path: importar funciones Python del registry ────────
|
||||
_python_functions = FN_REGISTRY_ROOT / "python" / "functions"
|
||||
for _domain in sorted(_python_functions.iterdir()) if _python_functions.exists() else []:
|
||||
if _domain.is_dir() and not _domain.name.startswith("_"):
|
||||
_path = str(_domain)
|
||||
if _path not in sys.path:
|
||||
sys.path.insert(0, _path)
|
||||
|
||||
# Tambien el directorio padre para imports por dominio: from core import filter_list
|
||||
_pf = str(_python_functions)
|
||||
if _pf not in sys.path:
|
||||
sys.path.insert(0, _pf)
|
||||
|
||||
# ── fn_query: consultar registry.db desde el notebook ───────
|
||||
_REGISTRY_DB = FN_REGISTRY_ROOT / "registry.db"
|
||||
|
||||
def fn_query(sql, params=()):
|
||||
"""Ejecuta una consulta SQL sobre registry.db y retorna las filas.
|
||||
|
||||
Ejemplos:
|
||||
fn_query("SELECT id, description FROM functions WHERE domain = ?", ("finance",))
|
||||
fn_query("SELECT id FROM functions_fts WHERE functions_fts MATCH ?", ("slice*",))
|
||||
"""
|
||||
if not _REGISTRY_DB.exists():
|
||||
raise FileNotFoundError(f"registry.db no encontrado en {_REGISTRY_DB}")
|
||||
con = sqlite3.connect(str(_REGISTRY_DB))
|
||||
con.row_factory = sqlite3.Row
|
||||
try:
|
||||
rows = con.execute(sql, params).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
def fn_search(term):
|
||||
"""Busca funciones y tipos en el registry por nombre o descripcion.
|
||||
|
||||
Ejemplo:
|
||||
fn_search("slice")
|
||||
fn_search("finance")
|
||||
"""
|
||||
fts_term = f"name:{term}* OR description:{term}*"
|
||||
functions = fn_query(
|
||||
"SELECT id, kind, purity, lang, description FROM functions "
|
||||
"WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH ?) "
|
||||
"ORDER BY name", (fts_term,)
|
||||
)
|
||||
types = fn_query(
|
||||
"SELECT id, algebraic, lang, description FROM types "
|
||||
"WHERE id IN (SELECT id FROM types_fts WHERE types_fts MATCH ?) "
|
||||
"ORDER BY name", (fts_term,)
|
||||
)
|
||||
return {"functions": functions, "types": types}
|
||||
|
||||
def fn_code(function_id):
|
||||
"""Retorna el codigo fuente de una funcion del registry.
|
||||
|
||||
Ejemplo:
|
||||
print(fn_code("filter_list_py_core"))
|
||||
"""
|
||||
rows = fn_query("SELECT code FROM functions WHERE id = ?", (function_id,))
|
||||
if not rows:
|
||||
raise KeyError(f"Funcion no encontrada: {function_id}")
|
||||
return rows[0]["code"]
|
||||
|
||||
# ── Mensaje de bienvenida ───────────────────────────────────
|
||||
print(f"fn_registry conectado: {FN_REGISTRY_ROOT}")
|
||||
print(f" registry.db: {'OK' if _REGISTRY_DB.exists() else 'NO ENCONTRADO'}")
|
||||
print(f" Python functions: {_pf}")
|
||||
print(f" Helpers: fn_query(), fn_search(), fn_code()")
|
||||
STARTUP
|
||||
|
||||
echo "${startup_dir}/00_fn_registry.py"
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
name: write_mcp_jupyter_config
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "write_mcp_jupyter_config([project_dir: string], [port: int]) -> string"
|
||||
description: "Genera o actualiza .mcp.json con la configuracion de jupyter-mcp-server apuntando al venv local y puerto dado. Merge con jq si ya existe."
|
||||
tags: [mcp, jupyter, config, setup, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/write_mcp_jupyter_config.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source write_mcp_jupyter_config.sh
|
||||
path=$(write_mcp_jupyter_config /home/lucas/analysis/finanzas 8890)
|
||||
echo "Config MCP en: $path"
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
El MCP se invoca como modulo Python (`python -m jupyter_mcp_server`) usando el python del venv local, nunca una instalacion global. Si `.mcp.json` ya existe y jq esta disponible, hace merge conservando otros servidores MCP. Sin jq, sobrescribe el archivo.
|
||||
@@ -0,0 +1,57 @@
|
||||
# write_mcp_jupyter_config
|
||||
# -------------------------
|
||||
# Genera o actualiza .mcp.json con la configuracion de jupyter-mcp-server.
|
||||
# Usa el python del venv local con -m jupyter_mcp_server.
|
||||
# Hace merge si ya existe .mcp.json (requiere jq).
|
||||
#
|
||||
# USO (sourced):
|
||||
# source write_mcp_jupyter_config.sh
|
||||
# write_mcp_jupyter_config /path/to/project 8888
|
||||
|
||||
write_mcp_jupyter_config() {
|
||||
local project_dir="${1:-.}"
|
||||
local port="${2:-8888}"
|
||||
local mcp_file="${project_dir}/.mcp.json"
|
||||
local abs_project
|
||||
abs_project="$(cd "$project_dir" && pwd)"
|
||||
|
||||
local python_bin="${abs_project}/.venv/bin/python"
|
||||
if [ ! -f "$python_bin" ]; then
|
||||
echo "write_mcp_jupyter_config: python no encontrado en ${python_bin}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Verificar que el modulo esta instalado
|
||||
if ! "$python_bin" -c "import jupyter_mcp_server" 2>/dev/null; then
|
||||
echo "write_mcp_jupyter_config: jupyter_mcp_server no instalado en el venv" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local new_config
|
||||
new_config=$(cat << EOF
|
||||
{
|
||||
"mcpServers": {
|
||||
"jupyter": {
|
||||
"command": "${python_bin}",
|
||||
"args": [
|
||||
"-m", "jupyter_mcp_server",
|
||||
"--runtime-url", "http://localhost:${port}",
|
||||
"--start-new-runtime", "false"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
if [ -f "$mcp_file" ] && command -v jq &>/dev/null; then
|
||||
# Merge conservando otros servidores MCP
|
||||
jq -s '.[0] * {mcpServers: ((.[0].mcpServers // {}) * (.[1].mcpServers // {}))}' \
|
||||
"$mcp_file" <(echo "$new_config") > "${mcp_file}.tmp"
|
||||
mv "${mcp_file}.tmp" "$mcp_file"
|
||||
else
|
||||
echo "$new_config" > "$mcp_file"
|
||||
fi
|
||||
|
||||
echo "$mcp_file"
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
name: init_jupyter_analysis
|
||||
kind: pipeline
|
||||
lang: bash
|
||||
domain: pipelines
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "init_jupyter_analysis(nombre: string, [...paquetes_extra: string]) -> void"
|
||||
description: "Inicializa un analisis Jupyter completo en analysis/{nombre}/ con venv, paquetes, launcher, MCP y reglas para agentes Claude. Acepta paquetes extra opcionales."
|
||||
tags: [jupyter, analysis, setup, pipeline, bash, launcher]
|
||||
uses_functions:
|
||||
- assert_command_exists_bash_shell
|
||||
- find_free_port_bash_shell
|
||||
- init_uv_venv_bash_infra
|
||||
- uv_add_packages_bash_infra
|
||||
- write_jupyter_launcher_bash_infra
|
||||
- write_mcp_jupyter_config_bash_infra
|
||||
- write_claude_jupyter_rules_bash_infra
|
||||
- write_jupyter_registry_kernel_bash_infra
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/pipelines/init_jupyter_analysis.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Analisis basico
|
||||
./init_jupyter_analysis.sh finanzas
|
||||
|
||||
# Con paquetes extra
|
||||
./init_jupyter_analysis.sh duckdb polars duckdb
|
||||
./init_jupyter_analysis.sh ml scikit-learn torch
|
||||
|
||||
# Via fn run
|
||||
fn run init_jupyter_analysis finanzas
|
||||
fn run init_jupyter_analysis ml scikit-learn torch
|
||||
```
|
||||
|
||||
## Flujo
|
||||
|
||||
1. `assert_command_exists` — verifica que uv o python3 estan disponibles
|
||||
2. Crea estructura `analysis/{nombre}/notebooks/` y `analysis/{nombre}/data/`
|
||||
3. `init_uv_venv` — crea venv en `analysis/{nombre}/.venv/`
|
||||
4. `uv_add_packages` — instala jupyter, jupyterlab, jupyter-collaboration, jupyter-mcp-server, pandas, numpy, matplotlib + extras
|
||||
5. `write_jupyter_launcher` — genera `run-jupyter-lab.sh` con modo colaborativo
|
||||
6. `find_free_port` + `write_mcp_jupyter_config` — detecta puerto libre y genera `.mcp.json`
|
||||
7. `write_claude_jupyter_rules` — genera `.claude/CLAUDE.md` con reglas de agente
|
||||
8. `write_jupyter_registry_kernel` — genera IPython startup con `fn_query`, `fn_search`, `fn_code` y acceso a `python/functions/`
|
||||
|
||||
## Notas
|
||||
|
||||
Cada analisis es independiente (propio venv, propio Jupyter, propio MCP). Mismo patron que `apps/` pero para exploraciones no reutilizables.
|
||||
|
||||
El pipeline usa `set -euo pipefail` — cualquier fallo detiene la ejecucion.
|
||||
|
||||
Paquetes base siempre incluidos: jupyter, jupyterlab, jupyter-collaboration, jupyter-mcp-server, pandas, numpy, matplotlib. Los paquetes extra se añaden a estos.
|
||||
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env bash
|
||||
# init_jupyter_analysis
|
||||
# ----------------------
|
||||
# Inicializa un analisis Jupyter completo en analysis/{nombre}/.
|
||||
# Compone: assert_command_exists + find_free_port + init_uv_venv +
|
||||
# uv_add_packages + write_jupyter_launcher +
|
||||
# write_mcp_jupyter_config + write_claude_jupyter_rules +
|
||||
# write_jupyter_registry_kernel
|
||||
#
|
||||
# USO:
|
||||
# ./init_jupyter_analysis.sh <nombre> [paquetes_extra...]
|
||||
#
|
||||
# EJEMPLOS:
|
||||
# ./init_jupyter_analysis.sh finanzas
|
||||
# ./init_jupyter_analysis.sh duckdb polars duckdb
|
||||
# ./init_jupyter_analysis.sh ml scikit-learn torch
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REGISTRY_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
|
||||
# Source funciones atomicas
|
||||
source "$REGISTRY_ROOT/bash/functions/shell/assert_command_exists.sh"
|
||||
source "$REGISTRY_ROOT/bash/functions/shell/find_free_port.sh"
|
||||
source "$REGISTRY_ROOT/bash/functions/infra/init_uv_venv.sh"
|
||||
source "$REGISTRY_ROOT/bash/functions/infra/uv_add_packages.sh"
|
||||
source "$REGISTRY_ROOT/bash/functions/infra/write_jupyter_launcher.sh"
|
||||
source "$REGISTRY_ROOT/bash/functions/infra/write_mcp_jupyter_config.sh"
|
||||
source "$REGISTRY_ROOT/bash/functions/infra/write_claude_jupyter_rules.sh"
|
||||
source "$REGISTRY_ROOT/bash/functions/infra/write_jupyter_registry_kernel.sh"
|
||||
|
||||
# ── Argumentos ──────────────────────────────────────────────
|
||||
|
||||
NOMBRE="${1:-}"
|
||||
if [ -z "$NOMBRE" ]; then
|
||||
echo "Uso: $0 <nombre> [paquetes_extra...]" >&2
|
||||
echo " Ejemplo: $0 finanzas polars" >&2
|
||||
exit 1
|
||||
fi
|
||||
shift
|
||||
EXTRA_PACKAGES=("$@")
|
||||
|
||||
ANALYSIS_DIR="${REGISTRY_ROOT}/analysis/${NOMBRE}"
|
||||
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
echo " INIT JUPYTER ANALYSIS: ${NOMBRE}"
|
||||
echo " Directorio: ${ANALYSIS_DIR}"
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
# ── 1. Verificar herramientas ───────────────────────────────
|
||||
|
||||
echo "[1/8] Verificando herramientas..."
|
||||
assert_command_exists uv || assert_command_exists python3
|
||||
echo " OK"
|
||||
|
||||
# ── 2. Crear estructura de carpetas ─────────────────────────
|
||||
|
||||
echo "[2/8] Creando estructura..."
|
||||
mkdir -p "$ANALYSIS_DIR/notebooks" "$ANALYSIS_DIR/data"
|
||||
echo " ${ANALYSIS_DIR}/notebooks/"
|
||||
echo " ${ANALYSIS_DIR}/data/"
|
||||
|
||||
# ── 3. Crear venv ───────────────────────────────────────────
|
||||
|
||||
echo "[3/8] Inicializando venv..."
|
||||
venv_path=$(init_uv_venv "$ANALYSIS_DIR")
|
||||
echo " $venv_path"
|
||||
|
||||
# ── 4. Instalar paquetes ────────────────────────────────────
|
||||
|
||||
echo "[4/8] Instalando paquetes..."
|
||||
BASE_PACKAGES=(jupyter jupyterlab jupyter-collaboration jupyter-mcp-server pandas numpy matplotlib)
|
||||
ALL_PACKAGES=("${BASE_PACKAGES[@]}" "${EXTRA_PACKAGES[@]}")
|
||||
uv_add_packages "$ANALYSIS_DIR" "${ALL_PACKAGES[@]}"
|
||||
echo " Instalados: ${ALL_PACKAGES[*]}"
|
||||
|
||||
# ── 5. Generar launcher ─────────────────────────────────────
|
||||
|
||||
echo "[5/8] Generando launcher..."
|
||||
launcher=$(write_jupyter_launcher "$ANALYSIS_DIR")
|
||||
echo " $launcher"
|
||||
|
||||
# ── 6. Configurar MCP ───────────────────────────────────────
|
||||
|
||||
echo "[6/8] Configurando MCP..."
|
||||
port=$(find_free_port 8888 8899)
|
||||
mcp_config=$(write_mcp_jupyter_config "$ANALYSIS_DIR" "$port")
|
||||
echo " $mcp_config (puerto: $port)"
|
||||
|
||||
# ── 7. Reglas para agentes ──────────────────────────────────
|
||||
|
||||
echo "[7/8] Escribiendo reglas Claude..."
|
||||
rules=$(write_claude_jupyter_rules "$ANALYSIS_DIR")
|
||||
echo " $rules"
|
||||
|
||||
# ── 8. Kernel startup con acceso al registry ────────────────
|
||||
|
||||
echo "[8/8] Configurando kernel con acceso al registry..."
|
||||
kernel_startup=$(write_jupyter_registry_kernel "$ANALYSIS_DIR")
|
||||
echo " $kernel_startup"
|
||||
|
||||
# ── Resumen ─────────────────────────────────────────────────
|
||||
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
echo " ANALISIS '${NOMBRE}' LISTO"
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
echo " Pasos siguientes:"
|
||||
echo ""
|
||||
echo " 1. En otra terminal:"
|
||||
echo " cd ${ANALYSIS_DIR} && ./run-jupyter-lab.sh"
|
||||
echo ""
|
||||
echo " 2. Abrir Claude en el analisis:"
|
||||
echo " cd ${ANALYSIS_DIR} && claude"
|
||||
echo ""
|
||||
echo " 3. Abrir en navegador: http://localhost:${port}"
|
||||
echo ""
|
||||
echo " FN_REGISTRY_ROOT=${REGISTRY_ROOT}"
|
||||
echo ""
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: exit_with_status
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: shell
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "exit_with_status(total_steps: int, ok_steps: int, failed_steps: int) -> int"
|
||||
description: "Calcula el exit code estandar (0=success, 1=failure, 2=partial) a partir de contadores de pasos. Si failed_steps=0 imprime 0 y sale con 0. Si ok_steps=0 imprime 1 y sale con 1. Si hay ambos imprime 2 y sale con 2."
|
||||
tags: [execution, status, exit-code, standard]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/shell/exit_with_status.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source bash/functions/shell/exit_with_status.sh
|
||||
|
||||
exit_with_status 5 5 0 # stdout: 0, exit code: 0
|
||||
exit_with_status 5 0 5 # stdout: 1, exit code: 1
|
||||
exit_with_status 5 3 2 # stdout: 2, exit code: 2
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Funcion pura: no realiza I/O de sistema, no modifica estado global, no lee variables de entorno. El argumento `total_steps` se recibe para completitud semantica pero la logica solo depende de `ok_steps` y `failed_steps`. El valor se imprime a stdout ademas de usarse como exit code, de modo que el caller puede capturarlo con `$(exit_with_status ...)` o evaluar directamente con `exit_with_status ... ; echo $?`.
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# exit_with_status — calcula el exit code estandar a partir de contadores de pasos
|
||||
|
||||
# exit_with_status <total_steps> <ok_steps> <failed_steps>
|
||||
#
|
||||
# Calcula el exit code estandar:
|
||||
# 0 — todos los pasos exitosos (failed_steps == 0)
|
||||
# 1 — todos los pasos fallaron (ok_steps == 0)
|
||||
# 2 — resultado parcial (hay ok y failed)
|
||||
#
|
||||
# Imprime el codigo a stdout y sale con ese codigo.
|
||||
exit_with_status() {
|
||||
local total_steps="$1"
|
||||
local ok_steps="$2"
|
||||
local failed_steps="$3"
|
||||
|
||||
local code
|
||||
|
||||
if [[ "$failed_steps" -eq 0 ]]; then
|
||||
code=0
|
||||
elif [[ "$ok_steps" -eq 0 ]]; then
|
||||
code=1
|
||||
else
|
||||
code=2
|
||||
fi
|
||||
|
||||
echo "$code"
|
||||
return "$code"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: find_free_port
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: shell
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "find_free_port([start_port: int], [end_port: int]) -> int"
|
||||
description: "Busca el primer puerto TCP libre en un rango dado usando ss y lsof. Retorna el numero de puerto a stdout."
|
||||
tags: [network, port, shell, utility]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/shell/find_free_port.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source find_free_port.sh
|
||||
port=$(find_free_port 8888 8899)
|
||||
echo "Puerto libre: $port"
|
||||
|
||||
# Con defaults (8888-8899)
|
||||
port=$(find_free_port)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Usa `ss -tln` como primer intento y `lsof` como fallback. Ambos deben confirmar que el puerto no esta en uso. Si ningun puerto esta libre en el rango, sale con exit code 1.
|
||||
@@ -0,0 +1,25 @@
|
||||
# find_free_port
|
||||
# ---------------
|
||||
# Busca el primer puerto libre en un rango dado.
|
||||
# Imprime el puerto encontrado a stdout.
|
||||
# Sale con exit code 1 si no encuentra ninguno.
|
||||
#
|
||||
# USO (sourced):
|
||||
# source find_free_port.sh
|
||||
# port=$(find_free_port 8888 8899)
|
||||
|
||||
find_free_port() {
|
||||
local start="${1:-8888}"
|
||||
local end="${2:-8899}"
|
||||
|
||||
for ((port=start; port<=end; port++)); do
|
||||
if ! ss -tln 2>/dev/null | grep -q ":${port} " && \
|
||||
! lsof -i:"$port" >/dev/null 2>&1; then
|
||||
echo "$port"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
echo "find_free_port: no se encontro puerto libre en rango ${start}-${end}" >&2
|
||||
return 1
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
name: report_execution_json
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: shell
|
||||
version: "2.0.0"
|
||||
purity: pure
|
||||
signature: "report_execution_json(flow_name: string, status: string, exit_code: int, started_at: string, ended_at: string, duration_ms: int, steps_file: string) -> string"
|
||||
description: "Genera un JSON de reporte de ejecucion siguiendo el estandar fn-registry (docs/execution_standard.md). Recibe los metadatos del flujo y un archivo TSV con resultados de pasos (columnas: name, action, status, elapsed_ms, output, error). Imprime el JSON completo a stdout. Usa jq si esta disponible, con fallback a printf. Funcion pura: sin efectos secundarios ni I/O adicional."
|
||||
tags: [execution, json, report, standard, shell, pure]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/shell/report_execution_json.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source bash/functions/shell/report_execution_json.sh
|
||||
|
||||
# Crear archivo de pasos (TSV sin cabecera, 6 columnas)
|
||||
cat > /tmp/steps.tsv <<'EOF'
|
||||
check_db command ok 10 exists
|
||||
backup command ok 450 done
|
||||
push command error 2540 remote rejected
|
||||
EOF
|
||||
|
||||
report_execution_json \
|
||||
"backup_db" "partial" 2 \
|
||||
"2026-04-01T02:00:00Z" "2026-04-01T02:00:03Z" 3000 \
|
||||
/tmp/steps.tsv
|
||||
```
|
||||
|
||||
Output esperado:
|
||||
|
||||
```json
|
||||
{"name":"backup_db","status":"partial","exit_code":2,"started_at":"2026-04-01T02:00:00Z","ended_at":"2026-04-01T02:00:03Z","duration_ms":3000,"steps_total":3,"steps_ok":2,"steps_failed":1,"steps":[{"name":"check_db","action":"command","status":"ok","elapsed_ms":10,"output":"exists"},{"name":"backup","action":"command","status":"ok","elapsed_ms":450,"output":"done"},{"name":"push","action":"command","status":"error","elapsed_ms":2540,"error":"remote rejected"}]}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Funcion pura: solo lee el archivo de pasos y escribe JSON a stdout, sin efectos secundarios ni I/O adicional mas alla de leer el steps_file.
|
||||
|
||||
Formato TSV: 6 columnas separadas por tabulador real (sin cabecera): name, action, status (ok|error), elapsed_ms, output, error. Los campos output y error pueden estar vacios; se omiten del JSON si no tienen valor, siguiendo el estandar de output estructurado.
|
||||
|
||||
El caller es responsable de calcular status (success|failure|partial) y exit_code (0|1|2) segun las reglas del estandar. Ver exit_with_status_bash_shell para esa logica.
|
||||
|
||||
Compatibilidad dual: con jq construye el JSON de forma robusta (maneja caracteres especiales y saltos de linea en output/error). Sin jq, el fallback con printf escapa backslash, comillas dobles y caracteres de control basicos.
|
||||
|
||||
Puede ejecutarse directamente: `bash report_execution_json.sh <args>`.
|
||||
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env bash
|
||||
# report_execution_json — Genera un JSON de reporte de ejecucion siguiendo el estandar fn-registry.
|
||||
#
|
||||
# USO (sourced):
|
||||
# source report_execution_json.sh
|
||||
# report_execution_json "backup_db" "partial" 2 \
|
||||
# "2026-04-01T02:00:00Z" "2026-04-01T02:00:03Z" 3000 /tmp/steps.tsv
|
||||
#
|
||||
# USO (ejecutado directamente):
|
||||
# bash report_execution_json.sh "backup_db" "partial" 2 \
|
||||
# "2026-04-01T02:00:00Z" "2026-04-01T02:00:03Z" 3000 /tmp/steps.tsv
|
||||
#
|
||||
# FORMATO steps_file (TSV sin cabecera, 6 columnas):
|
||||
# name<TAB>action<TAB>status<TAB>elapsed_ms<TAB>output<TAB>error
|
||||
# Los campos output y error pueden estar vacios.
|
||||
# status valido: ok | error
|
||||
#
|
||||
# NOTA sobre IFS y tabs: bash trata tab como whitespace en IFS y colapsa
|
||||
# campos vacios consecutivos. Se usa 'cut -f N' para parsear cada columna
|
||||
# de forma correcta cuando hay campos vacios entre tabs.
|
||||
|
||||
report_execution_json() {
|
||||
local flow_name="$1"
|
||||
local status="$2"
|
||||
local exit_code="$3"
|
||||
local started_at="$4"
|
||||
local ended_at="$5"
|
||||
local duration_ms="$6"
|
||||
local steps_file="$7"
|
||||
|
||||
if [[ -z "$flow_name" || -z "$status" || -z "$exit_code" || \
|
||||
-z "$started_at" || -z "$ended_at" || -z "$duration_ms" || \
|
||||
-z "$steps_file" ]]; then
|
||||
echo "report_execution_json: uso: report_execution_json <flow_name> <status> <exit_code> <started_at> <ended_at> <duration_ms> <steps_file>" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$steps_file" ]]; then
|
||||
echo "report_execution_json: archivo de pasos no encontrado: $steps_file" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
_report_execution_json_jq \
|
||||
"$flow_name" "$status" "$exit_code" \
|
||||
"$started_at" "$ended_at" "$duration_ms" "$steps_file"
|
||||
else
|
||||
_report_execution_json_printf \
|
||||
"$flow_name" "$status" "$exit_code" \
|
||||
"$started_at" "$ended_at" "$duration_ms" "$steps_file"
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Implementacion con jq ---
|
||||
|
||||
_report_execution_json_jq() {
|
||||
local flow_name="$1" status="$2" exit_code="$3"
|
||||
local started_at="$4" ended_at="$5" duration_ms="$6" steps_file="$7"
|
||||
|
||||
local steps_ok=0 steps_failed=0
|
||||
local steps_json="[]"
|
||||
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
|
||||
local s_name s_action s_status s_elapsed s_output s_error
|
||||
s_name=$(printf '%s' "$line" | cut -f1)
|
||||
s_action=$(printf '%s' "$line" | cut -f2)
|
||||
s_status=$(printf '%s' "$line" | cut -f3)
|
||||
s_elapsed=$(printf '%s' "$line" | cut -f4)
|
||||
s_output=$(printf '%s' "$line" | cut -f5)
|
||||
s_error=$(printf '%s' "$line" | cut -f6)
|
||||
|
||||
[[ -z "$s_name" ]] && continue
|
||||
|
||||
local step_obj
|
||||
step_obj=$(jq -n \
|
||||
--arg name "$s_name" \
|
||||
--arg action "$s_action" \
|
||||
--arg st "$s_status" \
|
||||
--argjson ms "${s_elapsed:-0}" \
|
||||
--arg output "$s_output" \
|
||||
--arg error "$s_error" \
|
||||
'{name: $name, action: $action, status: $st, elapsed_ms: $ms}
|
||||
+ (if $output != "" then {output: $output} else {} end)
|
||||
+ (if $error != "" then {error: $error} else {} end)')
|
||||
|
||||
steps_json=$(printf '%s' "$steps_json" | jq --argjson step "$step_obj" '. + [$step]')
|
||||
|
||||
if [[ "$s_status" == "ok" ]]; then
|
||||
((steps_ok++))
|
||||
else
|
||||
((steps_failed++))
|
||||
fi
|
||||
done < "$steps_file"
|
||||
|
||||
local steps_total=$(( steps_ok + steps_failed ))
|
||||
|
||||
jq -n \
|
||||
--arg name "$flow_name" \
|
||||
--arg st "$status" \
|
||||
--argjson exit_code "$exit_code" \
|
||||
--arg started_at "$started_at" \
|
||||
--arg ended_at "$ended_at" \
|
||||
--argjson duration_ms "$duration_ms" \
|
||||
--argjson total "$steps_total" \
|
||||
--argjson ok "$steps_ok" \
|
||||
--argjson failed "$steps_failed" \
|
||||
--argjson steps "$steps_json" \
|
||||
'{
|
||||
name: $name,
|
||||
status: $st,
|
||||
exit_code: $exit_code,
|
||||
started_at: $started_at,
|
||||
ended_at: $ended_at,
|
||||
duration_ms: $duration_ms,
|
||||
steps_total: $total,
|
||||
steps_ok: $ok,
|
||||
steps_failed: $failed,
|
||||
steps: $steps
|
||||
}'
|
||||
}
|
||||
|
||||
# --- Implementacion con printf (fallback sin jq) ---
|
||||
|
||||
_json_escape_rj() {
|
||||
local s="$1"
|
||||
s="${s//\\/\\\\}"
|
||||
s="${s//\"/\\\"}"
|
||||
s="${s//$'\n'/\\n}"
|
||||
s="${s//$'\r'/\\r}"
|
||||
s="${s//$'\t'/\\t}"
|
||||
printf '%s' "$s"
|
||||
}
|
||||
|
||||
_report_execution_json_printf() {
|
||||
local flow_name="$1" status="$2" exit_code="$3"
|
||||
local started_at="$4" ended_at="$5" duration_ms="$6" steps_file="$7"
|
||||
|
||||
local steps_ok=0 steps_failed=0
|
||||
local steps_parts=()
|
||||
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
|
||||
local s_name s_action s_status s_elapsed s_output s_error
|
||||
s_name=$(printf '%s' "$line" | cut -f1)
|
||||
s_action=$(printf '%s' "$line" | cut -f2)
|
||||
s_status=$(printf '%s' "$line" | cut -f3)
|
||||
s_elapsed=$(printf '%s' "$line" | cut -f4)
|
||||
s_output=$(printf '%s' "$line" | cut -f5)
|
||||
s_error=$(printf '%s' "$line" | cut -f6)
|
||||
|
||||
[[ -z "$s_name" ]] && continue
|
||||
|
||||
local part
|
||||
part=$(printf '{"name":"%s","action":"%s","status":"%s","elapsed_ms":%s' \
|
||||
"$(_json_escape_rj "$s_name")" \
|
||||
"$(_json_escape_rj "$s_action")" \
|
||||
"$(_json_escape_rj "$s_status")" \
|
||||
"${s_elapsed:-0}")
|
||||
|
||||
if [[ -n "$s_output" ]]; then
|
||||
part+=",\"output\":\"$(_json_escape_rj "$s_output")\""
|
||||
fi
|
||||
if [[ -n "$s_error" ]]; then
|
||||
part+=",\"error\":\"$(_json_escape_rj "$s_error")\""
|
||||
fi
|
||||
part+="}"
|
||||
|
||||
steps_parts+=("$part")
|
||||
|
||||
if [[ "$s_status" == "ok" ]]; then
|
||||
((steps_ok++))
|
||||
else
|
||||
((steps_failed++))
|
||||
fi
|
||||
done < "$steps_file"
|
||||
|
||||
local steps_total=$(( steps_ok + steps_failed ))
|
||||
|
||||
local steps_array="["
|
||||
local first=1
|
||||
for part in "${steps_parts[@]}"; do
|
||||
if [[ $first -eq 1 ]]; then
|
||||
steps_array+="$part"
|
||||
first=0
|
||||
else
|
||||
steps_array+=",$part"
|
||||
fi
|
||||
done
|
||||
steps_array+="]"
|
||||
|
||||
printf '{"name":"%s","status":"%s","exit_code":%s,"started_at":"%s","ended_at":"%s","duration_ms":%s,"steps_total":%s,"steps_ok":%s,"steps_failed":%s,"steps":%s}\n' \
|
||||
"$(_json_escape_rj "$flow_name")" \
|
||||
"$(_json_escape_rj "$status")" \
|
||||
"$exit_code" \
|
||||
"$(_json_escape_rj "$started_at")" \
|
||||
"$(_json_escape_rj "$ended_at")" \
|
||||
"$duration_ms" \
|
||||
"$steps_total" \
|
||||
"$steps_ok" \
|
||||
"$steps_failed" \
|
||||
"$steps_array"
|
||||
}
|
||||
|
||||
# Permitir ejecucion directa (no solo sourced)
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
report_execution_json "$@"
|
||||
fi
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: run_steps
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: shell
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "run_steps(yaml_file: string, [--strict]) -> string"
|
||||
description: "Ejecuta pasos de un YAML generico donde cada step tiene action=command. Lee el YAML con yq, ejecuta cada paso secuencialmente con timeout configurable, captura exit code y output, respeta continue_on_error, y al final reporta JSON estandar a stdout via report_execution_json. Sale con exit code 0 (success), 1 (failure) o 2 (partial). Con --strict mapea partial a failure."
|
||||
tags: [execution, yaml, runner, standard, pipeline]
|
||||
uses_functions: [exit_with_status_bash_shell, report_execution_json_bash_shell]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/shell/run_steps.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```yaml
|
||||
# /tmp/test_flow.yaml
|
||||
name: test_flow
|
||||
steps:
|
||||
- name: check
|
||||
action: command
|
||||
command: "echo hello"
|
||||
- name: list
|
||||
action: command
|
||||
command: "ls /tmp"
|
||||
continue_on_error: true
|
||||
- name: slow_step
|
||||
action: command
|
||||
command: "sleep 2"
|
||||
timeout_ms: 1000
|
||||
continue_on_error: true
|
||||
```
|
||||
|
||||
```bash
|
||||
source bash/functions/shell/run_steps.sh
|
||||
|
||||
run_steps /tmp/test_flow.yaml
|
||||
# Imprime JSON a stdout:
|
||||
# {
|
||||
# "name": "test_flow",
|
||||
# "status": "partial",
|
||||
# "exit_code": 2,
|
||||
# "started_at": "2026-04-01T10:00:00Z",
|
||||
# "ended_at": "2026-04-01T10:00:01Z",
|
||||
# "duration_ms": 1250,
|
||||
# "steps_total": 3,
|
||||
# "steps_ok": 2,
|
||||
# "steps_failed": 1,
|
||||
# "steps": [
|
||||
# {"name": "check", "action": "command", "status": "ok", "elapsed_ms": 10, "output": "hello"},
|
||||
# {"name": "list", "action": "command", "status": "ok", "elapsed_ms": 15},
|
||||
# {"name": "slow_step", "action": "command", "status": "error", "elapsed_ms": 1001, "error": "timeout: comando excedio 1000ms"}
|
||||
# ]
|
||||
# }
|
||||
# Sale con exit code 2 (partial)
|
||||
|
||||
run_steps /tmp/test_flow.yaml --strict
|
||||
# Igual pero status="failure" y exit code=1
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Requiere `yq` (v4+, modo expression `-e`) y `jq` (o la implementacion printf de report_execution_json). Solo soporta `action: command` — otros actions se marcan como error del paso. El campo `timeout_ms` por defecto es 30000 (30s); si el comando excede el timeout, `timeout(1)` sale con exit code 124 y se registra el error correspondiente. El output del comando (stdout+stderr combinados) se sanitiza reemplazando tabuladores y saltos de linea por espacios antes de escribir al TSV temporal. Las funciones `exit_with_status` y `report_execution_json` se cargan via `source` desde el mismo directorio que `run_steps.sh`.
|
||||
@@ -0,0 +1,201 @@
|
||||
#!/usr/bin/env bash
|
||||
# run_steps — ejecuta pasos de un YAML generico (action=command)
|
||||
|
||||
# run_steps <yaml_file> [--strict]
|
||||
#
|
||||
# Lee un YAML con la estructura:
|
||||
# name: <run_name>
|
||||
# steps:
|
||||
# - name: <step_name>
|
||||
# action: command
|
||||
# command: "<shell_command>"
|
||||
# continue_on_error: true|false # opcional, default false
|
||||
# timeout_ms: 30000 # opcional, default 30000
|
||||
#
|
||||
# Para cada paso de action=command:
|
||||
# - Ejecuta el command con timeout (timeout_ms ms)
|
||||
# - Captura exit code, stdout+stderr y elapsed time
|
||||
# - Si falla y continue_on_error=false → aborta
|
||||
# - Acumula resultados en TSV temporal
|
||||
#
|
||||
# Al final:
|
||||
# - Llama a report_execution_json para generar JSON a stdout
|
||||
# - Llama a exit_with_status para determinar el exit code
|
||||
#
|
||||
# --strict: mapea partial (2) a failure (1) en status y exit code
|
||||
#
|
||||
# Requiere: yq, jq (jq opcional si report_execution_json tiene fallback printf)
|
||||
run_steps() {
|
||||
local yaml_file="$1"
|
||||
local strict=0
|
||||
|
||||
if [[ "${2:-}" == "--strict" ]]; then
|
||||
strict=1
|
||||
fi
|
||||
|
||||
# --- validaciones previas ---
|
||||
if [[ -z "$yaml_file" ]]; then
|
||||
echo "run_steps: yaml_file requerido" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$yaml_file" ]]; then
|
||||
echo "run_steps: archivo '$yaml_file' no existe" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! command -v yq &>/dev/null; then
|
||||
echo "run_steps: yq no encontrado en PATH" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# --- cargar funciones del estandar ---
|
||||
local script_dir
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=bash/functions/shell/exit_with_status.sh
|
||||
source "$script_dir/exit_with_status.sh"
|
||||
# shellcheck source=bash/functions/shell/report_execution_json.sh
|
||||
source "$script_dir/report_execution_json.sh"
|
||||
|
||||
# --- leer nombre del run ---
|
||||
local run_name
|
||||
run_name=$(yq e '.name // "unnamed"' "$yaml_file")
|
||||
|
||||
# --- contar pasos ---
|
||||
local step_count
|
||||
step_count=$(yq e '.steps | length' "$yaml_file")
|
||||
|
||||
if [[ -z "$step_count" || "$step_count" -eq 0 ]]; then
|
||||
echo "run_steps: el YAML no tiene steps" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# --- archivo TSV temporal para acumular resultados ---
|
||||
# columnas: name<TAB>action<TAB>status<TAB>elapsed_ms<TAB>output<TAB>error
|
||||
local tsv_file
|
||||
tsv_file=$(mktemp /tmp/run_steps_XXXXXX.tsv)
|
||||
# shellcheck disable=SC2064
|
||||
trap "rm -f '$tsv_file'" EXIT
|
||||
|
||||
local ok_steps=0
|
||||
local failed_steps=0
|
||||
local started_at
|
||||
started_at=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
local t_run_start
|
||||
t_run_start=$(date +%s%3N)
|
||||
|
||||
# --- iterar sobre cada paso ---
|
||||
local i
|
||||
for (( i=0; i<step_count; i++ )); do
|
||||
local step_name step_action step_command step_continue step_timeout_ms
|
||||
|
||||
step_name=$(yq e ".steps[$i].name // \"step_$i\"" "$yaml_file")
|
||||
step_action=$(yq e ".steps[$i].action // \"command\"" "$yaml_file")
|
||||
step_command=$(yq e ".steps[$i].command // \"\"" "$yaml_file")
|
||||
step_continue=$(yq e ".steps[$i].continue_on_error // false" "$yaml_file")
|
||||
step_timeout_ms=$(yq e ".steps[$i].timeout_ms // 30000" "$yaml_file")
|
||||
|
||||
# convertir timeout de ms a segundos enteros (timeout(1) usa segundos)
|
||||
local timeout_sec
|
||||
timeout_sec=$(( (step_timeout_ms + 999) / 1000 ))
|
||||
|
||||
# solo soportamos action=command
|
||||
if [[ "$step_action" != "command" ]]; then
|
||||
local err_msg="action '$step_action' no soportada (solo command)"
|
||||
printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
|
||||
"$step_name" "$step_action" "error" "0" "" "$err_msg" >> "$tsv_file"
|
||||
failed_steps=$((failed_steps + 1))
|
||||
|
||||
if [[ "$step_continue" != "true" ]]; then
|
||||
echo "run_steps: paso '$step_name' — $err_msg — abortando" >&2
|
||||
break
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ -z "$step_command" ]]; then
|
||||
local err_msg="campo 'command' vacio en paso '$step_name'"
|
||||
printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
|
||||
"$step_name" "command" "error" "0" "" "$err_msg" >> "$tsv_file"
|
||||
failed_steps=$((failed_steps + 1))
|
||||
|
||||
if [[ "$step_continue" != "true" ]]; then
|
||||
echo "run_steps: $err_msg — abortando" >&2
|
||||
break
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
|
||||
# ejecutar con timeout y capturar output + tiempos
|
||||
local t_start t_end elapsed_ms step_output step_exit
|
||||
|
||||
t_start=$(date +%s%3N)
|
||||
step_output=$(timeout "$timeout_sec" bash -c "$step_command" 2>&1)
|
||||
step_exit=$?
|
||||
t_end=$(date +%s%3N)
|
||||
elapsed_ms=$(( t_end - t_start ))
|
||||
|
||||
# timeout(1) sale con 124 cuando agota el tiempo
|
||||
local step_error=""
|
||||
if [[ "$step_exit" -eq 124 ]]; then
|
||||
step_error="timeout: comando excedio ${step_timeout_ms}ms"
|
||||
fi
|
||||
|
||||
# escapar tabuladores y saltos de linea en el output para el TSV
|
||||
local safe_output
|
||||
safe_output=$(printf '%s' "$step_output" | tr '\t' ' ' | tr '\n' ' ')
|
||||
|
||||
if [[ "$step_exit" -eq 0 ]]; then
|
||||
printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
|
||||
"$step_name" "command" "ok" "$elapsed_ms" "$safe_output" "" >> "$tsv_file"
|
||||
ok_steps=$((ok_steps + 1))
|
||||
else
|
||||
printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
|
||||
"$step_name" "command" "error" "$elapsed_ms" "$safe_output" "$step_error" >> "$tsv_file"
|
||||
failed_steps=$((failed_steps + 1))
|
||||
|
||||
if [[ "$step_continue" != "true" ]]; then
|
||||
echo "run_steps: paso '$step_name' fallo (exit $step_exit) — abortando" >&2
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
local t_run_end
|
||||
t_run_end=$(date +%s%3N)
|
||||
local total_duration_ms=$(( t_run_end - t_run_start ))
|
||||
local ended_at
|
||||
ended_at=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
local total_steps=$(( ok_steps + failed_steps ))
|
||||
|
||||
# --- calcular status y exit code ---
|
||||
local status_code
|
||||
status_code=$(exit_with_status "$total_steps" "$ok_steps" "$failed_steps")
|
||||
|
||||
local run_status
|
||||
case "$status_code" in
|
||||
0) run_status="success" ;;
|
||||
1) run_status="failure" ;;
|
||||
2)
|
||||
if [[ "$strict" -eq 1 ]]; then
|
||||
run_status="failure"
|
||||
status_code=1
|
||||
else
|
||||
run_status="partial"
|
||||
fi
|
||||
;;
|
||||
*) run_status="failure"; status_code=1 ;;
|
||||
esac
|
||||
|
||||
# --- generar JSON a stdout ---
|
||||
report_execution_json \
|
||||
"$run_name" \
|
||||
"$run_status" \
|
||||
"$status_code" \
|
||||
"$started_at" \
|
||||
"$ended_at" \
|
||||
"$total_duration_ms" \
|
||||
"$tsv_file"
|
||||
|
||||
return "$status_code"
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"text/tabwriter"
|
||||
)
|
||||
|
||||
func cmdAnalysis(args []string) {
|
||||
if len(args) < 1 {
|
||||
printAnalysisUsage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "list":
|
||||
analysisList()
|
||||
case "clone":
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: fn analysis clone <id>")
|
||||
os.Exit(1)
|
||||
}
|
||||
analysisClone(args[1])
|
||||
case "pull":
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: fn analysis pull <id>")
|
||||
os.Exit(1)
|
||||
}
|
||||
analysisPull(args[1])
|
||||
case "help", "-h":
|
||||
printAnalysisUsage()
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown analysis command: %s\n", args[0])
|
||||
printAnalysisUsage()
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func printAnalysisUsage() {
|
||||
fmt.Println(`fn analysis — manage registry analyses
|
||||
|
||||
Usage:
|
||||
fn analysis list List all analyses (local + remote)
|
||||
fn analysis clone <id> Clone analysis repo to analysis/<name>/
|
||||
fn analysis pull <id> Git pull in existing clone`)
|
||||
}
|
||||
|
||||
func analysisList() {
|
||||
db := openDB()
|
||||
defer db.Close()
|
||||
|
||||
items, err := db.ListAllAnalysis()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
fmt.Println("No analyses registered.")
|
||||
return
|
||||
}
|
||||
|
||||
r := root()
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "ID\tLANG\tDOMAIN\tSTATUS\tREPO_URL")
|
||||
for _, a := range items {
|
||||
status := "local"
|
||||
if a.RepoURL != "" {
|
||||
dirPath := filepath.Join(r, "analysis", a.Name)
|
||||
if _, err := os.Stat(dirPath); os.IsNotExist(err) {
|
||||
status = "remote"
|
||||
} else {
|
||||
status = "cloned"
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", a.ID, a.Lang, a.Domain, status, a.RepoURL)
|
||||
}
|
||||
w.Flush()
|
||||
}
|
||||
|
||||
func analysisClone(id string) {
|
||||
db := openDB()
|
||||
defer db.Close()
|
||||
|
||||
a, err := db.GetAnalysis(id)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if a.RepoURL == "" {
|
||||
fmt.Fprintf(os.Stderr, "analysis %s has no repo_url set\n", id)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
r := root()
|
||||
target := filepath.Join(r, "analysis", a.Name)
|
||||
if _, err := os.Stat(target); err == nil {
|
||||
fmt.Fprintf(os.Stderr, "directory already exists: %s\n", target)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Cloning %s → %s\n", a.RepoURL, target)
|
||||
cmd := exec.Command("git", "clone", a.RepoURL, target)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "git clone failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("Done. Run 'fn index' to re-index.")
|
||||
}
|
||||
|
||||
func analysisPull(id string) {
|
||||
db := openDB()
|
||||
defer db.Close()
|
||||
|
||||
a, err := db.GetAnalysis(id)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
r := root()
|
||||
dir := filepath.Join(r, "analysis", a.Name)
|
||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||
fmt.Fprintf(os.Stderr, "analysis not cloned locally: %s\n", dir)
|
||||
fmt.Fprintln(os.Stderr, "Run 'fn analysis clone "+id+"' first.")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Pulling %s\n", dir)
|
||||
cmd := exec.Command("git", "-C", dir, "pull")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "git pull failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("Done. Run 'fn index' to re-index.")
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
)
|
||||
|
||||
func cmdApp(args []string) {
|
||||
if len(args) < 1 {
|
||||
printAppUsage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "list":
|
||||
appList()
|
||||
case "clone":
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: fn app clone <id>")
|
||||
os.Exit(1)
|
||||
}
|
||||
appClone(args[1])
|
||||
case "pull":
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: fn app pull <id>")
|
||||
os.Exit(1)
|
||||
}
|
||||
appPull(args[1])
|
||||
case "help", "-h":
|
||||
printAppUsage()
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown app command: %s\n", args[0])
|
||||
printAppUsage()
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func printAppUsage() {
|
||||
fmt.Println(`fn app — manage registry apps
|
||||
|
||||
Usage:
|
||||
fn app list List all apps (local + remote)
|
||||
fn app clone <id> Clone app repo to apps/<name>/
|
||||
fn app pull <id> Git pull in existing clone`)
|
||||
}
|
||||
|
||||
func appList() {
|
||||
db := openDB()
|
||||
defer db.Close()
|
||||
|
||||
apps, err := db.ListAllApps()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if len(apps) == 0 {
|
||||
fmt.Println("No apps registered.")
|
||||
return
|
||||
}
|
||||
|
||||
r := root()
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "ID\tLANG\tDOMAIN\tSTATUS\tREPO_URL")
|
||||
for _, a := range apps {
|
||||
status := "local"
|
||||
if a.RepoURL != "" {
|
||||
dirPath := filepath.Join(r, "apps", a.Name)
|
||||
if _, err := os.Stat(dirPath); os.IsNotExist(err) {
|
||||
status = "remote"
|
||||
} else {
|
||||
status = "cloned"
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", a.ID, a.Lang, a.Domain, status, a.RepoURL)
|
||||
}
|
||||
w.Flush()
|
||||
}
|
||||
|
||||
func appClone(id string) {
|
||||
db := openDB()
|
||||
defer db.Close()
|
||||
|
||||
a, err := db.GetApp(id)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if a.RepoURL == "" {
|
||||
fmt.Fprintf(os.Stderr, "app %s has no repo_url set\n", id)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
r := root()
|
||||
target := filepath.Join(r, "apps", a.Name)
|
||||
if _, err := os.Stat(target); err == nil {
|
||||
fmt.Fprintf(os.Stderr, "directory already exists: %s\n", target)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Cloning %s → %s\n", a.RepoURL, target)
|
||||
cmd := exec.Command("git", "clone", a.RepoURL, target)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "git clone failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("Done. Run 'fn index' to re-index.")
|
||||
}
|
||||
|
||||
func appPull(id string) {
|
||||
db := openDB()
|
||||
defer db.Close()
|
||||
|
||||
a, err := db.GetApp(id)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
r := root()
|
||||
dir := filepath.Join(r, "apps", a.Name)
|
||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||
fmt.Fprintf(os.Stderr, "app not cloned locally: %s\n", dir)
|
||||
fmt.Fprintln(os.Stderr, "Run 'fn app clone "+id+"' first.")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Pulling %s\n", dir)
|
||||
cmd := exec.Command("git", "-C", dir, "pull")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "git pull failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("Done. Run 'fn index' to re-index.")
|
||||
}
|
||||
|
||||
// formatRepoURL ensures the URL does not contain inline credentials for display.
|
||||
func formatRepoURL(url string) string {
|
||||
if strings.Contains(url, "@") && strings.Contains(url, "://") {
|
||||
// Mask credentials in URLs like https://user:pass@host/...
|
||||
parts := strings.SplitN(url, "://", 2)
|
||||
if len(parts) == 2 {
|
||||
atIdx := strings.LastIndex(parts[1], "@")
|
||||
if atIdx >= 0 {
|
||||
return parts[0] + "://" + parts[1][atIdx+1:]
|
||||
}
|
||||
}
|
||||
}
|
||||
return url
|
||||
}
|
||||
+84
-5
@@ -35,6 +35,10 @@ func main() {
|
||||
cmdProposal(os.Args[2:])
|
||||
case "run":
|
||||
cmdRun(os.Args[2:])
|
||||
case "app":
|
||||
cmdApp(os.Args[2:])
|
||||
case "analysis":
|
||||
cmdAnalysis(os.Args[2:])
|
||||
case "help", "-h", "--help":
|
||||
printUsage()
|
||||
default:
|
||||
@@ -55,7 +59,9 @@ Usage:
|
||||
fn add [-k kind] Abre $EDITOR con template
|
||||
fn run <id_or_name> [args...] Ejecuta funcion/pipeline (go/py/bash)
|
||||
fn ops <subcommand> Gestiona operations.db (fn ops help)
|
||||
fn proposal <add|list|show|update> Gestiona proposals`)
|
||||
fn proposal <add|list|show|update> Gestiona proposals
|
||||
fn app <list|clone|pull> Gestiona apps externas (Gitea)
|
||||
fn analysis <list|clone|pull> Gestiona analyses externas (Gitea)`)
|
||||
}
|
||||
|
||||
func root() string {
|
||||
@@ -117,7 +123,7 @@ func cmdIndex() {
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Indexed %d functions, %d types, %d apps\n", result.Functions, result.Types, result.Apps)
|
||||
fmt.Printf("Indexed %d functions, %d types, %d apps, %d analysis\n", result.Functions, result.Types, result.Apps, result.Analysis)
|
||||
for _, e := range result.ValidationErrors {
|
||||
fmt.Fprintf(os.Stderr, " INVALID: %s\n", e)
|
||||
}
|
||||
@@ -172,7 +178,13 @@ func cmdSearch(args []string) {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if len(fns) == 0 && len(types) == 0 && len(apps) == 0 {
|
||||
analyses, err := db.SearchAnalysis(query, lang, domain)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if len(fns) == 0 && len(types) == 0 && len(apps) == 0 && len(analyses) == 0 {
|
||||
fmt.Println("No results.")
|
||||
return
|
||||
}
|
||||
@@ -205,6 +217,16 @@ func cmdSearch(args []string) {
|
||||
fmt.Fprintf(w, "app\t%s\t%s\t%s\n", a.ID, a.Lang, desc)
|
||||
}
|
||||
}
|
||||
if len(analyses) > 0 {
|
||||
if len(fns) > 0 || len(types) > 0 || len(apps) > 0 {
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
fmt.Fprintln(w, "ANALYSIS\tID\tLANG\tDESCRIPTION")
|
||||
for _, a := range analyses {
|
||||
desc := truncate(a.Description, 60)
|
||||
fmt.Fprintf(w, "analysis\t%s\t%s\t%s\n", a.ID, a.Lang, desc)
|
||||
}
|
||||
}
|
||||
w.Flush()
|
||||
}
|
||||
|
||||
@@ -249,6 +271,12 @@ func cmdList(args []string) {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
analyses, err := db.SearchAnalysis("", lang, domain)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
if len(fns) > 0 {
|
||||
fmt.Fprintln(w, "KIND\tID\tPURITY\tVERSION\tDOMAIN")
|
||||
@@ -274,7 +302,16 @@ func cmdList(args []string) {
|
||||
fmt.Fprintf(w, "app\t%s\t%s\t%s\n", a.ID, a.Lang, a.Domain)
|
||||
}
|
||||
}
|
||||
if len(fns) == 0 && len(types) == 0 && len(apps) == 0 {
|
||||
if len(analyses) > 0 {
|
||||
if len(fns) > 0 || len(types) > 0 || len(apps) > 0 {
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
fmt.Fprintln(w, "ANALYSIS\tID\tLANG\tDOMAIN")
|
||||
for _, a := range analyses {
|
||||
fmt.Fprintf(w, "analysis\t%s\t%s\t%s\n", a.ID, a.Lang, a.Domain)
|
||||
}
|
||||
}
|
||||
if len(fns) == 0 && len(types) == 0 && len(apps) == 0 && len(analyses) == 0 {
|
||||
fmt.Println("Registry is empty. Run 'fn index' first.")
|
||||
}
|
||||
w.Flush()
|
||||
@@ -310,6 +347,12 @@ func cmdShow(args []string) {
|
||||
return
|
||||
}
|
||||
|
||||
an, errAn := db.GetAnalysis(id)
|
||||
if errAn == nil {
|
||||
printAnalysisEntry(an)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "not found: %s\n", id)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -412,6 +455,40 @@ func printApp(a *registry.App) {
|
||||
fmt.Printf("Description: %s\n", a.Description)
|
||||
fmt.Printf("Tags: %s\n", strings.Join(a.Tags, ", "))
|
||||
fmt.Printf("Dir: %s\n", a.DirPath)
|
||||
if a.RepoURL != "" {
|
||||
fmt.Printf("Repo URL: %s\n", a.RepoURL)
|
||||
}
|
||||
if a.Framework != "" {
|
||||
fmt.Printf("Framework: %s\n", a.Framework)
|
||||
}
|
||||
if a.EntryPoint != "" {
|
||||
fmt.Printf("Entry point: %s\n", a.EntryPoint)
|
||||
}
|
||||
if len(a.UsesFunctions) > 0 {
|
||||
fmt.Printf("Uses fns: %s\n", strings.Join(a.UsesFunctions, ", "))
|
||||
}
|
||||
if len(a.UsesTypes) > 0 {
|
||||
fmt.Printf("Uses types: %s\n", strings.Join(a.UsesTypes, ", "))
|
||||
}
|
||||
if a.Notes != "" {
|
||||
fmt.Printf("\nNotes:\n%s\n", a.Notes)
|
||||
}
|
||||
if a.Documentation != "" {
|
||||
fmt.Printf("\nDocumentation:\n%s\n", a.Documentation)
|
||||
}
|
||||
}
|
||||
|
||||
func printAnalysisEntry(a *registry.Analysis) {
|
||||
fmt.Printf("ID: %s\n", a.ID)
|
||||
fmt.Printf("Name: %s\n", a.Name)
|
||||
fmt.Printf("Lang: %s\n", a.Lang)
|
||||
fmt.Printf("Domain: %s\n", a.Domain)
|
||||
fmt.Printf("Description: %s\n", a.Description)
|
||||
fmt.Printf("Tags: %s\n", strings.Join(a.Tags, ", "))
|
||||
fmt.Printf("Dir: %s\n", a.DirPath)
|
||||
if a.RepoURL != "" {
|
||||
fmt.Printf("Repo URL: %s\n", a.RepoURL)
|
||||
}
|
||||
if a.Framework != "" {
|
||||
fmt.Printf("Framework: %s\n", a.Framework)
|
||||
}
|
||||
@@ -459,8 +536,10 @@ func cmdAdd(args []string) {
|
||||
templatePath = filepath.Join(r, "docs", "templates", "component.md")
|
||||
case "app":
|
||||
templatePath = filepath.Join(r, "docs", "templates", "app.md")
|
||||
case "analysis":
|
||||
templatePath = filepath.Join(r, "docs", "templates", "analysis.md")
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown kind: %s (use function, pipeline, component, or app)\n", kind)
|
||||
fmt.Fprintf(os.Stderr, "unknown kind: %s (use function, pipeline, component, app, or analysis)\n", kind)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
# Estandar de Ejecucion: YAML + Exit Codes + Hooks
|
||||
|
||||
Contrato comun para apps, pipelines y automatizaciones del fn-registry. Define como declarar flujos en YAML, que exit codes devolver, como reportar resultados y como integrarse con Dagu y operations.db.
|
||||
|
||||
---
|
||||
|
||||
## Motivacion
|
||||
|
||||
Tenemos dos patrones que ya funcionan:
|
||||
|
||||
- **Dagu**: orquesta DAGs con `command`/`script`, usa exit codes para decidir flujo, tiene handlers globales y scheduling cron.
|
||||
- **script_navegador**: ejecuta pasos tipados (CDP actions) desde YAML, registra todo en operations.db, sale con 0/1.
|
||||
|
||||
Ambos comparten la misma idea: **YAML declara el flujo, exit code señala el resultado, logs estructurados permiten analisis posterior**. Este documento formaliza ese contrato para que cualquier app nueva lo siga desde el inicio.
|
||||
|
||||
---
|
||||
|
||||
## 1. Estructura YAML
|
||||
|
||||
Toda app que ejecute un flujo debe leer un YAML con esta estructura minima:
|
||||
|
||||
```yaml
|
||||
# Cabecera obligatoria
|
||||
name: nombre_del_flujo
|
||||
description: "Que hace este flujo" # opcional pero recomendado
|
||||
|
||||
# Variables de entorno disponibles para todos los pasos
|
||||
env: # opcional
|
||||
KEY: value
|
||||
|
||||
# Pasos del flujo
|
||||
steps:
|
||||
- name: paso_1 # identificador unico dentro del flujo
|
||||
action: tipo_de_accion # dispatch key (navigate, command, query, etc.)
|
||||
# ... parametros especificos del action
|
||||
continue_on_error: false # default: false
|
||||
timeout_ms: 30000 # default: 30s, 0 = sin timeout
|
||||
depends: [] # IDs de pasos previos (vacio = secuencial)
|
||||
|
||||
# Hooks opcionales
|
||||
hooks:
|
||||
on_success: "comando o script" # se ejecuta si exit code = 0
|
||||
on_failure: "comando o script" # se ejecuta si exit code = 1
|
||||
on_partial: "comando o script" # se ejecuta si exit code = 2
|
||||
```
|
||||
|
||||
### Campos de la cabecera
|
||||
|
||||
| Campo | Requerido | Descripcion |
|
||||
|-------|-----------|-------------|
|
||||
| `name` | si | Identificador del flujo (snake_case) |
|
||||
| `description` | no | Descripcion legible |
|
||||
| `env` | no | Variables de entorno (mapa key-value) |
|
||||
| `steps` | si | Lista de pasos a ejecutar |
|
||||
| `hooks` | no | Comandos a ejecutar segun resultado |
|
||||
|
||||
### Campos de cada step
|
||||
|
||||
| Campo | Requerido | Default | Descripcion |
|
||||
|-------|-----------|---------|-------------|
|
||||
| `name` | si | — | ID unico del paso dentro del flujo |
|
||||
| `action` | si | — | Tipo de accion (cada app define sus propios actions) |
|
||||
| `continue_on_error` | no | `false` | Si `true`, un fallo no detiene el flujo |
|
||||
| `timeout_ms` | no | `30000` | Timeout del paso en ms (0 = sin timeout) |
|
||||
| `depends` | no | `[]` | Pasos que deben completarse antes |
|
||||
|
||||
Los campos adicionales dependen del `action` y los define cada app/dominio.
|
||||
|
||||
### Actions por dominio
|
||||
|
||||
Cada app registra sus actions validos. Ejemplos:
|
||||
|
||||
| Dominio | Actions | App de referencia |
|
||||
|---------|---------|-------------------|
|
||||
| navegacion | `navigate`, `click`, `type`, `wait`, `screenshot`, `evaluate`, `get_html`, `wait_load`, `sleep` | script_navegador |
|
||||
| shell | `command`, `script` | Dagu / apps genericas |
|
||||
| database | `query`, `migrate`, `backup` | futuras apps |
|
||||
| http | `request`, `assert_status`, `extract` | futuras apps |
|
||||
|
||||
---
|
||||
|
||||
## 2. Exit Codes
|
||||
|
||||
Toda app/script que siga este estandar DEBE salir con uno de estos tres codigos:
|
||||
|
||||
| Codigo | Constante | Significado |
|
||||
|--------|-----------|-------------|
|
||||
| `0` | `EXIT_SUCCESS` | Todos los pasos completaron sin error |
|
||||
| `1` | `EXIT_FAILURE` | Al menos un paso fallo y el flujo aborto |
|
||||
| `2` | `EXIT_PARTIAL` | Algunos pasos fallaron pero el flujo continuo (continue_on_error) |
|
||||
|
||||
### Reglas
|
||||
|
||||
- Si todos los pasos son exitosos → `0`
|
||||
- Si algun paso falla y tiene `continue_on_error: false` → `1` (aborta)
|
||||
- Si algun paso falla pero todos los fallos tenian `continue_on_error: true` → `2`
|
||||
- Un timeout agotado cuenta como fallo del paso
|
||||
|
||||
### Compatibilidad con Dagu
|
||||
|
||||
Dagu interpreta exit code != 0 como fallo. Para que `2` (partial) no dispare el handler de failure en Dagu, el DAG que llama a la app puede usar:
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
- name: run_app
|
||||
command: ./mi_app --script flujo.yaml
|
||||
continue_on:
|
||||
failure: true # no abortar el DAG por parcial
|
||||
- name: check_result
|
||||
command: test $? -le 1 # falla solo si fue error fatal
|
||||
depends: [run_app]
|
||||
```
|
||||
|
||||
O alternativamente, la app puede configurarse con `--strict` para mapear partial → failure (exit 1).
|
||||
|
||||
---
|
||||
|
||||
## 3. Output Estructurado
|
||||
|
||||
Toda app DEBE escribir un **resumen JSON en stdout** al finalizar (despues de su output humano). El formato:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "nombre_del_flujo",
|
||||
"status": "success|failure|partial",
|
||||
"exit_code": 0,
|
||||
"started_at": "2026-04-01T10:00:00Z",
|
||||
"ended_at": "2026-04-01T10:00:05Z",
|
||||
"duration_ms": 5000,
|
||||
"steps_total": 5,
|
||||
"steps_ok": 4,
|
||||
"steps_failed": 1,
|
||||
"steps": [
|
||||
{
|
||||
"name": "paso_1",
|
||||
"action": "navigate",
|
||||
"status": "ok",
|
||||
"elapsed_ms": 1200,
|
||||
"output": "optional output"
|
||||
},
|
||||
{
|
||||
"name": "paso_2",
|
||||
"action": "click",
|
||||
"status": "error",
|
||||
"elapsed_ms": 500,
|
||||
"error": "elemento no encontrado: #btn-submit"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Separacion humano / maquina
|
||||
|
||||
- **stderr**: logs legibles para humanos (progreso, warnings)
|
||||
- **stdout**: output JSON estructurado al final (para Dagu, hooks, pipes)
|
||||
- Flag `--json` o `--quiet`: suprime output humano, solo JSON en stdout
|
||||
|
||||
Esto permite que Dagu capture el JSON:
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
- name: navegacion
|
||||
command: ./script_navegador --script busqueda.yaml --json
|
||||
output: RESULT
|
||||
- name: analizar
|
||||
command: echo "$RESULT" | jq '.steps[] | select(.status=="error")'
|
||||
depends: [navegacion]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Hooks
|
||||
|
||||
Los hooks se ejecutan **despues** de todos los pasos, segun el exit code resultante.
|
||||
|
||||
### En el YAML del flujo
|
||||
|
||||
```yaml
|
||||
hooks:
|
||||
on_success: "notify-send 'Flujo completado'"
|
||||
on_failure: "echo 'FALLO: {{.Name}}' >> /tmp/failures.log"
|
||||
on_partial: "echo 'PARCIAL: {{.StepsFailed}} pasos fallaron' >> /tmp/warnings.log"
|
||||
on_step_error: "echo 'Step {{.StepName}} fallo: {{.Error}}'" # se ejecuta por cada paso fallido
|
||||
```
|
||||
|
||||
### Variables disponibles en hooks
|
||||
|
||||
| Variable | Tipo | Descripcion |
|
||||
|----------|------|-------------|
|
||||
| `{{.Name}}` | string | Nombre del flujo |
|
||||
| `{{.Status}}` | string | success, failure, partial |
|
||||
| `{{.ExitCode}}` | int | 0, 1, 2 |
|
||||
| `{{.DurationMs}}` | int | Duracion total en ms |
|
||||
| `{{.StepsTotal}}` | int | Total de pasos |
|
||||
| `{{.StepsOk}}` | int | Pasos exitosos |
|
||||
| `{{.StepsFailed}}` | int | Pasos fallidos |
|
||||
| `{{.StepName}}` | string | Nombre del paso (solo en on_step_error) |
|
||||
| `{{.Error}}` | string | Mensaje de error (solo en on_step_error/on_failure) |
|
||||
|
||||
### En Dagu (handlers globales)
|
||||
|
||||
Los hooks del YAML son locales al flujo. Para orquestacion, Dagu maneja sus propios handlers:
|
||||
|
||||
```yaml
|
||||
# dags/mi_automatizacion.yaml
|
||||
name: mi_automatizacion
|
||||
handlers:
|
||||
failure:
|
||||
- name: alerta
|
||||
command: echo "Fallo en mi_automatizacion" >> ~/dagu/logs/failures.log
|
||||
success:
|
||||
- name: registrar
|
||||
command: echo "OK $(date)" >> ~/dagu/logs/success.log
|
||||
steps:
|
||||
- name: ejecutar
|
||||
command: ./fn run mi_pipeline --script flujo.yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Integracion con operations.db
|
||||
|
||||
Las apps que siguen el bucle reactivo DEBEN registrar la ejecucion en operations.db:
|
||||
|
||||
### Mapping del estandar a operations.db
|
||||
|
||||
| Concepto estandar | Tabla operations.db | Campo |
|
||||
|-------------------|---------------------|-------|
|
||||
| Flujo completo | `executions` | `status`: success/failure/partial |
|
||||
| Cada paso | `logs` | `level`: info/error, `message`: resultado |
|
||||
| Exit code | `executions.metrics` | `{"exit_code": N}` |
|
||||
| Output JSON | `executions.metrics` | `{"steps": [...]}` |
|
||||
| Duracion | `executions.duration_ms` | milisegundos |
|
||||
| Pasos in/out | `executions.records_in/out` | total/exitosos |
|
||||
|
||||
### Patron de registro (referencia: script_navegador/ops.go)
|
||||
|
||||
```
|
||||
1. initOpsDB() → abrir/crear operations.db
|
||||
2. EnsureEntities() → registrar recursos involucrados
|
||||
3. EnsureRelations() → registrar como se conectan
|
||||
4. [ejecutar flujo]
|
||||
5. RecordRun() → insertar execution con metricas
|
||||
6. LogStep() por paso → insertar log por cada paso
|
||||
7. UpdateRelation() → status final de la relation
|
||||
```
|
||||
|
||||
Este patron ya esta implementado en `script_navegador/ops.go` y sirve como referencia para nuevas apps.
|
||||
|
||||
---
|
||||
|
||||
## 6. Flujo completo: App → Dagu → Hooks
|
||||
|
||||
```
|
||||
Dagu (scheduler)
|
||||
│
|
||||
cron / manual
|
||||
│
|
||||
┌────▼────┐
|
||||
│ DAG │ dags/mi_dag.yaml
|
||||
│ step │ command: ./mi_app --script flujo.yaml --json
|
||||
└────┬────┘
|
||||
│
|
||||
┌────▼────────────────┐
|
||||
│ App (mi_app) │
|
||||
│ │
|
||||
│ 1. Load YAML │
|
||||
│ 2. Validate steps │
|
||||
│ 3. Init ops.db │
|
||||
│ 4. Execute steps │
|
||||
│ 5. Run hooks │
|
||||
│ 6. Record to ops │
|
||||
│ 7. JSON → stdout │
|
||||
│ 8. Exit code │
|
||||
└────┬────────────────┘
|
||||
│
|
||||
┌──────────┼──────────┐
|
||||
│ │ │
|
||||
exit 0 exit 1 exit 2
|
||||
success failure partial
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
Dagu OK Dagu FAIL Dagu FAIL*
|
||||
handler handler (configurable)
|
||||
```
|
||||
|
||||
*Con `continue_on: failure: true` en el step de Dagu, exit 2 no aborta el DAG.
|
||||
|
||||
---
|
||||
|
||||
## 7. Implementacion: funciones del registry
|
||||
|
||||
### Existentes (ya en uso por script_navegador)
|
||||
|
||||
Todas las funciones CDP del registry (`cdp_*_go_browser`, `chrome_launch_go_browser`) ya siguen el patron de retornar error. El runner de script_navegador las compone.
|
||||
|
||||
### Nuevas (por construir)
|
||||
|
||||
| Funcion | Lang | Domain | Descripcion |
|
||||
|---------|------|--------|-------------|
|
||||
| `run_steps` | bash | shell | Ejecuta pasos de un YAML generico (action=command/script), reporta JSON y sale con 0/1/2 |
|
||||
| `report_execution_json` | bash | shell | Genera el JSON de salida estandar dado los resultados de pasos |
|
||||
| `exit_with_status` | bash | shell | Calcula exit code (0/1/2) a partir de contadores ok/fail/partial |
|
||||
|
||||
Estas funciones Bash permiten que cualquier script nuevo siga el estandar sin reimplementar la logica de reporte y exit codes.
|
||||
|
||||
---
|
||||
|
||||
## 8. Ejemplo completo
|
||||
|
||||
### Script YAML (flujo.yaml)
|
||||
|
||||
```yaml
|
||||
name: backup_db
|
||||
description: "Backup de operations.db y push a remoto"
|
||||
steps:
|
||||
- name: check_db
|
||||
action: command
|
||||
command: "test -f operations.db"
|
||||
- name: backup
|
||||
action: command
|
||||
command: "cp operations.db backups/operations_$(date +%Y%m%d).db"
|
||||
depends: [check_db]
|
||||
- name: push
|
||||
action: command
|
||||
command: "git add backups/ && git commit -m 'backup' && git push"
|
||||
depends: [backup]
|
||||
continue_on_error: true
|
||||
hooks:
|
||||
on_failure: "echo 'Backup fallo' >> /tmp/backup_errors.log"
|
||||
```
|
||||
|
||||
### Invocacion desde Dagu
|
||||
|
||||
```yaml
|
||||
name: backup_diario
|
||||
schedule: "0 2 * * *"
|
||||
steps:
|
||||
- name: backup
|
||||
command: ./fn run backup_db --script flujo.yaml --json
|
||||
working_dir: /home/lucas/fn_registry/apps/mi_app
|
||||
handlers:
|
||||
failure:
|
||||
- name: alerta
|
||||
command: echo "Backup fallo $(date)" >> ~/dagu/logs/failures.log
|
||||
```
|
||||
|
||||
### Output esperado (stdout)
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "backup_db",
|
||||
"status": "partial",
|
||||
"exit_code": 2,
|
||||
"started_at": "2026-04-01T02:00:00Z",
|
||||
"ended_at": "2026-04-01T02:00:03Z",
|
||||
"duration_ms": 3000,
|
||||
"steps_total": 3,
|
||||
"steps_ok": 2,
|
||||
"steps_failed": 1,
|
||||
"steps": [
|
||||
{"name": "check_db", "action": "command", "status": "ok", "elapsed_ms": 10},
|
||||
{"name": "backup", "action": "command", "status": "ok", "elapsed_ms": 450},
|
||||
{"name": "push", "action": "command", "status": "error", "elapsed_ms": 2540, "error": "remote rejected"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Exit code: `2` (partial — push fallo pero tenia continue_on_error).
|
||||
|
||||
---
|
||||
|
||||
## Resumen del contrato
|
||||
|
||||
| Aspecto | Regla |
|
||||
|---------|-------|
|
||||
| Formato de flujo | YAML con `name` + `steps[]` |
|
||||
| Exit codes | 0=success, 1=failure, 2=partial |
|
||||
| Output maquina | JSON en stdout (con `--json`) |
|
||||
| Output humano | stderr (progreso, warnings) |
|
||||
| Error handling | `continue_on_error` por paso, hooks por resultado |
|
||||
| Trazabilidad | operations.db (executions + logs) |
|
||||
| Orquestacion | Dagu como scheduler, apps como ejecutores |
|
||||
| Funciones | Reutilizar del registry, actions por dominio |
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
---
|
||||
name: my_analysis
|
||||
lang: py
|
||||
domain: datascience
|
||||
description: "Descripcion breve del analisis."
|
||||
tags: []
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
framework: "jupyterlab"
|
||||
entry_point: "notebooks/main.ipynb"
|
||||
dir_path: "analysis/my_analysis"
|
||||
repo_url: ""
|
||||
---
|
||||
|
||||
## Notas
|
||||
|
||||
Notas adicionales sobre el analisis.
|
||||
@@ -1,4 +1,4 @@
|
||||
package infra
|
||||
package browser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -2,13 +2,13 @@
|
||||
name: cdp_click
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CdpClick(c *CDPConn, selector string) error"
|
||||
description: "Hace click en el primer elemento que coincide con el selector CSS. Obtiene coordenadas del centro via getBoundingClientRect, hace scroll al elemento y despacha eventos mousedown+mouseup via Input.dispatchMouseEvent."
|
||||
tags: [chrome, cdp, browser, automation, click, dom, devtools]
|
||||
uses_functions: [cdp_connect_go_infra, cdp_evaluate_go_infra]
|
||||
uses_functions: [cdp_connect_go_browser, cdp_evaluate_go_browser]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
@@ -1,4 +1,4 @@
|
||||
package infra
|
||||
package browser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -2,7 +2,7 @@
|
||||
name: cdp_close
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CdpClose(c *CDPConn, pid int) error"
|
||||
@@ -1,4 +1,4 @@
|
||||
package infra
|
||||
package browser
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
@@ -1,4 +1,4 @@
|
||||
package infra
|
||||
package browser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -2,7 +2,7 @@
|
||||
name: cdp_connect
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CdpConnect(port int) (*CDPConn, error)"
|
||||
@@ -1,4 +1,4 @@
|
||||
package infra
|
||||
package browser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -2,13 +2,13 @@
|
||||
name: cdp_evaluate
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CdpEvaluate(c *CDPConn, expression string) (string, error)"
|
||||
description: "Ejecuta una expresion JavaScript arbitraria en la pagina actual via Runtime.evaluate. Retorna el resultado serializado como string. Soporta await (awaitPromise=true). Reporta excepciones JS como error."
|
||||
tags: [chrome, cdp, browser, automation, javascript, devtools]
|
||||
uses_functions: [cdp_connect_go_infra]
|
||||
uses_functions: [cdp_connect_go_browser]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
@@ -1,4 +1,4 @@
|
||||
package infra
|
||||
package browser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -2,13 +2,13 @@
|
||||
name: cdp_get_html
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CdpGetHTML(c *CDPConn) (string, error)"
|
||||
description: "Retorna el HTML completo de la pagina actual (document.documentElement.outerHTML) via Runtime.evaluate. Captura el DOM vivo post-JavaScript, no el HTML fuente original."
|
||||
tags: [chrome, cdp, browser, automation, html, dom, scraping, devtools]
|
||||
uses_functions: [cdp_connect_go_infra, cdp_evaluate_go_infra]
|
||||
uses_functions: [cdp_connect_go_browser, cdp_evaluate_go_browser]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
@@ -1,4 +1,4 @@
|
||||
package infra
|
||||
package browser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -2,13 +2,13 @@
|
||||
name: cdp_navigate
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CdpNavigate(c *CDPConn, targetURL string) error"
|
||||
description: "Navega a la URL indicada usando el comando Page.navigate del protocolo CDP. Verifica que no haya errorText en la respuesta. Recibe una *CDPConn obtenida de CdpConnect."
|
||||
tags: [chrome, cdp, browser, automation, navigation, devtools]
|
||||
uses_functions: [cdp_connect_go_infra]
|
||||
uses_functions: [cdp_connect_go_browser]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
@@ -1,4 +1,4 @@
|
||||
package infra
|
||||
package browser
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
@@ -2,13 +2,13 @@
|
||||
name: cdp_screenshot
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CdpScreenshot(c *CDPConn, outputPath string, opts CdpScreenshotOpts) error"
|
||||
description: "Captura un screenshot de la pagina actual via Page.captureScreenshot y lo guarda en el archivo indicado. Soporta PNG y JPEG, viewport o pagina completa. Crea el directorio destino si no existe."
|
||||
tags: [chrome, cdp, browser, automation, screenshot, devtools, png]
|
||||
uses_functions: [cdp_connect_go_infra, cdp_evaluate_go_infra]
|
||||
uses_functions: [cdp_connect_go_browser, cdp_evaluate_go_browser]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
@@ -1,4 +1,4 @@
|
||||
package infra
|
||||
package browser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -2,13 +2,13 @@
|
||||
name: cdp_type_text
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CdpTypeText(c *CDPConn, text string) error"
|
||||
description: "Escribe texto en el elemento activo de la pagina caracter por caracter via Input.dispatchKeyEvent. Envia eventos keyDown, char y keyUp por cada caracter con 10ms de pausa entre ellos. Usar CdpClick primero para enfocar el elemento."
|
||||
tags: [chrome, cdp, browser, automation, keyboard, input, devtools]
|
||||
uses_functions: [cdp_connect_go_infra]
|
||||
uses_functions: [cdp_connect_go_browser]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
@@ -1,4 +1,4 @@
|
||||
package infra
|
||||
package browser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -2,13 +2,13 @@
|
||||
name: cdp_wait_element
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CdpWaitElement(c *CDPConn, selector string, timeout time.Duration) error"
|
||||
description: "Espera hasta que un selector CSS exista en el DOM. Hace polling con Runtime.evaluate cada 200ms. Retorna nil cuando el elemento aparece o error si se agota el timeout. Util despues de navegacion o acciones que producen cambios dinamicos."
|
||||
tags: [chrome, cdp, browser, automation, dom, wait, polling, devtools]
|
||||
uses_functions: [cdp_connect_go_infra, cdp_evaluate_go_infra]
|
||||
uses_functions: [cdp_connect_go_browser, cdp_evaluate_go_browser]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
@@ -1,4 +1,4 @@
|
||||
package infra
|
||||
package browser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user