merge: quick/fn-operations-docker-tui — fn_operations library, CLI ops y Docker TUI
This commit is contained in:
+4
-1
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
build/
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
@@ -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}'
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -0,0 +1,6 @@
|
||||
go 1.22.2
|
||||
|
||||
use (
|
||||
.
|
||||
/home/lucas/.local_agentes/backend
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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.
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package docker
|
||||
|
||||
// ComposeProject representa un proyecto Docker Compose con sus servicios.
|
||||
type ComposeProject struct {
|
||||
Name string
|
||||
ConfigFiles string
|
||||
Services []string
|
||||
}
|
||||
@@ -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"
|
||||
---
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
---
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
---
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
---
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
---
|
||||
Reference in New Issue
Block a user