merge: quick/fn-operations-docker-tui — fn_operations library, CLI ops y Docker TUI

This commit is contained in:
2026-03-28 04:38:22 +01:00
39 changed files with 3767 additions and 1 deletions
+4 -1
View File
@@ -29,6 +29,8 @@ func main() {
cmdShow(os.Args[2:])
case "add":
cmdAdd(os.Args[2:])
case "ops":
cmdOps(os.Args[2:])
case "help", "-h", "--help":
printUsage()
default:
@@ -46,7 +48,8 @@ Usage:
fn search [-k kind] [-p purity] [-l lang] [-d domain] <query>
fn list [-k kind] [-d domain] [-l lang]
fn show <id> Muestra entrada completa
fn add [-k kind] Abre $EDITOR con template`)
fn add [-k kind] Abre $EDITOR con template
fn ops <subcommand> Gestiona operations.db (fn ops help)`)
}
func root() string {
+634
View File
@@ -0,0 +1,634 @@
package main
import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"text/tabwriter"
ops "fn-registry/fn_operations"
"fn-registry/registry"
)
const opsDBName = "operations.db"
func cmdOps(args []string) {
if len(args) < 1 {
printOpsUsage()
os.Exit(1)
}
switch args[0] {
case "init":
cmdOpsInit(args[1:])
case "entity":
cmdOpsEntity(args[1:])
case "relation":
cmdOpsRelation(args[1:])
case "graph":
cmdOpsGraph()
case "snapshot":
cmdOpsSnapshot(args[1:])
case "help", "-h", "--help":
printOpsUsage()
default:
fmt.Fprintf(os.Stderr, "unknown ops command: %s\n", args[0])
printOpsUsage()
os.Exit(1)
}
}
func printOpsUsage() {
fmt.Println(`fn ops — operations CLI
Usage:
fn ops init [path] Crea operations.db en path (default: .)
fn ops entity add <flags> Añade entity
fn ops entity list [-d domain] [-s status] Lista entities
fn ops entity show <id> Muestra entity
fn ops entity delete <id> Elimina entity
fn ops relation add <flags> Añade relation
fn ops relation list [entity_id] Lista relations
fn ops relation show <id> Muestra relation
fn ops relation delete <id> Elimina relation
fn ops graph Grafo ASCII de entities y relations
fn ops snapshot list Lista tipos snapshotted
Entity flags:
--id <id> --name <name> --type-ref <type_id> --source <source>
--domain <domain> --status <status> --description <desc>
--tags <t1,t2> --metadata <json> --notes <text>
Relation flags:
--id <id> --name <name> --from <entity_id> --to <entity_id>
--via <function_id> --direction <uni|bi|inverse> --status <status>
--purity <pure|impure> --weight <0.0-1.0> --description <desc>
--tags <t1,t2> --notes <text>`)
}
// --- ops init ---
func cmdOpsInit(args []string) {
dir := "."
if len(args) > 0 {
dir = args[0]
}
path := filepath.Join(dir, opsDBName)
if _, err := os.Stat(path); err == nil {
fmt.Fprintf(os.Stderr, "operations.db already exists at %s\n", path)
os.Exit(1)
}
// Copy from template if available, otherwise create fresh
templatePath := filepath.Join(root(), "fn_operations", "project_template", "operations.db")
if _, err := os.Stat(templatePath); err == nil {
src, err := os.Open(templatePath)
if err != nil {
fmt.Fprintf(os.Stderr, "error opening template: %v\n", err)
os.Exit(1)
}
defer src.Close()
if err := os.MkdirAll(dir, 0o755); err != nil {
fmt.Fprintf(os.Stderr, "error creating directory: %v\n", err)
os.Exit(1)
}
dst, err := os.Create(path)
if err != nil {
fmt.Fprintf(os.Stderr, "error creating db: %v\n", err)
os.Exit(1)
}
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
fmt.Fprintf(os.Stderr, "error copying template: %v\n", err)
os.Exit(1)
}
fmt.Printf("operations.db created at %s (from template)\n", path)
return
}
// Create fresh
db, err := ops.Open(path)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
db.Close()
fmt.Printf("operations.db created at %s\n", path)
}
// --- ops entity ---
func cmdOpsEntity(args []string) {
if len(args) < 1 {
fmt.Fprintln(os.Stderr, "usage: fn ops entity <add|list|show|delete> ...")
os.Exit(1)
}
switch args[0] {
case "add":
cmdOpsEntityAdd(args[1:])
case "list":
cmdOpsEntityList(args[1:])
case "show":
cmdOpsEntityShow(args[1:])
case "delete":
cmdOpsEntityDelete(args[1:])
default:
fmt.Fprintf(os.Stderr, "unknown entity command: %s\n", args[0])
os.Exit(1)
}
}
func cmdOpsEntityAdd(args []string) {
var e ops.Entity
e.Status = ops.StatusActive
var tagsStr, metadataStr string
for i := 0; i < len(args); i++ {
switch args[i] {
case "--id":
i++; e.ID = args[i]
case "--name":
i++; e.Name = args[i]
case "--type-ref":
i++; e.TypeRef = args[i]
case "--source":
i++; e.Source = args[i]
case "--domain":
i++; e.Domain = args[i]
case "--status":
i++; e.Status = ops.EntityStatus(args[i])
case "--description":
i++; e.Description = args[i]
case "--tags":
i++; tagsStr = args[i]
case "--metadata":
i++; metadataStr = args[i]
case "--notes":
i++; e.Notes = args[i]
}
}
if e.Name == "" || e.TypeRef == "" || e.Source == "" {
fmt.Fprintln(os.Stderr, "required: --name, --type-ref, --source")
os.Exit(1)
}
if e.ID == "" {
e.ID = e.Name
}
if tagsStr != "" {
e.Tags = strings.Split(tagsStr, ",")
}
if metadataStr != "" {
json.Unmarshal([]byte(metadataStr), &e.Metadata)
}
opsDB := openOpsDB()
defer opsDB.Close()
// Try to open registry for type snapshot
regDB := tryOpenRegistryDB()
if regDB != nil {
defer regDB.Close()
}
if err := ops.InsertEntityWithSnapshot(opsDB, regDB, &e); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
fmt.Printf("Entity %s added\n", e.ID)
}
func cmdOpsEntityList(args []string) {
var domain string
var status ops.EntityStatus
for i := 0; i < len(args); i++ {
switch args[i] {
case "-d":
i++; domain = args[i]
case "-s":
i++; status = ops.EntityStatus(args[i])
}
}
db := openOpsDB()
defer db.Close()
entities, err := db.ListEntities(domain, status)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if len(entities) == 0 {
fmt.Println("No entities.")
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ID\tTYPE_REF\tSTATUS\tSOURCE\tDOMAIN")
for _, e := range entities {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", e.ID, e.TypeRef, e.Status, e.Source, e.Domain)
}
w.Flush()
}
func cmdOpsEntityShow(args []string) {
if len(args) < 1 {
fmt.Fprintln(os.Stderr, "usage: fn ops entity show <id>")
os.Exit(1)
}
db := openOpsDB()
defer db.Close()
e, err := db.GetEntity(args[0])
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if e == nil {
fmt.Fprintf(os.Stderr, "entity not found: %s\n", args[0])
os.Exit(1)
}
fmt.Printf("ID: %s\n", e.ID)
fmt.Printf("Name: %s\n", e.Name)
fmt.Printf("Type ref: %s\n", e.TypeRef)
fmt.Printf("Status: %s\n", e.Status)
fmt.Printf("Source: %s\n", e.Source)
fmt.Printf("Domain: %s\n", e.Domain)
fmt.Printf("Description: %s\n", e.Description)
fmt.Printf("Tags: %s\n", strings.Join(e.Tags, ", "))
if len(e.Metadata) > 0 {
meta, _ := json.MarshalIndent(e.Metadata, " ", " ")
fmt.Printf("Metadata: %s\n", meta)
}
if e.Notes != "" {
fmt.Printf("Notes: %s\n", e.Notes)
}
fmt.Printf("Created: %s\n", e.CreatedAt.Format("2006-01-02 15:04:05"))
fmt.Printf("Updated: %s\n", e.UpdatedAt.Format("2006-01-02 15:04:05"))
}
func cmdOpsEntityDelete(args []string) {
if len(args) < 1 {
fmt.Fprintln(os.Stderr, "usage: fn ops entity delete <id>")
os.Exit(1)
}
db := openOpsDB()
defer db.Close()
if err := db.DeleteEntity(args[0]); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
fmt.Printf("Entity %s deleted\n", args[0])
}
// --- ops relation ---
func cmdOpsRelation(args []string) {
if len(args) < 1 {
fmt.Fprintln(os.Stderr, "usage: fn ops relation <add|list|show|delete> ...")
os.Exit(1)
}
switch args[0] {
case "add":
cmdOpsRelationAdd(args[1:])
case "list":
cmdOpsRelationList(args[1:])
case "show":
cmdOpsRelationShow(args[1:])
case "delete":
cmdOpsRelationDelete(args[1:])
default:
fmt.Fprintf(os.Stderr, "unknown relation command: %s\n", args[0])
os.Exit(1)
}
}
func cmdOpsRelationAdd(args []string) {
var r ops.Relation
r.Direction = ops.DirUnidirectional
r.Status = ops.RelDesigned
var tagsStr string
for i := 0; i < len(args); i++ {
switch args[i] {
case "--id":
i++; r.ID = args[i]
case "--name":
i++; r.Name = args[i]
case "--from":
i++; r.FromEntity = args[i]
case "--to":
i++; r.ToEntity = args[i]
case "--via":
i++; r.Via = args[i]
case "--direction":
i++; r.Direction = ops.Direction(args[i])
case "--status":
i++; r.Status = ops.RelationStatus(args[i])
case "--purity":
i++; r.Purity = args[i]
case "--weight":
i++
var w float64
fmt.Sscanf(args[i], "%f", &w)
r.Weight = &w
case "--description":
i++; r.Description = args[i]
case "--tags":
i++; tagsStr = args[i]
case "--notes":
i++; r.Notes = args[i]
}
}
if r.Name == "" || r.ToEntity == "" {
fmt.Fprintln(os.Stderr, "required: --name, --to (and --from for simple relations)")
os.Exit(1)
}
if r.ID == "" && r.FromEntity != "" {
via := "semantic"
if r.Via != "" {
via = r.Via
}
r.ID = fmt.Sprintf("%s__to__%s__via__%s", r.FromEntity, r.ToEntity, via)
}
if tagsStr != "" {
r.Tags = strings.Split(tagsStr, ",")
}
db := openOpsDB()
defer db.Close()
if err := ops.InsertRelationSafe(db, &r); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
fmt.Printf("Relation %s added\n", r.ID)
}
func cmdOpsRelationList(args []string) {
var entityID string
if len(args) > 0 {
entityID = args[0]
}
db := openOpsDB()
defer db.Close()
rels, err := db.ListRelations(entityID)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if len(rels) == 0 {
fmt.Println("No relations.")
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ID\tNAME\tFROM\tTO\tVIA\tDIRECTION\tSTATUS")
for _, r := range rels {
from := r.FromEntity
if from == "" {
from = "(inputs)"
}
via := r.Via
if via == "" {
via = "-"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
r.ID, r.Name, from, r.ToEntity, via, r.Direction, r.Status)
}
w.Flush()
}
func cmdOpsRelationShow(args []string) {
if len(args) < 1 {
fmt.Fprintln(os.Stderr, "usage: fn ops relation show <id>")
os.Exit(1)
}
db := openOpsDB()
defer db.Close()
r, err := db.GetRelation(args[0])
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if r == nil {
fmt.Fprintf(os.Stderr, "relation not found: %s\n", args[0])
os.Exit(1)
}
fmt.Printf("ID: %s\n", r.ID)
fmt.Printf("Name: %s\n", r.Name)
fmt.Printf("From: %s\n", r.FromEntity)
fmt.Printf("To: %s\n", r.ToEntity)
fmt.Printf("Via: %s\n", r.Via)
fmt.Printf("Description: %s\n", r.Description)
fmt.Printf("Purity: %s\n", r.Purity)
fmt.Printf("Direction: %s\n", r.Direction)
if r.Weight != nil {
fmt.Printf("Weight: %.2f\n", *r.Weight)
}
fmt.Printf("Status: %s\n", r.Status)
fmt.Printf("Tags: %s\n", strings.Join(r.Tags, ", "))
if r.Notes != "" {
fmt.Printf("Notes: %s\n", r.Notes)
}
// Show inputs if any
inputs, _ := db.GetRelationInputs(r.ID)
if len(inputs) > 0 {
fmt.Println("\nInputs:")
for _, ri := range inputs {
ord := ""
if ri.Order != nil {
ord = fmt.Sprintf(" (order: %d)", *ri.Order)
}
fmt.Printf(" %s [%s]%s\n", ri.EntityID, ri.Role, ord)
}
}
}
func cmdOpsRelationDelete(args []string) {
if len(args) < 1 {
fmt.Fprintln(os.Stderr, "usage: fn ops relation delete <id>")
os.Exit(1)
}
db := openOpsDB()
defer db.Close()
if err := db.DeleteRelation(args[0]); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
fmt.Printf("Relation %s deleted\n", args[0])
}
// --- ops graph ---
func cmdOpsGraph() {
db := openOpsDB()
defer db.Close()
g, err := ops.GetEntityGraph(db)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if len(g.Entities) == 0 {
fmt.Println("Empty graph.")
return
}
fmt.Println("Entities:")
for _, e := range g.Entities {
fmt.Printf(" [%s] (%s) status:%s source:%s\n", e.ID, e.TypeRef, e.Status, e.Source)
}
if len(g.Relations) > 0 {
fmt.Println("\nRelations:")
for _, r := range g.Relations {
via := ""
if r.Via != "" {
via = fmt.Sprintf(" via:%s", r.Via)
}
inputs, hasInputs := g.Inputs[r.ID]
if hasInputs {
sources := make([]string, len(inputs))
for i, ri := range inputs {
sources[i] = fmt.Sprintf("%s[%s]", ri.EntityID, ri.Role)
}
fmt.Printf(" (%s) %s → %s%s\n",
strings.Join(sources, " + "), r.Name, r.ToEntity, via)
} else {
from := r.FromEntity
if from == "" {
from = "?"
}
dir := "→"
if r.Direction == ops.DirBidirectional {
dir = "↔"
}
fmt.Printf(" %s %s %s %s%s\n", from, dir, r.Name, r.ToEntity, via)
}
}
}
}
// --- ops snapshot ---
func cmdOpsSnapshot(args []string) {
if len(args) < 1 || args[0] != "list" {
fmt.Fprintln(os.Stderr, "usage: fn ops snapshot list")
os.Exit(1)
}
db := openOpsDB()
defer db.Close()
snaps, err := db.ListTypeSnapshots()
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if len(snaps) == 0 {
fmt.Println("No type snapshots.")
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ID\tVERSION\tLANG\tALGEBRAIC\tSNAPPED_AT")
for _, s := range snaps {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
s.ID, s.Version, s.Lang, s.Algebraic, s.SnappedAt.Format("2006-01-02 15:04"))
}
w.Flush()
}
// --- helpers ---
func findOpsDB() string {
dir, _ := os.Getwd()
for {
path := filepath.Join(dir, opsDBName)
if _, err := os.Stat(path); err == nil {
return path
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
return filepath.Join(".", opsDBName)
}
func openOpsDB() *ops.DB {
path := findOpsDB()
db, err := ops.Open(path)
if err != nil {
fmt.Fprintf(os.Stderr, "error opening operations.db: %v\n", err)
fmt.Fprintln(os.Stderr, "Run 'fn ops init' first to create one.")
os.Exit(1)
}
return db
}
func tryOpenRegistryDB() *registry.DB {
// Try FN_REGISTRY_ROOT env var first
if envRoot := os.Getenv("FN_REGISTRY_ROOT"); envRoot != "" {
path := filepath.Join(envRoot, dbName)
if _, err := os.Stat(path); err == nil {
db, err := registry.Open(path)
if err == nil {
return db
}
}
}
// Try root() (finds go.mod walking up from cwd)
path := filepath.Join(root(), dbName)
if _, err := os.Stat(path); err == nil {
db, err := registry.Open(path)
if err == nil {
return db
}
}
// Try executable's directory
if exe, err := os.Executable(); err == nil {
exeDir := filepath.Dir(exe)
path := filepath.Join(exeDir, dbName)
if _, err := os.Stat(path); err == nil {
db, err := registry.Open(path)
if err == nil {
return db
}
}
}
return nil
}
+27
View File
@@ -0,0 +1,27 @@
package main
import (
"fmt"
"os"
ops "fn-registry/fn_operations"
)
func main() {
path := "fn_operations/project_template/operations.db"
if len(os.Args) > 1 {
path = os.Args[1]
}
// Remove existing template
os.Remove(path)
db, err := ops.Open(path)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
defer db.Close()
fmt.Printf("Template DB created at %s\n", path)
}
+140
View File
@@ -0,0 +1,140 @@
package fn_operations
import (
"database/sql"
"fmt"
"os"
"path/filepath"
_ "github.com/mattn/go-sqlite3"
)
const schemaSQL = `
CREATE TABLE IF NOT EXISTS types_snapshot (
id TEXT PRIMARY KEY,
version TEXT NOT NULL DEFAULT '1.0.0',
lang TEXT NOT NULL,
algebraic TEXT NOT NULL CHECK(algebraic IN ('product','sum')),
definition TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
snapped_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS entities (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type_ref TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','stale','corrupted','archived')),
description TEXT NOT NULL DEFAULT '',
domain TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '[]',
source TEXT NOT NULL,
metadata TEXT NOT NULL DEFAULT '{}',
notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS relations (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
from_entity TEXT NOT NULL DEFAULT '',
to_entity TEXT NOT NULL,
via TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
purity TEXT NOT NULL DEFAULT '' CHECK(purity IN ('','pure','impure')),
direction TEXT NOT NULL DEFAULT 'unidirectional' CHECK(direction IN ('unidirectional','bidirectional','inverse')),
weight REAL,
status TEXT NOT NULL DEFAULT 'designed' CHECK(status IN ('designed','implemented','tested','running','deprecated')),
started_at TEXT,
ended_at TEXT,
"order" INTEGER,
tags TEXT NOT NULL DEFAULT '[]',
notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS relation_inputs (
id TEXT PRIMARY KEY,
relation_id TEXT NOT NULL REFERENCES relations(id) ON DELETE CASCADE,
entity_id TEXT NOT NULL REFERENCES entities(id),
role TEXT NOT NULL,
"order" INTEGER
);
CREATE VIRTUAL TABLE IF NOT EXISTS entities_fts USING fts5(
id,
name,
description,
tags,
domain,
content='entities',
content_rowid='rowid'
);
-- Triggers to keep entities FTS in sync
CREATE TRIGGER IF NOT EXISTS entities_ai AFTER INSERT ON entities BEGIN
INSERT INTO entities_fts(rowid, id, name, description, tags, domain)
VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.domain);
END;
CREATE TRIGGER IF NOT EXISTS entities_ad AFTER DELETE ON entities BEGIN
INSERT INTO entities_fts(entities_fts, rowid, id, name, description, tags, domain)
VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.domain);
END;
CREATE TRIGGER IF NOT EXISTS entities_au AFTER UPDATE ON entities BEGIN
INSERT INTO entities_fts(entities_fts, rowid, id, name, description, tags, domain)
VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.domain);
INSERT INTO entities_fts(rowid, id, name, description, tags, domain)
VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.domain);
END;
`
// DB wraps a SQLite connection for an operations database.
type DB struct {
conn *sql.DB
path string
}
// Open opens or creates an operations database at the given path.
func Open(path string) (*DB, error) {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("creating db directory: %w", err)
}
conn, err := sql.Open("sqlite3", path+"?_foreign_keys=on")
if err != nil {
return nil, fmt.Errorf("opening database: %w", err)
}
if _, err := conn.Exec("PRAGMA journal_mode=WAL"); err != nil {
conn.Close()
return nil, fmt.Errorf("setting WAL mode: %w", err)
}
if _, err := conn.Exec(schemaSQL); err != nil {
conn.Close()
return nil, fmt.Errorf("applying schema: %w", err)
}
return &DB{conn: conn, path: path}, nil
}
// Conn returns the underlying sql.DB for transaction use.
func (db *DB) Conn() *sql.DB {
return db.conn
}
// Close closes the database connection.
func (db *DB) Close() error {
return db.conn.Close()
}
// Drop removes the database file.
func (db *DB) Drop() error {
db.Close()
return os.Remove(db.path)
}
+5
View File
@@ -0,0 +1,5 @@
build/
*.exe
*.dll
*.so
*.dylib
+19
View File
@@ -0,0 +1,19 @@
.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}'
+146
View File
@@ -0,0 +1,146 @@
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 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
}
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
}
+15
View File
@@ -0,0 +1,15 @@
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,
}
}
+43
View File
@@ -0,0 +1,43 @@
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
+84
View File
@@ -0,0 +1,84 @@
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=
+6
View File
@@ -0,0 +1,6 @@
go 1.22.2
use (
.
/home/lucas/.local_agentes/backend
)
+40
View File
@@ -0,0 +1,40 @@
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=
+20
View File
@@ -0,0 +1,20 @@
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)
}
}
Binary file not shown.
+193
View File
@@ -0,0 +1,193 @@
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--
}
case "esc", "q", "0":
m.state = composeList
return m, nil
}
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
}
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
}
@@ -0,0 +1,243 @@
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--
}
case "esc", "q", "0":
m.state = containersList
return m, nil
}
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
}
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
}
+192
View File
@@ -0,0 +1,192 @@
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)
}
+127
View File
@@ -0,0 +1,127 @@
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
}
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 ""
}
+14
View File
@@ -0,0 +1,14 @@
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
}
+123
View File
@@ -0,0 +1,123 @@
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
}
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 ""
}
+45
View File
@@ -0,0 +1,45 @@
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"`
}
+123
View File
@@ -0,0 +1,123 @@
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
}
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 ""
}
+80
View File
@@ -0,0 +1,80 @@
-- fn_operations schema v1.0.0
-- Espejo del schema en fn_operations/db.go para referencia y tooling externo.
PRAGMA journal_mode=WAL;
PRAGMA foreign_keys=ON;
CREATE TABLE IF NOT EXISTS types_snapshot (
id TEXT PRIMARY KEY,
version TEXT NOT NULL DEFAULT '1.0.0',
lang TEXT NOT NULL,
algebraic TEXT NOT NULL CHECK(algebraic IN ('product','sum')),
definition TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
snapped_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS entities (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type_ref TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','stale','corrupted','archived')),
description TEXT NOT NULL DEFAULT '',
domain TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '[]',
source TEXT NOT NULL,
metadata TEXT NOT NULL DEFAULT '{}',
notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS relations (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
from_entity TEXT NOT NULL DEFAULT '',
to_entity TEXT NOT NULL,
via TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
purity TEXT NOT NULL DEFAULT '' CHECK(purity IN ('','pure','impure')),
direction TEXT NOT NULL DEFAULT 'unidirectional' CHECK(direction IN ('unidirectional','bidirectional','inverse')),
weight REAL,
status TEXT NOT NULL DEFAULT 'designed' CHECK(status IN ('designed','implemented','tested','running','deprecated')),
started_at TEXT,
ended_at TEXT,
"order" INTEGER,
tags TEXT NOT NULL DEFAULT '[]',
notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS relation_inputs (
id TEXT PRIMARY KEY,
relation_id TEXT NOT NULL REFERENCES relations(id) ON DELETE CASCADE,
entity_id TEXT NOT NULL REFERENCES entities(id),
role TEXT NOT NULL,
"order" INTEGER
);
CREATE VIRTUAL TABLE IF NOT EXISTS entities_fts USING fts5(
id, name, description, tags, domain,
content='entities', content_rowid='rowid'
);
CREATE TRIGGER IF NOT EXISTS entities_ai AFTER INSERT ON entities BEGIN
INSERT INTO entities_fts(rowid, id, name, description, tags, domain)
VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.domain);
END;
CREATE TRIGGER IF NOT EXISTS entities_ad AFTER DELETE ON entities BEGIN
INSERT INTO entities_fts(entities_fts, rowid, id, name, description, tags, domain)
VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.domain);
END;
CREATE TRIGGER IF NOT EXISTS entities_au AFTER UPDATE ON entities BEGIN
INSERT INTO entities_fts(entities_fts, rowid, id, name, description, tags, domain)
VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.domain);
INSERT INTO entities_fts(rowid, id, name, description, tags, domain)
VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.domain);
END;
+90
View File
@@ -0,0 +1,90 @@
package fn_operations
import "time"
// EntityStatus represents the lifecycle state of an entity.
type EntityStatus string
const (
StatusActive EntityStatus = "active"
StatusStale EntityStatus = "stale"
StatusCorrupted EntityStatus = "corrupted"
StatusArchived EntityStatus = "archived"
)
// RelationStatus represents the lifecycle state of a relation.
type RelationStatus string
const (
RelDesigned RelationStatus = "designed"
RelImplemented RelationStatus = "implemented"
RelTested RelationStatus = "tested"
RelRunning RelationStatus = "running"
RelDeprecated RelationStatus = "deprecated"
)
// Direction represents the directionality of a relation.
type Direction string
const (
DirUnidirectional Direction = "unidirectional"
DirBidirectional Direction = "bidirectional"
DirInverse Direction = "inverse"
)
// Entity is a concrete instance of a registry type within a project context.
type Entity struct {
ID string `json:"id"`
Name string `json:"name"`
TypeRef string `json:"type_ref"`
Status EntityStatus `json:"status"`
Description string `json:"description"`
Domain string `json:"domain"`
Tags []string `json:"tags"`
Source string `json:"source"`
Metadata map[string]any `json:"metadata"`
Notes string `json:"notes"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Relation describes how one entity connects to or transforms into another.
type Relation struct {
ID string `json:"id"`
Name string `json:"name"`
FromEntity string `json:"from_entity"`
ToEntity string `json:"to_entity"`
Via string `json:"via"`
Description string `json:"description"`
Purity string `json:"purity"`
Direction Direction `json:"direction"`
Weight *float64 `json:"weight"`
Status RelationStatus `json:"status"`
StartedAt *time.Time `json:"started_at"`
EndedAt *time.Time `json:"ended_at"`
Order *int `json:"order"`
Tags []string `json:"tags"`
Notes string `json:"notes"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// RelationInput represents one input entity in a multi-input relation.
type RelationInput struct {
ID string `json:"id"`
RelationID string `json:"relation_id"`
EntityID string `json:"entity_id"`
Role string `json:"role"`
Order *int `json:"order"`
}
// TypeSnapshot is an immutable copy of a registry type at point of use.
type TypeSnapshot struct {
ID string `json:"id"`
Version string `json:"version"`
Lang string `json:"lang"`
Algebraic string `json:"algebraic"`
Definition string `json:"definition"`
Description string `json:"description"`
SnappedAt time.Time `json:"snapped_at"`
}
+202
View File
@@ -0,0 +1,202 @@
package fn_operations
import (
"fmt"
"time"
"fn-registry/registry"
)
// InsertEntityWithSnapshot inserts an entity, snapshotting its type from the registry if needed.
// registryDB can be nil if the type is already snapshotted.
func InsertEntityWithSnapshot(opsDB *DB, registryDB *registry.DB, e *Entity) error {
if err := ValidateEntity(e); err != nil {
return err
}
// Check if type is already snapshotted
snap, err := opsDB.GetTypeSnapshot(e.TypeRef)
if err != nil {
return fmt.Errorf("checking type snapshot: %w", err)
}
if snap == nil {
// Need to fetch from registry
if registryDB == nil {
return fmt.Errorf("type %q not found in local snapshots and no registry provided", e.TypeRef)
}
if err := SnapshotType(opsDB, registryDB, e.TypeRef); err != nil {
return err
}
}
return opsDB.InsertEntity(e)
}
// SnapshotType fetches a type from the registry and copies it to types_snapshot.
func SnapshotType(opsDB *DB, registryDB *registry.DB, typeID string) error {
t, err := registryDB.GetType(typeID)
if err != nil {
return fmt.Errorf("fetching type %q from registry: %w", typeID, err)
}
snap := &TypeSnapshot{
ID: t.ID,
Version: t.Version,
Lang: t.Lang,
Algebraic: string(t.Algebraic),
Definition: t.Definition,
Description: t.Description,
SnappedAt: time.Now().UTC(),
}
return opsDB.InsertTypeSnapshot(snap)
}
// InsertRelationSafe validates, checks for cycles, and inserts a relation.
func InsertRelationSafe(db *DB, r *Relation) error {
entities, err := buildEntitySet(db)
if err != nil {
return err
}
if err := ValidateRelation(r, entities); err != nil {
return err
}
// from_entity is required when not using relation_inputs
if r.FromEntity == "" {
return fmt.Errorf("from_entity is required (use InsertRelationWithInputs for multi-input relations)")
}
// Cycle detection only for causal relations
if r.Via != "" {
if err := DetectCycle(db, r.FromEntity, r.ToEntity); err != nil {
return err
}
}
return db.InsertRelation(r)
}
// InsertRelationWithInputs validates and inserts a relation with multiple inputs in a transaction.
func InsertRelationWithInputs(db *DB, r *Relation, inputs []RelationInput) error {
entities, err := buildEntitySet(db)
if err != nil {
return err
}
if err := ValidateRelation(r, entities); err != nil {
return err
}
if err := ValidateRelationInputs(inputs, entities); err != nil {
return err
}
// Cycle detection for each input if causal
if r.Via != "" {
for _, input := range inputs {
if err := DetectCycle(db, input.EntityID, r.ToEntity); err != nil {
return err
}
}
}
tx, err := db.Conn().Begin()
if err != nil {
return fmt.Errorf("beginning transaction: %w", err)
}
defer tx.Rollback()
// Insert relation
now := time.Now().UTC()
if r.CreatedAt.IsZero() {
r.CreatedAt = now
}
r.UpdatedAt = now
var startedAt, endedAt *string
if r.StartedAt != nil {
s := r.StartedAt.Format(time.RFC3339)
startedAt = &s
}
if r.EndedAt != nil {
s := r.EndedAt.Format(time.RFC3339)
endedAt = &s
}
_, err = tx.Exec(`
INSERT OR REPLACE INTO relations (id, name, from_entity, to_entity, via, description, purity, direction, weight, status, started_at, ended_at, "order", tags, notes, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
r.ID, r.Name, r.FromEntity, r.ToEntity, r.Via, r.Description,
r.Purity, string(r.Direction), r.Weight, string(r.Status),
startedAt, endedAt, r.Order, marshalStrings(r.Tags), r.Notes,
r.CreatedAt.Format(time.RFC3339), r.UpdatedAt.Format(time.RFC3339),
)
if err != nil {
return fmt.Errorf("inserting relation: %w", err)
}
// Insert inputs
for _, ri := range inputs {
_, err = tx.Exec(`
INSERT INTO relation_inputs (id, relation_id, entity_id, role, "order")
VALUES (?, ?, ?, ?, ?)`,
ri.ID, ri.RelationID, ri.EntityID, ri.Role, ri.Order,
)
if err != nil {
return fmt.Errorf("inserting relation_input: %w", err)
}
}
return tx.Commit()
}
// Graph holds the full entity-relation graph for a project.
type Graph struct {
Entities []Entity
Relations []Relation
Inputs map[string][]RelationInput
}
// GetEntityGraph returns all entities and relations for visualization.
func GetEntityGraph(db *DB) (*Graph, error) {
entities, err := db.ListEntities("", "")
if err != nil {
return nil, fmt.Errorf("listing entities: %w", err)
}
relations, err := db.ListRelations("")
if err != nil {
return nil, fmt.Errorf("listing relations: %w", err)
}
inputs := map[string][]RelationInput{}
for _, r := range relations {
ri, err := db.GetRelationInputs(r.ID)
if err != nil {
return nil, fmt.Errorf("getting inputs for relation %s: %w", r.ID, err)
}
if len(ri) > 0 {
inputs[r.ID] = ri
}
}
return &Graph{
Entities: entities,
Relations: relations,
Inputs: inputs,
}, nil
}
func buildEntitySet(db *DB) (map[string]bool, error) {
all, err := db.ListEntities("", "")
if err != nil {
return nil, fmt.Errorf("building entity set: %w", err)
}
set := make(map[string]bool, len(all))
for _, e := range all {
set[e.ID] = true
}
return set, nil
}
+375
View File
@@ -0,0 +1,375 @@
package fn_operations
import (
"os"
"path/filepath"
"testing"
)
func tempDB(t *testing.T) *DB {
t.Helper()
path := filepath.Join(t.TempDir(), "test.db")
db, err := Open(path)
if err != nil {
t.Fatalf("opening test db: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
func TestOpenAndClose(t *testing.T) {
path := filepath.Join(t.TempDir(), "test.db")
db, err := Open(path)
if err != nil {
t.Fatalf("open: %v", err)
}
if err := db.Close(); err != nil {
t.Fatalf("close: %v", err)
}
if _, err := os.Stat(path); os.IsNotExist(err) {
t.Fatal("db file should exist")
}
}
func TestTypeSnapshotCRUD(t *testing.T) {
db := tempDB(t)
ts := &TypeSnapshot{
ID: "ohlcv_go_finance",
Version: "1.0.0",
Lang: "go",
Algebraic: "product",
Definition: "type OHLCV struct { ... }",
Description: "Vela de mercado",
}
if err := db.InsertTypeSnapshot(ts); err != nil {
t.Fatalf("insert: %v", err)
}
got, err := db.GetTypeSnapshot("ohlcv_go_finance")
if err != nil {
t.Fatalf("get: %v", err)
}
if got == nil {
t.Fatal("expected snapshot, got nil")
}
if got.Definition != ts.Definition {
t.Errorf("definition = %q, want %q", got.Definition, ts.Definition)
}
// INSERT OR IGNORE: second insert should not overwrite
ts2 := &TypeSnapshot{
ID: "ohlcv_go_finance",
Version: "2.0.0",
Lang: "go",
Algebraic: "product",
Definition: "type OHLCV struct { CHANGED }",
Description: "Changed",
}
if err := db.InsertTypeSnapshot(ts2); err != nil {
t.Fatalf("insert duplicate: %v", err)
}
got2, _ := db.GetTypeSnapshot("ohlcv_go_finance")
if got2.Version != "1.0.0" {
t.Errorf("snapshot should be immutable, got version %q", got2.Version)
}
// Not found
missing, err := db.GetTypeSnapshot("nonexistent")
if err != nil {
t.Fatalf("get missing: %v", err)
}
if missing != nil {
t.Error("expected nil for missing snapshot")
}
all, err := db.ListTypeSnapshots()
if err != nil {
t.Fatalf("list: %v", err)
}
if len(all) != 1 {
t.Errorf("expected 1 snapshot, got %d", len(all))
}
}
func TestEntityCRUD(t *testing.T) {
db := tempDB(t)
// Insert snapshot first (type_ref)
db.InsertTypeSnapshot(&TypeSnapshot{
ID: "tick_go_finance", Version: "1.0.0", Lang: "go", Algebraic: "product",
})
e := &Entity{
ID: "ticks_btcusdt_2024",
Name: "ticks_btcusdt_2024",
TypeRef: "tick_go_finance",
Status: StatusActive,
Source: "binance_api",
Domain: "market_data",
Tags: []string{"btc", "binance"},
Metadata: map[string]any{
"pair": "BTCUSDT",
"exchange": "binance",
},
}
if err := db.InsertEntity(e); err != nil {
t.Fatalf("insert: %v", err)
}
got, err := db.GetEntity("ticks_btcusdt_2024")
if err != nil {
t.Fatalf("get: %v", err)
}
if got == nil {
t.Fatal("expected entity, got nil")
}
if got.Source != "binance_api" {
t.Errorf("source = %q, want binance_api", got.Source)
}
if len(got.Tags) != 2 {
t.Errorf("tags len = %d, want 2", len(got.Tags))
}
if got.Metadata["pair"] != "BTCUSDT" {
t.Errorf("metadata pair = %v, want BTCUSDT", got.Metadata["pair"])
}
// Update
got.Status = StatusStale
if err := db.UpdateEntity(got); err != nil {
t.Fatalf("update: %v", err)
}
updated, _ := db.GetEntity("ticks_btcusdt_2024")
if updated.Status != StatusStale {
t.Errorf("status = %q, want stale", updated.Status)
}
// List
all, err := db.ListEntities("market_data", "")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(all) != 1 {
t.Errorf("expected 1, got %d", len(all))
}
// Search
found, err := db.SearchEntities("btcusdt", "")
if err != nil {
t.Fatalf("search: %v", err)
}
if len(found) != 1 {
t.Errorf("search expected 1, got %d", len(found))
}
// Delete
if err := db.DeleteEntity("ticks_btcusdt_2024"); err != nil {
t.Fatalf("delete: %v", err)
}
deleted, _ := db.GetEntity("ticks_btcusdt_2024")
if deleted != nil {
t.Error("expected nil after delete")
}
}
func TestRelationCRUD(t *testing.T) {
db := tempDB(t)
// Setup entities
db.InsertTypeSnapshot(&TypeSnapshot{ID: "t1", Version: "1.0.0", Lang: "go", Algebraic: "product"})
db.InsertEntity(&Entity{ID: "a", Name: "a", TypeRef: "t1", Status: StatusActive, Source: "test"})
db.InsertEntity(&Entity{ID: "b", Name: "b", TypeRef: "t1", Status: StatusActive, Source: "test"})
r := &Relation{
ID: "a__to__b__via__transform",
Name: "TRANSFORMA",
FromEntity: "a",
ToEntity: "b",
Direction: DirUnidirectional,
Status: RelDesigned,
}
if err := InsertRelationSafe(db, r); err != nil {
t.Fatalf("insert relation: %v", err)
}
got, err := db.GetRelation("a__to__b__via__transform")
if err != nil {
t.Fatalf("get: %v", err)
}
if got == nil {
t.Fatal("expected relation, got nil")
}
if got.Name != "TRANSFORMA" {
t.Errorf("name = %q, want TRANSFORMA", got.Name)
}
// List by entity
rels, err := db.ListRelations("a")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(rels) != 1 {
t.Errorf("expected 1, got %d", len(rels))
}
// Delete
if err := db.DeleteRelation("a__to__b__via__transform"); err != nil {
t.Fatalf("delete: %v", err)
}
deleted, _ := db.GetRelation("a__to__b__via__transform")
if deleted != nil {
t.Error("expected nil after delete")
}
}
func TestRelationInputs(t *testing.T) {
db := tempDB(t)
db.InsertTypeSnapshot(&TypeSnapshot{ID: "t1", Version: "1.0.0", Lang: "go", Algebraic: "product"})
db.InsertEntity(&Entity{ID: "a", Name: "a", TypeRef: "t1", Status: StatusActive, Source: "test"})
db.InsertEntity(&Entity{ID: "b", Name: "b", TypeRef: "t1", Status: StatusActive, Source: "test"})
db.InsertEntity(&Entity{ID: "c", Name: "c", TypeRef: "t1", Status: StatusActive, Source: "test"})
r := &Relation{
ID: "multi__to__c",
Name: "ENRIQUECE",
ToEntity: "c",
Direction: DirUnidirectional,
Status: RelDesigned,
}
inputs := []RelationInput{
{ID: "i1", RelationID: "multi__to__c", EntityID: "a", Role: "base"},
{ID: "i2", RelationID: "multi__to__c", EntityID: "b", Role: "lookup"},
}
if err := InsertRelationWithInputs(db, r, inputs); err != nil {
t.Fatalf("insert with inputs: %v", err)
}
got, err := db.GetRelationInputs("multi__to__c")
if err != nil {
t.Fatalf("get inputs: %v", err)
}
if len(got) != 2 {
t.Errorf("expected 2 inputs, got %d", len(got))
}
}
func TestCycleDetectionCausal(t *testing.T) {
db := tempDB(t)
db.InsertTypeSnapshot(&TypeSnapshot{ID: "t1", Version: "1.0.0", Lang: "go", Algebraic: "product"})
db.InsertEntity(&Entity{ID: "a", Name: "a", TypeRef: "t1", Status: StatusActive, Source: "test"})
db.InsertEntity(&Entity{ID: "b", Name: "b", TypeRef: "t1", Status: StatusActive, Source: "test"})
db.InsertEntity(&Entity{ID: "c", Name: "c", TypeRef: "t1", Status: StatusActive, Source: "test"})
// a -> b (causal)
InsertRelationSafe(db, &Relation{
ID: "ab", Name: "T1", FromEntity: "a", ToEntity: "b", Via: "fn1",
Purity: "impure", Direction: DirUnidirectional, Status: RelDesigned,
})
// b -> c (causal)
InsertRelationSafe(db, &Relation{
ID: "bc", Name: "T2", FromEntity: "b", ToEntity: "c", Via: "fn2",
Purity: "impure", Direction: DirUnidirectional, Status: RelDesigned,
})
// c -> a (causal) should fail — creates cycle
err := InsertRelationSafe(db, &Relation{
ID: "ca", Name: "T3", FromEntity: "c", ToEntity: "a", Via: "fn3",
Purity: "impure", Direction: DirUnidirectional, Status: RelDesigned,
})
if err == nil {
t.Fatal("expected cycle error, got nil")
}
}
func TestCycleDetectionSemanticAllowed(t *testing.T) {
db := tempDB(t)
db.InsertTypeSnapshot(&TypeSnapshot{ID: "t1", Version: "1.0.0", Lang: "go", Algebraic: "product"})
db.InsertEntity(&Entity{ID: "juan", Name: "juan", TypeRef: "t1", Status: StatusActive, Source: "test"})
db.InsertEntity(&Entity{ID: "paula", Name: "paula", TypeRef: "t1", Status: StatusActive, Source: "test"})
// juan -> paula (semantic, no via)
if err := InsertRelationSafe(db, &Relation{
ID: "jp", Name: "CONOCE A", FromEntity: "juan", ToEntity: "paula",
Direction: DirBidirectional, Status: RelRunning,
}); err != nil {
t.Fatalf("insert semantic: %v", err)
}
// paula -> juan (semantic, no via) — should succeed, no cycle check
if err := InsertRelationSafe(db, &Relation{
ID: "pj", Name: "CONOCE A", FromEntity: "paula", ToEntity: "juan",
Direction: DirBidirectional, Status: RelRunning,
}); err != nil {
t.Fatalf("semantic cycle should be allowed: %v", err)
}
}
func TestValidateEntity(t *testing.T) {
tests := []struct {
name string
entity Entity
wantErr bool
}{
{
name: "valid",
entity: Entity{ID: "x", Name: "x", TypeRef: "t1", Status: StatusActive, Source: "test"},
wantErr: false,
},
{
name: "missing name",
entity: Entity{ID: "x", TypeRef: "t1", Status: StatusActive, Source: "test"},
wantErr: true,
},
{
name: "missing source",
entity: Entity{ID: "x", Name: "x", TypeRef: "t1", Status: StatusActive},
wantErr: true,
},
{
name: "missing type_ref",
entity: Entity{ID: "x", Name: "x", Status: StatusActive, Source: "test"},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateEntity(&tt.entity)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateEntity() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestGetEntityGraph(t *testing.T) {
db := tempDB(t)
db.InsertTypeSnapshot(&TypeSnapshot{ID: "t1", Version: "1.0.0", Lang: "go", Algebraic: "product"})
db.InsertEntity(&Entity{ID: "a", Name: "a", TypeRef: "t1", Status: StatusActive, Source: "test"})
db.InsertEntity(&Entity{ID: "b", Name: "b", TypeRef: "t1", Status: StatusActive, Source: "test"})
InsertRelationSafe(db, &Relation{
ID: "ab", Name: "FLUYE", FromEntity: "a", ToEntity: "b",
Direction: DirUnidirectional, Status: RelDesigned,
})
g, err := GetEntityGraph(db)
if err != nil {
t.Fatalf("graph: %v", err)
}
if len(g.Entities) != 2 {
t.Errorf("entities = %d, want 2", len(g.Entities))
}
if len(g.Relations) != 1 {
t.Errorf("relations = %d, want 1", len(g.Relations))
}
}
Binary file not shown.
+459
View File
@@ -0,0 +1,459 @@
package fn_operations
import (
"database/sql"
"encoding/json"
"fmt"
"strings"
"time"
)
func marshalStrings(ss []string) string {
if ss == nil {
ss = []string{}
}
b, _ := json.Marshal(ss)
return string(b)
}
func unmarshalStrings(s string) []string {
var out []string
json.Unmarshal([]byte(s), &out)
if out == nil {
out = []string{}
}
return out
}
func marshalJSON(v map[string]any) string {
if v == nil {
v = map[string]any{}
}
b, _ := json.Marshal(v)
return string(b)
}
func unmarshalJSON(s string) map[string]any {
var out map[string]any
json.Unmarshal([]byte(s), &out)
if out == nil {
out = map[string]any{}
}
return out
}
// --- TypeSnapshot CRUD ---
// InsertTypeSnapshot inserts a type snapshot.
func (db *DB) InsertTypeSnapshot(ts *TypeSnapshot) error {
if ts.SnappedAt.IsZero() {
ts.SnappedAt = time.Now().UTC()
}
_, err := db.conn.Exec(`
INSERT OR IGNORE INTO types_snapshot (id, version, lang, algebraic, definition, description, snapped_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
ts.ID, ts.Version, ts.Lang, ts.Algebraic, ts.Definition, ts.Description,
ts.SnappedAt.Format(time.RFC3339),
)
return err
}
// GetTypeSnapshot returns a type snapshot by ID.
func (db *DB) GetTypeSnapshot(id string) (*TypeSnapshot, error) {
row := db.conn.QueryRow("SELECT id, version, lang, algebraic, definition, description, snapped_at FROM types_snapshot WHERE id = ?", id)
var ts TypeSnapshot
var snappedAt string
err := row.Scan(&ts.ID, &ts.Version, &ts.Lang, &ts.Algebraic, &ts.Definition, &ts.Description, &snappedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scanning type_snapshot: %w", err)
}
ts.SnappedAt, _ = time.Parse(time.RFC3339, snappedAt)
return &ts, nil
}
// ListTypeSnapshots returns all type snapshots.
func (db *DB) ListTypeSnapshots() ([]TypeSnapshot, error) {
rows, err := db.conn.Query("SELECT id, version, lang, algebraic, definition, description, snapped_at FROM types_snapshot ORDER BY id")
if err != nil {
return nil, err
}
defer rows.Close()
var result []TypeSnapshot
for rows.Next() {
var ts TypeSnapshot
var snappedAt string
if err := rows.Scan(&ts.ID, &ts.Version, &ts.Lang, &ts.Algebraic, &ts.Definition, &ts.Description, &snappedAt); err != nil {
return nil, fmt.Errorf("scanning type_snapshot: %w", err)
}
ts.SnappedAt, _ = time.Parse(time.RFC3339, snappedAt)
result = append(result, ts)
}
return result, nil
}
// --- Entity CRUD ---
// InsertEntity inserts or replaces an entity.
func (db *DB) InsertEntity(e *Entity) error {
now := time.Now().UTC()
if e.CreatedAt.IsZero() {
e.CreatedAt = now
}
e.UpdatedAt = now
_, err := db.conn.Exec(`
INSERT OR REPLACE INTO entities (id, name, type_ref, status, description, domain, tags, source, metadata, notes, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
e.ID, e.Name, e.TypeRef, string(e.Status), e.Description, e.Domain,
marshalStrings(e.Tags), e.Source, marshalJSON(e.Metadata), e.Notes,
e.CreatedAt.Format(time.RFC3339), e.UpdatedAt.Format(time.RFC3339),
)
return err
}
// GetEntity returns an entity by ID.
func (db *DB) GetEntity(id string) (*Entity, error) {
row := db.conn.QueryRow(`
SELECT id, name, type_ref, status, description, domain, tags, source, metadata, notes, created_at, updated_at
FROM entities WHERE id = ?`, id)
var e Entity
var tagsJSON, metadataJSON, createdAt, updatedAt string
err := row.Scan(&e.ID, &e.Name, &e.TypeRef, &e.Status, &e.Description, &e.Domain,
&tagsJSON, &e.Source, &metadataJSON, &e.Notes, &createdAt, &updatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scanning entity: %w", err)
}
e.Tags = unmarshalStrings(tagsJSON)
e.Metadata = unmarshalJSON(metadataJSON)
e.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
e.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
return &e, nil
}
// UpdateEntity updates an existing entity.
func (db *DB) UpdateEntity(e *Entity) error {
e.UpdatedAt = time.Now().UTC()
_, err := db.conn.Exec(`
UPDATE entities SET name=?, type_ref=?, status=?, description=?, domain=?, tags=?, source=?, metadata=?, notes=?, updated_at=?
WHERE id=?`,
e.Name, e.TypeRef, string(e.Status), e.Description, e.Domain,
marshalStrings(e.Tags), e.Source, marshalJSON(e.Metadata), e.Notes,
e.UpdatedAt.Format(time.RFC3339), e.ID,
)
return err
}
// DeleteEntity removes an entity by ID.
func (db *DB) DeleteEntity(id string) error {
_, err := db.conn.Exec("DELETE FROM entities WHERE id = ?", id)
return err
}
// ListEntities returns entities filtered by domain and/or status.
func (db *DB) ListEntities(domain string, status EntityStatus) ([]Entity, error) {
where := []string{}
args := []any{}
if domain != "" {
where = append(where, "domain = ?")
args = append(args, domain)
}
if status != "" {
where = append(where, "status = ?")
args = append(args, string(status))
}
q := "SELECT id, name, type_ref, status, description, domain, tags, source, metadata, notes, created_at, updated_at FROM entities"
if len(where) > 0 {
q += " WHERE " + strings.Join(where, " AND ")
}
q += " ORDER BY name"
rows, err := db.conn.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanEntities(rows)
}
// SearchEntities performs FTS search on entities.
func (db *DB) SearchEntities(query, domain string) ([]Entity, error) {
where := []string{}
args := []any{}
if query != "" {
where = append(where, "e.id IN (SELECT id FROM entities_fts WHERE entities_fts MATCH ?)")
args = append(args, query)
}
if domain != "" {
where = append(where, "e.domain = ?")
args = append(args, domain)
}
q := "SELECT e.id, e.name, e.type_ref, e.status, e.description, e.domain, e.tags, e.source, e.metadata, e.notes, e.created_at, e.updated_at FROM entities e"
if len(where) > 0 {
q += " WHERE " + strings.Join(where, " AND ")
}
q += " ORDER BY e.name"
rows, err := db.conn.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanEntities(rows)
}
func scanEntities(rows *sql.Rows) ([]Entity, error) {
var result []Entity
for rows.Next() {
var e Entity
var tagsJSON, metadataJSON, createdAt, updatedAt string
if err := rows.Scan(&e.ID, &e.Name, &e.TypeRef, &e.Status, &e.Description, &e.Domain,
&tagsJSON, &e.Source, &metadataJSON, &e.Notes, &createdAt, &updatedAt); err != nil {
return nil, fmt.Errorf("scanning entity: %w", err)
}
e.Tags = unmarshalStrings(tagsJSON)
e.Metadata = unmarshalJSON(metadataJSON)
e.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
e.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
result = append(result, e)
}
return result, nil
}
// --- Relation CRUD ---
// InsertRelation inserts or replaces a relation.
func (db *DB) InsertRelation(r *Relation) error {
now := time.Now().UTC()
if r.CreatedAt.IsZero() {
r.CreatedAt = now
}
r.UpdatedAt = now
var startedAt, endedAt *string
if r.StartedAt != nil {
s := r.StartedAt.Format(time.RFC3339)
startedAt = &s
}
if r.EndedAt != nil {
s := r.EndedAt.Format(time.RFC3339)
endedAt = &s
}
_, err := db.conn.Exec(`
INSERT OR REPLACE INTO relations (id, name, from_entity, to_entity, via, description, purity, direction, weight, status, started_at, ended_at, "order", tags, notes, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
r.ID, r.Name, r.FromEntity, r.ToEntity, r.Via, r.Description,
r.Purity, string(r.Direction), r.Weight, string(r.Status),
startedAt, endedAt, r.Order, marshalStrings(r.Tags), r.Notes,
r.CreatedAt.Format(time.RFC3339), r.UpdatedAt.Format(time.RFC3339),
)
return err
}
// GetRelation returns a relation by ID.
func (db *DB) GetRelation(id string) (*Relation, error) {
row := db.conn.QueryRow(`
SELECT id, name, from_entity, to_entity, via, description, purity, direction, weight, status, started_at, ended_at, "order", tags, notes, created_at, updated_at
FROM relations WHERE id = ?`, id)
return scanRelation(row)
}
// UpdateRelation updates an existing relation.
func (db *DB) UpdateRelation(r *Relation) error {
r.UpdatedAt = time.Now().UTC()
var startedAt, endedAt *string
if r.StartedAt != nil {
s := r.StartedAt.Format(time.RFC3339)
startedAt = &s
}
if r.EndedAt != nil {
s := r.EndedAt.Format(time.RFC3339)
endedAt = &s
}
_, err := db.conn.Exec(`
UPDATE relations SET name=?, from_entity=?, to_entity=?, via=?, description=?, purity=?, direction=?, weight=?, status=?, started_at=?, ended_at=?, "order"=?, tags=?, notes=?, updated_at=?
WHERE id=?`,
r.Name, r.FromEntity, r.ToEntity, r.Via, r.Description,
r.Purity, string(r.Direction), r.Weight, string(r.Status),
startedAt, endedAt, r.Order, marshalStrings(r.Tags), r.Notes,
r.UpdatedAt.Format(time.RFC3339), r.ID,
)
return err
}
// DeleteRelation removes a relation by ID (cascades to relation_inputs).
func (db *DB) DeleteRelation(id string) error {
_, err := db.conn.Exec("DELETE FROM relations WHERE id = ?", id)
return err
}
// ListRelations returns all relations, optionally filtered by entity involvement.
func (db *DB) ListRelations(entityID string) ([]Relation, error) {
q := `SELECT id, name, from_entity, to_entity, via, description, purity, direction, weight, status, started_at, ended_at, "order", tags, notes, created_at, updated_at FROM relations`
var args []any
if entityID != "" {
q += " WHERE from_entity = ? OR to_entity = ?"
args = append(args, entityID, entityID)
}
q += " ORDER BY name"
rows, err := db.conn.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var result []Relation
for rows.Next() {
r, err := scanRelationFromRows(rows)
if err != nil {
return nil, err
}
result = append(result, *r)
}
return result, nil
}
// GetRelationsFrom returns all relations where from_entity matches.
func (db *DB) GetRelationsFrom(entityID string) ([]Relation, error) {
rows, err := db.conn.Query(`
SELECT id, name, from_entity, to_entity, via, description, purity, direction, weight, status, started_at, ended_at, "order", tags, notes, created_at, updated_at
FROM relations WHERE from_entity = ? ORDER BY name`, entityID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []Relation
for rows.Next() {
r, err := scanRelationFromRows(rows)
if err != nil {
return nil, err
}
result = append(result, *r)
}
return result, nil
}
// GetRelationsTo returns all relations where to_entity matches.
func (db *DB) GetRelationsTo(entityID string) ([]Relation, error) {
rows, err := db.conn.Query(`
SELECT id, name, from_entity, to_entity, via, description, purity, direction, weight, status, started_at, ended_at, "order", tags, notes, created_at, updated_at
FROM relations WHERE to_entity = ? ORDER BY name`, entityID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []Relation
for rows.Next() {
r, err := scanRelationFromRows(rows)
if err != nil {
return nil, err
}
result = append(result, *r)
}
return result, nil
}
func scanRelation(row *sql.Row) (*Relation, error) {
var r Relation
var tagsJSON, createdAt, updatedAt string
var startedAt, endedAt *string
err := row.Scan(&r.ID, &r.Name, &r.FromEntity, &r.ToEntity, &r.Via, &r.Description,
&r.Purity, &r.Direction, &r.Weight, &r.Status,
&startedAt, &endedAt, &r.Order, &tagsJSON, &r.Notes, &createdAt, &updatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scanning relation: %w", err)
}
r.Tags = unmarshalStrings(tagsJSON)
r.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
r.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
if startedAt != nil {
t, _ := time.Parse(time.RFC3339, *startedAt)
r.StartedAt = &t
}
if endedAt != nil {
t, _ := time.Parse(time.RFC3339, *endedAt)
r.EndedAt = &t
}
return &r, nil
}
func scanRelationFromRows(rows *sql.Rows) (*Relation, error) {
var r Relation
var tagsJSON, createdAt, updatedAt string
var startedAt, endedAt *string
err := rows.Scan(&r.ID, &r.Name, &r.FromEntity, &r.ToEntity, &r.Via, &r.Description,
&r.Purity, &r.Direction, &r.Weight, &r.Status,
&startedAt, &endedAt, &r.Order, &tagsJSON, &r.Notes, &createdAt, &updatedAt)
if err != nil {
return nil, fmt.Errorf("scanning relation: %w", err)
}
r.Tags = unmarshalStrings(tagsJSON)
r.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
r.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
if startedAt != nil {
t, _ := time.Parse(time.RFC3339, *startedAt)
r.StartedAt = &t
}
if endedAt != nil {
t, _ := time.Parse(time.RFC3339, *endedAt)
r.EndedAt = &t
}
return &r, nil
}
// --- RelationInput CRUD ---
// InsertRelationInput inserts a relation input.
func (db *DB) InsertRelationInput(ri *RelationInput) error {
_, err := db.conn.Exec(`
INSERT INTO relation_inputs (id, relation_id, entity_id, role, "order")
VALUES (?, ?, ?, ?, ?)`,
ri.ID, ri.RelationID, ri.EntityID, ri.Role, ri.Order,
)
return err
}
// GetRelationInputs returns all inputs for a relation.
func (db *DB) GetRelationInputs(relationID string) ([]RelationInput, error) {
rows, err := db.conn.Query(`
SELECT id, relation_id, entity_id, role, "order"
FROM relation_inputs WHERE relation_id = ? ORDER BY "order"`, relationID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []RelationInput
for rows.Next() {
var ri RelationInput
if err := rows.Scan(&ri.ID, &ri.RelationID, &ri.EntityID, &ri.Role, &ri.Order); err != nil {
return nil, fmt.Errorf("scanning relation_input: %w", err)
}
result = append(result, ri)
}
return result, nil
}
// DeleteRelationInputs removes all inputs for a relation.
func (db *DB) DeleteRelationInputs(relationID string) error {
_, err := db.conn.Exec("DELETE FROM relation_inputs WHERE relation_id = ?", relationID)
return err
}
+181
View File
@@ -0,0 +1,181 @@
package fn_operations
import (
"fmt"
"strings"
)
// ValidationError represents one or more integrity violations.
type ValidationError struct {
ID string
Errors []string
}
func (v *ValidationError) Error() string {
return fmt.Sprintf("%s: %s", v.ID, strings.Join(v.Errors, "; "))
}
// ValidateEntity checks entity integrity rules.
func ValidateEntity(e *Entity) *ValidationError {
var errs []string
if e.ID == "" {
errs = append(errs, "id is required")
}
if e.Name == "" {
errs = append(errs, "name is required")
}
if e.TypeRef == "" {
errs = append(errs, "type_ref is required")
}
if e.Source == "" {
errs = append(errs, "source is required")
}
switch e.Status {
case StatusActive, StatusStale, StatusCorrupted, StatusArchived:
case "":
errs = append(errs, "status is required")
default:
errs = append(errs, fmt.Sprintf("invalid status: %s", e.Status))
}
if len(errs) > 0 {
return &ValidationError{ID: e.ID, Errors: errs}
}
return nil
}
// ValidateRelation checks relation integrity rules.
// knownEntities is a set of entity IDs that exist.
func ValidateRelation(r *Relation, knownEntities map[string]bool) *ValidationError {
var errs []string
if r.ID == "" {
errs = append(errs, "id is required")
}
if r.Name == "" {
errs = append(errs, "name is required")
}
if r.ToEntity == "" {
errs = append(errs, "to_entity is required")
}
// from_entity or relation_inputs — validated at operation level
if r.FromEntity != "" && r.ToEntity != "" && r.FromEntity == r.ToEntity {
errs = append(errs, "from_entity and to_entity cannot be the same")
}
if r.FromEntity != "" && !knownEntities[r.FromEntity] {
errs = append(errs, fmt.Sprintf("from_entity references unknown entity: %s", r.FromEntity))
}
if r.ToEntity != "" && !knownEntities[r.ToEntity] {
errs = append(errs, fmt.Sprintf("to_entity references unknown entity: %s", r.ToEntity))
}
if r.Weight != nil {
if *r.Weight < 0.0 || *r.Weight > 1.0 {
errs = append(errs, "weight must be between 0.0 and 1.0")
}
}
if r.StartedAt != nil && r.EndedAt != nil {
if r.StartedAt.After(*r.EndedAt) {
errs = append(errs, "started_at must be before ended_at")
}
}
switch r.Direction {
case DirUnidirectional, DirBidirectional, DirInverse, "":
default:
errs = append(errs, fmt.Sprintf("invalid direction: %s", r.Direction))
}
if len(errs) > 0 {
return &ValidationError{ID: r.ID, Errors: errs}
}
return nil
}
// ValidateRelationInputs checks relation_inputs integrity.
func ValidateRelationInputs(inputs []RelationInput, knownEntities map[string]bool) *ValidationError {
var errs []string
if len(inputs) < 2 {
errs = append(errs, "relation_inputs must have at least 2 entries")
}
for i, ri := range inputs {
if ri.RelationID == "" {
errs = append(errs, fmt.Sprintf("input[%d]: relation_id is required", i))
}
if ri.EntityID == "" {
errs = append(errs, fmt.Sprintf("input[%d]: entity_id is required", i))
}
if ri.Role == "" {
errs = append(errs, fmt.Sprintf("input[%d]: role is required", i))
}
if ri.EntityID != "" && !knownEntities[ri.EntityID] {
errs = append(errs, fmt.Sprintf("input[%d]: entity_id references unknown entity: %s", i, ri.EntityID))
}
}
if len(errs) > 0 {
id := "relation_inputs"
if len(inputs) > 0 {
id = inputs[0].RelationID
}
return &ValidationError{ID: id, Errors: errs}
}
return nil
}
// DetectCycle checks if adding a causal relation (from -> to) creates a cycle.
// Only considers relations where via != "" (causal/transformational).
// Semantic relations (via == "") are exempt from cycle detection.
func DetectCycle(db *DB, fromEntity, toEntity string) error {
if fromEntity == "" || toEntity == "" {
return nil
}
// BFS from toEntity following only causal relations.
// If we reach fromEntity, there's a cycle.
visited := map[string]bool{}
queue := []string{toEntity}
for len(queue) > 0 {
current := queue[0]
queue = queue[1:]
if visited[current] {
continue
}
visited[current] = true
if current == fromEntity {
return fmt.Errorf("cycle detected: adding relation %s -> %s would create a causal cycle", fromEntity, toEntity)
}
// Follow causal relations from current entity
rows, err := db.conn.Query(`
SELECT to_entity FROM relations
WHERE from_entity = ? AND via != ''`, current)
if err != nil {
return fmt.Errorf("querying relations for cycle detection: %w", err)
}
for rows.Next() {
var next string
if err := rows.Scan(&next); err != nil {
rows.Close()
return err
}
if !visited[next] {
queue = append(queue, next)
}
}
rows.Close()
}
return nil
}
+8
View File
@@ -0,0 +1,8 @@
package docker
// ComposeProject representa un proyecto Docker Compose con sus servicios.
type ComposeProject struct {
Name string
ConfigFiles string
Services []string
}
+17
View File
@@ -0,0 +1,17 @@
---
name: compose_project
lang: go
domain: docker
version: "1.0.0"
algebraic: product
definition: |
type ComposeProject struct {
Name string
ConfigFiles string
Services []string
}
description: "Proyecto Docker Compose con nombre, archivos de configuracion y lista de servicios."
tags: [docker, compose, infra, orchestration]
uses_types: []
file_path: "types/docker/compose_project.go"
---
+11
View File
@@ -0,0 +1,11 @@
package docker
// Container representa un contenedor Docker con su estado y configuracion.
type Container struct {
ID string
Names string
Image string
Status string
State string
Ports string
}
+20
View File
@@ -0,0 +1,20 @@
---
name: container
lang: go
domain: docker
version: "1.0.0"
algebraic: product
definition: |
type Container struct {
ID string
Names string
Image string
Status string
State string
Ports string
}
description: "Contenedor Docker con ID, nombre, imagen, estado y puertos expuestos."
tags: [docker, container, infra]
uses_types: []
file_path: "types/docker/container.go"
---
+10
View File
@@ -0,0 +1,10 @@
package docker
// Image representa una imagen Docker con su repositorio, tag y tamaño.
type Image struct {
ID string
Repository string
Tag string
Size string
CreatedAt string
}
+19
View File
@@ -0,0 +1,19 @@
---
name: image
lang: go
domain: docker
version: "1.0.0"
algebraic: product
definition: |
type Image struct {
ID string
Repository string
Tag string
Size string
CreatedAt string
}
description: "Imagen Docker con repositorio, tag, tamaño y fecha de creacion."
tags: [docker, image, infra]
uses_types: []
file_path: "types/docker/image.go"
---
+9
View File
@@ -0,0 +1,9 @@
package docker
// Network representa una red Docker con nombre, driver y scope.
type Network struct {
ID string
Name string
Driver string
Scope string
}
+18
View File
@@ -0,0 +1,18 @@
---
name: network
lang: go
domain: docker
version: "1.0.0"
algebraic: product
definition: |
type Network struct {
ID string
Name string
Driver string
Scope string
}
description: "Red Docker con nombre, driver y scope (local/global)."
tags: [docker, network, infra]
uses_types: []
file_path: "types/docker/network.go"
---
+8
View File
@@ -0,0 +1,8 @@
package docker
// Volume representa un volumen Docker con nombre, driver y punto de montaje.
type Volume struct {
Name string
Driver string
Mountpoint string
}
+17
View File
@@ -0,0 +1,17 @@
---
name: volume
lang: go
domain: docker
version: "1.0.0"
algebraic: product
definition: |
type Volume struct {
Name string
Driver string
Mountpoint string
}
description: "Volumen Docker con nombre, driver y punto de montaje en el host."
tags: [docker, volume, storage, infra]
uses_types: []
file_path: "types/docker/volume.go"
---