Files
dataforge c9fd4aa84c feat: enrichers, panel de ingest y menu contextual en el grafo
- Añade enricher.go + directorio enrichers/ para enriquecer entidades con fuentes externas.
- Nuevos componentes frontend: IngestPanel (panel de ingesta de datos) y NodeContextMenu (menu contextual sobre nodos del grafo).
- Retira SearchBar y lib/utils.ts; la busqueda se integra dentro de los paneles existentes.
- Ajusta tipos (types.go, types.ts, wailsjs/go) y theming (postcss + app.css + Mantine).
- Actualiza app.go y wails.json para exponer las nuevas capacidades.
- Añade directorio projects/ con estado inicial.
- Rebuild del frontend (dist actualizado).
2026-04-13 23:32:55 +02:00

798 lines
20 KiB
Go

package main
import (
"context"
"crypto/rand"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
ops "fn-registry/fn_operations"
"fn-registry/registry"
)
// App is the Wails-bound application struct.
type App struct {
ctx context.Context
mu sync.RWMutex
projectsDir string
registryRoot string
registryDB *registry.DB
db *ops.DB
currentProj string
}
// NewApp creates a new App instance.
func NewApp(projectsDir, registryRoot string) *App {
log.Printf("[NewApp] projectsDir=%s registryRoot=%s", projectsDir, registryRoot)
return &App{
projectsDir: projectsDir,
registryRoot: registryRoot,
}
}
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
log.Println("[startup] called")
// Open registry.db for type lookups and snapshots
regPath := filepath.Join(a.registryRoot, "registry.db")
log.Printf("[startup] opening registry.db at %s", regPath)
if db, err := registry.Open(regPath); err == nil {
a.registryDB = db
log.Println("[startup] registry.db opened OK")
} else {
log.Printf("[startup] WARNING: registry.db failed: %v", err)
}
// Ensure projects directory exists
log.Printf("[startup] ensuring projectsDir exists: %s", a.projectsDir)
if err := os.MkdirAll(a.projectsDir, 0o755); err != nil {
log.Printf("[startup] ERROR creating projectsDir: %v", err)
}
// List what's there already
entries, err := os.ReadDir(a.projectsDir)
if err != nil {
log.Printf("[startup] ERROR reading projectsDir: %v", err)
} else {
log.Printf("[startup] projectsDir has %d entries", len(entries))
for _, e := range entries {
log.Printf("[startup] - %s (dir=%v)", e.Name(), e.IsDir())
}
}
log.Println("[startup] done")
}
func (a *App) shutdown(_ context.Context) {
log.Println("[shutdown] called")
a.mu.Lock()
defer a.mu.Unlock()
if a.db != nil {
a.db.Close()
log.Println("[shutdown] closed project db")
}
if a.registryDB != nil {
a.registryDB.Close()
log.Println("[shutdown] closed registry db")
}
}
// --- Projects ---
func (a *App) ListProjects() ([]ProjectInfo, error) {
a.mu.RLock()
defer a.mu.RUnlock()
log.Printf("[ListProjects] projectsDir=%s", a.projectsDir)
projects, err := listProjectDirs(a.projectsDir)
if err != nil {
log.Printf("[ListProjects] ERROR: %v", err)
return nil, err
}
log.Printf("[ListProjects] found %d projects", len(projects))
for _, p := range projects {
log.Printf("[ListProjects] - %s (entities=%d relations=%d)", p.Name, p.EntityCount, p.RelCount)
}
return projects, nil
}
func (a *App) CreateProject(name string) (ProjectInfo, error) {
a.mu.Lock()
defer a.mu.Unlock()
name = strings.TrimSpace(name)
log.Printf("[CreateProject] name=%q projectsDir=%s", name, a.projectsDir)
if name == "" {
log.Println("[CreateProject] ERROR: empty name")
return ProjectInfo{}, fmt.Errorf("project name cannot be empty")
}
targetDir := filepath.Join(a.projectsDir, name)
log.Printf("[CreateProject] targetDir=%s", targetDir)
if err := createProject(a.projectsDir, name); err != nil {
log.Printf("[CreateProject] ERROR: %v", err)
return ProjectInfo{}, err
}
// Verify it was created
dbPath := filepath.Join(targetDir, "operations.db")
if _, err := os.Stat(dbPath); err != nil {
log.Printf("[CreateProject] WARNING: operations.db not found after create: %v", err)
} else {
log.Printf("[CreateProject] OK: operations.db exists at %s", dbPath)
}
log.Printf("[CreateProject] success: %s", name)
return ProjectInfo{Name: name}, nil
}
func (a *App) SwitchProject(name string) error {
a.mu.Lock()
defer a.mu.Unlock()
log.Printf("[SwitchProject] name=%q (current=%q)", name, a.currentProj)
if a.db != nil {
a.db.Close()
a.db = nil
log.Println("[SwitchProject] closed previous db")
}
dbPath := filepath.Join(a.projectsDir, name, "operations.db")
log.Printf("[SwitchProject] opening %s", dbPath)
db, err := ops.Open(dbPath)
if err != nil {
log.Printf("[SwitchProject] ERROR: %v", err)
return fmt.Errorf("opening project %s: %w", name, err)
}
a.db = db
a.currentProj = name
log.Printf("[SwitchProject] OK: now on project %s", name)
return nil
}
func (a *App) DeleteProject(name string) error {
a.mu.Lock()
defer a.mu.Unlock()
log.Printf("[DeleteProject] name=%q", name)
if a.currentProj == name && a.db != nil {
a.db.Close()
a.db = nil
a.currentProj = ""
log.Println("[DeleteProject] closed active project db")
}
err := deleteProject(a.projectsDir, name)
if err != nil {
log.Printf("[DeleteProject] ERROR: %v", err)
} else {
log.Printf("[DeleteProject] OK: deleted %s", name)
}
return err
}
func (a *App) GetCurrentProject() string {
a.mu.RLock()
defer a.mu.RUnlock()
log.Printf("[GetCurrentProject] -> %q", a.currentProj)
return a.currentProj
}
// --- Entities ---
func (a *App) ListEntities() ([]ops.Entity, error) {
a.mu.RLock()
defer a.mu.RUnlock()
if a.db == nil {
log.Println("[ListEntities] ERROR: no project selected")
return nil, fmt.Errorf("no project selected")
}
entities, err := a.db.ListEntities("", "")
if err != nil {
log.Printf("[ListEntities] ERROR: %v", err)
} else {
log.Printf("[ListEntities] found %d entities", len(entities))
}
return entities, err
}
func (a *App) GetEntity(id string) (*ops.Entity, error) {
a.mu.RLock()
defer a.mu.RUnlock()
if a.db == nil {
return nil, fmt.Errorf("no project selected")
}
log.Printf("[GetEntity] id=%s", id)
return a.db.GetEntity(id)
}
func (a *App) AddEntity(input EntityInput) (string, error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.db == nil {
log.Println("[AddEntity] ERROR: no project selected")
return "", fmt.Errorf("no project selected")
}
id := makeEntityID(input.Name, input.TypeRef)
log.Printf("[AddEntity] name=%q typeRef=%q -> id=%s", input.Name, input.TypeRef, id)
now := time.Now()
e := &ops.Entity{
ID: id,
Name: input.Name,
TypeRef: input.TypeRef,
Status: ops.StatusActive,
Description: input.Description,
Domain: "osint",
Tags: input.Tags,
Source: "fuzzygraph",
Metadata: input.Metadata,
Notes: input.Notes,
CreatedAt: now,
UpdatedAt: now,
}
// Use InsertEntityWithSnapshot if registry is available
if a.registryDB != nil {
log.Println("[AddEntity] using InsertEntityWithSnapshot")
if err := ops.InsertEntityWithSnapshot(a.db, a.registryDB, e); err != nil {
log.Printf("[AddEntity] ERROR: %v", err)
return "", err
}
} else {
log.Println("[AddEntity] using plain InsertEntity (no registry)")
if err := a.db.InsertEntity(e); err != nil {
log.Printf("[AddEntity] ERROR: %v", err)
return "", err
}
}
log.Printf("[AddEntity] OK: %s", id)
return id, nil
}
func (a *App) UpdateEntity(id string, input EntityInput) error {
a.mu.Lock()
defer a.mu.Unlock()
if a.db == nil {
return fmt.Errorf("no project selected")
}
log.Printf("[UpdateEntity] id=%s", id)
existing, err := a.db.GetEntity(id)
if err != nil {
log.Printf("[UpdateEntity] ERROR getting: %v", err)
return err
}
if existing == nil {
log.Printf("[UpdateEntity] ERROR: not found")
return fmt.Errorf("entity %s not found", id)
}
existing.Name = input.Name
existing.TypeRef = input.TypeRef
existing.Description = input.Description
existing.Tags = input.Tags
existing.Metadata = input.Metadata
existing.Notes = input.Notes
existing.UpdatedAt = time.Now()
if err := a.db.UpdateEntity(existing); err != nil {
log.Printf("[UpdateEntity] ERROR: %v", err)
return err
}
log.Printf("[UpdateEntity] OK: %s", id)
return nil
}
func (a *App) DeleteEntity(id string) error {
a.mu.Lock()
defer a.mu.Unlock()
if a.db == nil {
return fmt.Errorf("no project selected")
}
log.Printf("[DeleteEntity] id=%s", id)
return a.db.DeleteEntity(id)
}
// --- Relations ---
func (a *App) ListRelations() ([]ops.Relation, error) {
a.mu.RLock()
defer a.mu.RUnlock()
if a.db == nil {
log.Println("[ListRelations] ERROR: no project selected")
return nil, fmt.Errorf("no project selected")
}
relations, err := a.db.ListRelations("")
if err != nil {
log.Printf("[ListRelations] ERROR: %v", err)
} else {
log.Printf("[ListRelations] found %d relations", len(relations))
}
return relations, err
}
func (a *App) AddRelation(input RelationInputDTO) (string, error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.db == nil {
return "", fmt.Errorf("no project selected")
}
id := generateID()
log.Printf("[AddRelation] name=%q from=%s to=%s id=%s", input.Name, input.FromEntity, input.ToEntity, id)
now := time.Now()
r := &ops.Relation{
ID: id,
Name: input.Name,
FromEntity: input.FromEntity,
ToEntity: input.ToEntity,
Description: input.Description,
Purity: "impure",
Direction: ops.DirUnidirectional,
Weight: input.Weight,
Status: ops.RelImplemented,
Tags: input.Tags,
Notes: input.Notes,
CreatedAt: now,
UpdatedAt: now,
}
if err := a.db.InsertRelation(r); err != nil {
log.Printf("[AddRelation] ERROR: %v", err)
return "", err
}
log.Printf("[AddRelation] OK: %s", id)
return id, nil
}
func (a *App) UpdateRelation(id string, input RelationInputDTO) error {
a.mu.Lock()
defer a.mu.Unlock()
if a.db == nil {
return fmt.Errorf("no project selected")
}
log.Printf("[UpdateRelation] id=%s", id)
existing, err := a.db.GetRelation(id)
if err != nil {
return err
}
if existing == nil {
return fmt.Errorf("relation %s not found", id)
}
existing.Name = input.Name
existing.FromEntity = input.FromEntity
existing.ToEntity = input.ToEntity
existing.Description = input.Description
existing.Weight = input.Weight
existing.Tags = input.Tags
existing.Notes = input.Notes
existing.UpdatedAt = time.Now()
return a.db.UpdateRelation(existing)
}
func (a *App) DeleteRelation(id string) error {
a.mu.Lock()
defer a.mu.Unlock()
if a.db == nil {
return fmt.Errorf("no project selected")
}
log.Printf("[DeleteRelation] id=%s", id)
return a.db.DeleteRelation(id)
}
// --- Graph ---
func (a *App) GetGraphData() (GraphData, error) {
a.mu.RLock()
defer a.mu.RUnlock()
if a.db == nil {
log.Println("[GetGraphData] no project selected")
return GraphData{}, fmt.Errorf("no project selected")
}
data, err := buildGraphData(a.db)
if err != nil {
log.Printf("[GetGraphData] ERROR: %v", err)
} else {
log.Printf("[GetGraphData] nodes=%d edges=%d", len(data.Nodes), len(data.Edges))
}
return data, err
}
func (a *App) GetEntityNeighbors(entityID string, depth int) (GraphData, error) {
a.mu.RLock()
defer a.mu.RUnlock()
if a.db == nil {
return GraphData{}, fmt.Errorf("no project selected")
}
log.Printf("[GetEntityNeighbors] entityID=%s depth=%d", entityID, depth)
return buildEgoGraph(a.db, entityID, depth)
}
func (a *App) GetFilteredGraph(typeFilters []string) (GraphData, error) {
a.mu.RLock()
defer a.mu.RUnlock()
if a.db == nil {
return GraphData{}, fmt.Errorf("no project selected")
}
log.Printf("[GetFilteredGraph] filters=%v", typeFilters)
full, err := buildGraphData(a.db)
if err != nil {
return GraphData{}, err
}
if len(typeFilters) == 0 {
return full, nil
}
allowed := map[string]bool{}
for _, t := range typeFilters {
allowed[t] = true
}
nodeIDs := map[string]bool{}
var nodes []GraphNode
for _, n := range full.Nodes {
if allowed[n.Type] {
nodes = append(nodes, n)
nodeIDs[n.ID] = true
}
}
var edges []GraphEdge
for _, e := range full.Edges {
if nodeIDs[e.Source] && nodeIDs[e.Target] {
edges = append(edges, e)
}
}
return GraphData{Nodes: nodes, Edges: edges}, nil
}
// --- Search ---
func (a *App) SearchEntities(query string) ([]ops.Entity, error) {
a.mu.RLock()
defer a.mu.RUnlock()
if a.db == nil {
return nil, fmt.Errorf("no project selected")
}
log.Printf("[SearchEntities] query=%q", query)
results, err := searchEntitiesFTS(a.db, query)
if err != nil {
log.Printf("[SearchEntities] ERROR: %v", err)
} else {
log.Printf("[SearchEntities] found %d results", len(results))
}
return results, err
}
func (a *App) SearchGraph(query string) (GraphData, error) {
a.mu.RLock()
defer a.mu.RUnlock()
if a.db == nil {
return GraphData{}, fmt.Errorf("no project selected")
}
log.Printf("[SearchGraph] query=%q", query)
return searchGraph(a.db, query)
}
// --- Assertions ---
func (a *App) ListAssertions(entityID string) ([]ops.Assertion, error) {
a.mu.RLock()
defer a.mu.RUnlock()
if a.db == nil {
return nil, fmt.Errorf("no project selected")
}
log.Printf("[ListAssertions] entityID=%q", entityID)
active := true
return a.db.ListAssertions(entityID, &active)
}
func (a *App) AddAssertion(input AssertionInput) (string, error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.db == nil {
return "", fmt.Errorf("no project selected")
}
id := generateID()
log.Printf("[AddAssertion] name=%q entityID=%s id=%s", input.Name, input.EntityID, id)
assertion := &ops.Assertion{
ID: id,
EntityID: input.EntityID,
Name: input.Name,
Kind: input.Kind,
Rule: input.Rule,
Severity: ops.Severity(input.Severity),
Description: input.Description,
Active: true,
CreatedAt: time.Now(),
}
if err := a.db.InsertAssertion(assertion); err != nil {
log.Printf("[AddAssertion] ERROR: %v", err)
return "", err
}
log.Printf("[AddAssertion] OK: %s", id)
return id, nil
}
func (a *App) DeleteAssertion(id string) error {
a.mu.Lock()
defer a.mu.Unlock()
if a.db == nil {
return fmt.Errorf("no project selected")
}
log.Printf("[DeleteAssertion] id=%s", id)
return a.db.DeleteAssertion(id)
}
func (a *App) EvalAssertions(entityID string) ([]ops.AssertionResult, error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.db == nil {
return nil, fmt.Errorf("no project selected")
}
log.Printf("[EvalAssertions] entityID=%s", entityID)
results, err := ops.EvalEntityAssertions(a.db, entityID, "")
if err != nil {
log.Printf("[EvalAssertions] ERROR: %v", err)
} else {
log.Printf("[EvalAssertions] %d results", len(results))
}
return results, err
}
// --- Presets ---
func (a *App) GetEntityPresets() []EntityTypePreset {
log.Printf("[GetEntityPresets] returning %d presets", len(entityTypePresets))
return entityTypePresets
}
func (a *App) GetRelationPresets() []string {
log.Printf("[GetRelationPresets] returning %d presets", len(relationPresets))
return relationPresets
}
// --- Enrichers ---
func (a *App) GetEnrichers() []EnricherDef {
log.Printf("[GetEnrichers] returning %d enrichers", len(enricherRegistry))
return enricherRegistry
}
func (a *App) GetEnrichersForEntity(entityID string) ([]EnricherDef, error) {
a.mu.RLock()
defer a.mu.RUnlock()
if a.db == nil {
return nil, fmt.Errorf("no project selected")
}
entity, err := a.db.GetEntity(entityID)
if err != nil {
return nil, err
}
if entity == nil {
return nil, fmt.Errorf("entity %s not found", entityID)
}
result := enrichersForType(entity.TypeRef)
log.Printf("[GetEnrichersForEntity] entityID=%s typeRef=%s -> %d enrichers", entityID, entity.TypeRef, len(result))
return result, nil
}
func (a *App) RunEnricher(enricherID, entityID string) (GraphData, error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.db == nil {
return GraphData{}, fmt.Errorf("no project selected")
}
log.Printf("[RunEnricher] enricherID=%s entityID=%s", enricherID, entityID)
enricher := findEnricher(enricherID)
if enricher == nil {
return GraphData{}, fmt.Errorf("enricher %s not found", enricherID)
}
entity, err := a.db.GetEntity(entityID)
if err != nil {
return GraphData{}, err
}
if entity == nil {
return GraphData{}, fmt.Errorf("entity %s not found", entityID)
}
// Serialize entity to JSON for the Python script
entityJSON, err := json.Marshal(map[string]any{
"id": entity.ID,
"name": entity.Name,
"type_ref": entity.TypeRef,
"metadata": entity.Metadata,
})
if err != nil {
return GraphData{}, fmt.Errorf("marshaling entity: %w", err)
}
// Run enricher
enrichersDir := filepath.Join(filepath.Dir(os.Args[0]), "enrichers")
// Fallback: try relative to working directory
if _, err := os.Stat(enrichersDir); err != nil {
enrichersDir = "enrichers"
}
// Fallback: try relative to project dir
if _, err := os.Stat(filepath.Join(enrichersDir, enricher.Script)); err != nil {
// Try from the app source directory
if exePath, err2 := os.Executable(); err2 == nil {
enrichersDir = filepath.Join(filepath.Dir(exePath), "enrichers")
}
}
log.Printf("[RunEnricher] executing %s in %s", enricher.Script, enrichersDir)
result, err := runEnricherScript(a.registryRoot, enrichersDir, enricher.Script, entityJSON)
if err != nil {
log.Printf("[RunEnricher] ERROR: %v", err)
return GraphData{}, err
}
log.Printf("[RunEnricher] result: %d entities, %d relations", len(result.Entities), len(result.Relations))
// Insert results into operations.db
if err := a.insertEnricherResults(result, entityID); err != nil {
log.Printf("[RunEnricher] ERROR inserting results: %v", err)
return GraphData{}, err
}
// Return full graph
data, err := buildGraphData(a.db)
if err != nil {
return GraphData{}, err
}
log.Printf("[RunEnricher] OK: graph now has %d nodes, %d edges", len(data.Nodes), len(data.Edges))
return data, nil
}
// --- Ingest ---
func (a *App) IngestURL(url string) (string, error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.db == nil {
return "", fmt.Errorf("no project selected")
}
url = strings.TrimSpace(url)
if url == "" {
return "", fmt.Errorf("URL cannot be empty")
}
log.Printf("[IngestURL] url=%s", url)
id := makeEntityID(url, "url")
now := time.Now()
e := &ops.Entity{
ID: id,
Name: url,
TypeRef: "url",
Status: ops.StatusActive,
Description: "Ingested URL",
Domain: "fuzzygraph",
Tags: []string{"ingested"},
Source: "fuzzygraph",
Metadata: map[string]any{"url": url},
CreatedAt: now,
UpdatedAt: now,
}
if a.registryDB != nil {
if err := ops.InsertEntityWithSnapshot(a.db, a.registryDB, e); err != nil {
if err2 := a.db.InsertEntity(e); err2 != nil {
return "", err2
}
}
} else {
if err := a.db.InsertEntity(e); err != nil {
return "", err
}
}
log.Printf("[IngestURL] OK: %s", id)
return id, nil
}
func (a *App) IngestFile(filePath string) (string, error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.db == nil {
return "", fmt.Errorf("no project selected")
}
filePath = strings.TrimSpace(filePath)
if filePath == "" {
return "", fmt.Errorf("file path cannot be empty")
}
log.Printf("[IngestFile] path=%s", filePath)
name := filepath.Base(filePath)
ext := strings.TrimPrefix(filepath.Ext(filePath), ".")
id := makeEntityID(name, "document")
now := time.Now()
e := &ops.Entity{
ID: id,
Name: name,
TypeRef: "document",
Status: ops.StatusActive,
Description: fmt.Sprintf("Ingested document (%s)", ext),
Domain: "fuzzygraph",
Tags: []string{"ingested"},
Source: "fuzzygraph",
Metadata: map[string]any{"file_path": filePath, "format": ext},
CreatedAt: now,
UpdatedAt: now,
}
if a.registryDB != nil {
if err := ops.InsertEntityWithSnapshot(a.db, a.registryDB, e); err != nil {
if err2 := a.db.InsertEntity(e); err2 != nil {
return "", err2
}
}
} else {
if err := a.db.InsertEntity(e); err != nil {
return "", err
}
}
log.Printf("[IngestFile] OK: %s", id)
return id, nil
}
// --- Helpers ---
func makeEntityID(name, typeRef string) string {
clean := strings.ToLower(strings.TrimSpace(name))
clean = strings.ReplaceAll(clean, " ", "_")
clean = strings.ReplaceAll(clean, "-", "_")
// Truncate long names (URLs etc.)
if len(clean) > 60 {
clean = clean[:60]
}
return fmt.Sprintf("%s_%s", clean, typeRef)
}
func generateID() string {
b := make([]byte, 16)
rand.Read(b)
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}