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).
This commit is contained in:
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
@@ -580,20 +581,210 @@ func (a *App) GetRelationPresets() []string {
|
|||||||
return 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 ---
|
// --- Helpers ---
|
||||||
|
|
||||||
func makeEntityID(name, typeRef string) string {
|
func makeEntityID(name, typeRef string) string {
|
||||||
clean := strings.ToLower(strings.TrimSpace(name))
|
clean := strings.ToLower(strings.TrimSpace(name))
|
||||||
clean = strings.ReplaceAll(clean, " ", "_")
|
clean = strings.ReplaceAll(clean, " ", "_")
|
||||||
clean = strings.ReplaceAll(clean, "-", "_")
|
clean = strings.ReplaceAll(clean, "-", "_")
|
||||||
|
// Truncate long names (URLs etc.)
|
||||||
parts := strings.Split(typeRef, "_")
|
if len(clean) > 60 {
|
||||||
shortType := typeRef
|
clean = clean[:60]
|
||||||
if len(parts) >= 2 {
|
|
||||||
shortType = parts[1]
|
|
||||||
}
|
}
|
||||||
|
return fmt.Sprintf("%s_%s", clean, typeRef)
|
||||||
return fmt.Sprintf("%s_%s", clean, shortType)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateID() string {
|
func generateID() string {
|
||||||
|
|||||||
+259
@@ -0,0 +1,259 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
ops "fn-registry/fn_operations"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EnricherDef describes a registered enricher function.
|
||||||
|
type EnricherDef struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
AppliesTo []string `json:"applies_to"` // entity type_refs this enricher works on
|
||||||
|
Script string `json:"script"` // Python script filename in enrichers/
|
||||||
|
Icon string `json:"icon"` // Tabler icon name
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnricherResult is the JSON contract returned by Python enricher scripts.
|
||||||
|
type EnricherResult struct {
|
||||||
|
Entities []EntityInput `json:"entities"`
|
||||||
|
Relations []RelationInputDTO `json:"relations"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
MetadataUpdate *MetadataUpdate `json:"metadata_update,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetadataUpdate allows enrichers to update the source entity's metadata.
|
||||||
|
type MetadataUpdate struct {
|
||||||
|
EntityID string `json:"entity_id"`
|
||||||
|
Metadata map[string]any `json:"metadata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static enricher registry
|
||||||
|
var enricherRegistry = []EnricherDef{
|
||||||
|
{
|
||||||
|
ID: "url_to_text",
|
||||||
|
Label: "Fetch & Extract Text",
|
||||||
|
Description: "Download URL content and extract text",
|
||||||
|
AppliesTo: []string{"url", "domain"},
|
||||||
|
Script: "url_to_text.py",
|
||||||
|
Icon: "IconWorldDownload",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "document_to_text",
|
||||||
|
Label: "Extract Text",
|
||||||
|
Description: "Extract text from document file",
|
||||||
|
AppliesTo: []string{"document"},
|
||||||
|
Script: "document_to_text.py",
|
||||||
|
Icon: "IconFileText",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "text_to_entities",
|
||||||
|
Label: "Extract Entities (LLM)",
|
||||||
|
Description: "Extract entities and relations using AI",
|
||||||
|
AppliesTo: []string{"text"},
|
||||||
|
Script: "text_to_entities.py",
|
||||||
|
Icon: "IconBrain",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "text_to_urls",
|
||||||
|
Label: "Extract URLs",
|
||||||
|
Description: "Find all URLs in text",
|
||||||
|
AppliesTo: []string{"text"},
|
||||||
|
Script: "text_to_urls.py",
|
||||||
|
Icon: "IconLink",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "url_to_headers",
|
||||||
|
Label: "Fetch HTTP Headers",
|
||||||
|
Description: "Retrieve HTTP headers for URL",
|
||||||
|
AppliesTo: []string{"url", "domain"},
|
||||||
|
Script: "url_to_headers.py",
|
||||||
|
Icon: "IconServer",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// enrichersForType returns enrichers applicable to a given entity type.
|
||||||
|
func enrichersForType(typeRef string) []EnricherDef {
|
||||||
|
var result []EnricherDef
|
||||||
|
for _, e := range enricherRegistry {
|
||||||
|
for _, t := range e.AppliesTo {
|
||||||
|
if t == typeRef {
|
||||||
|
result = append(result, e)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// findEnricher looks up an enricher by ID.
|
||||||
|
func findEnricher(id string) *EnricherDef {
|
||||||
|
for i := range enricherRegistry {
|
||||||
|
if enricherRegistry[i].ID == id {
|
||||||
|
return &enricherRegistry[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runEnricherScript executes a Python enricher script and returns the parsed result.
|
||||||
|
func runEnricherScript(registryRoot, enrichersDir, script string, entityJSON []byte) (*EnricherResult, error) {
|
||||||
|
scriptPath := filepath.Join(enrichersDir, script)
|
||||||
|
if _, err := os.Stat(scriptPath); err != nil {
|
||||||
|
return nil, fmt.Errorf("enricher script not found: %s", scriptPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find Python: prefer registry venv, then system
|
||||||
|
pythonPath := filepath.Join(registryRoot, "python", ".venv", "bin", "python3")
|
||||||
|
if _, err := os.Stat(pythonPath); err != nil {
|
||||||
|
pythonPath = "python3"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(pythonPath, scriptPath)
|
||||||
|
cmd.Stdin = strings.NewReader(string(entityJSON))
|
||||||
|
cmd.Dir = enrichersDir
|
||||||
|
|
||||||
|
// Set PYTHONPATH so enricher scripts can import registry functions
|
||||||
|
pypath := strings.Join([]string{
|
||||||
|
filepath.Join(registryRoot, "python", "functions", "core"),
|
||||||
|
filepath.Join(registryRoot, "python", "functions", "cybersecurity"),
|
||||||
|
filepath.Join(registryRoot, "python", "functions", "datascience"),
|
||||||
|
filepath.Join(registryRoot, "analysis", "ontology_graph", "lib"),
|
||||||
|
}, ":")
|
||||||
|
|
||||||
|
cmd.Env = append(os.Environ(),
|
||||||
|
"FN_REGISTRY_ROOT="+registryRoot,
|
||||||
|
"PYTHONPATH="+pypath,
|
||||||
|
)
|
||||||
|
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||||
|
return nil, fmt.Errorf("enricher %s failed: %s", script, string(exitErr.Stderr))
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("enricher %s failed: %w", script, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result EnricherResult
|
||||||
|
if err := json.Unmarshal(output, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("enricher %s: invalid JSON output: %w", script, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Error != "" {
|
||||||
|
return nil, fmt.Errorf("enricher %s: %s", script, result.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertEnricherResults inserts entities and relations from an enricher result,
|
||||||
|
// resolving __NEW_N__ and __SOURCE__ placeholders.
|
||||||
|
func (a *App) insertEnricherResults(result *EnricherResult, sourceEntityID string) error {
|
||||||
|
newIDs := make([]string, len(result.Entities))
|
||||||
|
|
||||||
|
// Insert entities
|
||||||
|
for i, ei := range result.Entities {
|
||||||
|
id := makeEntityID(ei.Name, ei.TypeRef)
|
||||||
|
now := time.Now()
|
||||||
|
e := &ops.Entity{
|
||||||
|
ID: id,
|
||||||
|
Name: ei.Name,
|
||||||
|
TypeRef: ei.TypeRef,
|
||||||
|
Status: ops.StatusActive,
|
||||||
|
Description: ei.Description,
|
||||||
|
Domain: "fuzzygraph",
|
||||||
|
Tags: ei.Tags,
|
||||||
|
Source: "enricher",
|
||||||
|
Metadata: ei.Metadata,
|
||||||
|
Notes: ei.Notes,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
if a.registryDB != nil {
|
||||||
|
if err := ops.InsertEntityWithSnapshot(a.db, a.registryDB, e); err != nil {
|
||||||
|
// Entity might already exist — try update instead
|
||||||
|
if err2 := a.db.InsertEntity(e); err2 != nil {
|
||||||
|
log.Printf("[insertEnricherResults] WARNING: skip entity %s: %v", id, err2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := a.db.InsertEntity(e); err != nil {
|
||||||
|
log.Printf("[insertEnricherResults] WARNING: skip entity %s: %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newIDs[i] = id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert relations with placeholder resolution
|
||||||
|
for _, ri := range result.Relations {
|
||||||
|
from := resolvePlaceholder(ri.FromEntity, sourceEntityID, newIDs)
|
||||||
|
to := resolvePlaceholder(ri.ToEntity, sourceEntityID, newIDs)
|
||||||
|
if from == "" || to == "" || from == to {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
id := generateID()
|
||||||
|
now := time.Now()
|
||||||
|
r := &ops.Relation{
|
||||||
|
ID: id,
|
||||||
|
Name: ri.Name,
|
||||||
|
FromEntity: from,
|
||||||
|
ToEntity: to,
|
||||||
|
Description: ri.Description,
|
||||||
|
Purity: "impure",
|
||||||
|
Direction: ops.DirUnidirectional,
|
||||||
|
Weight: ri.Weight,
|
||||||
|
Status: ops.RelImplemented,
|
||||||
|
Tags: ri.Tags,
|
||||||
|
Notes: ri.Notes,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
if err := a.db.InsertRelation(r); err != nil {
|
||||||
|
log.Printf("[insertEnricherResults] WARNING: skip relation %s->%s: %v", from, to, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle metadata update on source entity
|
||||||
|
if result.MetadataUpdate != nil && result.MetadataUpdate.EntityID != "" {
|
||||||
|
targetID := resolvePlaceholder(result.MetadataUpdate.EntityID, sourceEntityID, newIDs)
|
||||||
|
if existing, err := a.db.GetEntity(targetID); err == nil && existing != nil {
|
||||||
|
if existing.Metadata == nil {
|
||||||
|
existing.Metadata = map[string]any{}
|
||||||
|
}
|
||||||
|
for k, v := range result.MetadataUpdate.Metadata {
|
||||||
|
existing.Metadata[k] = v
|
||||||
|
}
|
||||||
|
existing.UpdatedAt = time.Now()
|
||||||
|
if err := a.db.UpdateEntity(existing); err != nil {
|
||||||
|
log.Printf("[insertEnricherResults] WARNING: metadata update failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolvePlaceholder converts __SOURCE__, __NEW_0__ etc. to actual entity IDs.
|
||||||
|
func resolvePlaceholder(val, sourceID string, newIDs []string) string {
|
||||||
|
if val == "__SOURCE__" {
|
||||||
|
return sourceID
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(val, "__NEW_") && strings.HasSuffix(val, "__") {
|
||||||
|
idxStr := val[6 : len(val)-2]
|
||||||
|
var idx int
|
||||||
|
if _, err := fmt.Sscanf(idxStr, "%d", &idx); err == nil && idx >= 0 && idx < len(newIDs) {
|
||||||
|
return newIDs[idx]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Enricher: Extract text from a document file."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.environ.get("FN_REGISTRY_ROOT", ""), "python", "functions", "core"))
|
||||||
|
|
||||||
|
from extract_text_from_file import extract_text_from_file
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
entity = json.load(sys.stdin)
|
||||||
|
file_path = (entity.get("metadata") or {}).get("file_path", "")
|
||||||
|
|
||||||
|
if not file_path:
|
||||||
|
json.dump({"error": "No file_path in entity metadata"}, sys.stdout)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
json.dump({"error": f"File not found: {file_path}"}, sys.stdout)
|
||||||
|
return
|
||||||
|
|
||||||
|
text = extract_text_from_file(file_path)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"name": f"Text: {os.path.basename(file_path)}",
|
||||||
|
"type_ref": "text",
|
||||||
|
"description": f"Text extracted from {os.path.basename(file_path)}",
|
||||||
|
"tags": ["extracted"],
|
||||||
|
"metadata": {
|
||||||
|
"content_preview": text[:500],
|
||||||
|
"source": file_path,
|
||||||
|
"char_count": len(text),
|
||||||
|
"full_content": text,
|
||||||
|
},
|
||||||
|
"notes": "",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"relations": [
|
||||||
|
{
|
||||||
|
"name": "extracted_from",
|
||||||
|
"from_entity": "__NEW_0__",
|
||||||
|
"to_entity": "__SOURCE__",
|
||||||
|
"description": f"Text extracted from document",
|
||||||
|
"weight": 1.0,
|
||||||
|
"tags": [],
|
||||||
|
"notes": "",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
json.dump(result, sys.stdout, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
"""Enricher: Extract entities + relations from text using LLM (claude -p haiku)."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
|
||||||
|
# Registry functions
|
||||||
|
ROOT = os.environ.get("FN_REGISTRY_ROOT", "")
|
||||||
|
sys.path.insert(0, os.path.join(ROOT, "python", "functions", "core"))
|
||||||
|
sys.path.insert(0, os.path.join(ROOT, "python", "functions", "datascience"))
|
||||||
|
sys.path.insert(0, os.path.join(ROOT, "python", "functions", "cybersecurity"))
|
||||||
|
sys.path.insert(0, os.path.join(ROOT, "analysis", "ontology_graph", "lib"))
|
||||||
|
|
||||||
|
from core_functions import extract_json_from_llm, preprocess_text
|
||||||
|
from split_text_into_chunks import split_text_into_chunks
|
||||||
|
from deduplicate_entities import deduplicate_entities
|
||||||
|
from deduplicate_relations import deduplicate_relations
|
||||||
|
|
||||||
|
# ── Presets ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
ENTITY_PRESETS = [
|
||||||
|
{"type_ref": "person", "label": "Person",
|
||||||
|
"metadata_fields": ["full_name", "alias", "nationality", "dob", "gender", "risk_score"]},
|
||||||
|
{"type_ref": "organization", "label": "Organization",
|
||||||
|
"metadata_fields": ["legal_name", "country", "sector", "founded", "risk_score"]},
|
||||||
|
{"type_ref": "location", "label": "Location",
|
||||||
|
"metadata_fields": ["lat", "lon", "address", "country", "city"]},
|
||||||
|
{"type_ref": "event", "label": "Event",
|
||||||
|
"metadata_fields": ["event_type", "date", "location", "description", "severity"]},
|
||||||
|
{"type_ref": "email", "label": "Email",
|
||||||
|
"metadata_fields": ["address", "provider", "verified", "breached"]},
|
||||||
|
{"type_ref": "domain", "label": "Domain",
|
||||||
|
"metadata_fields": ["fqdn", "registrar", "created_date", "expires_date"]},
|
||||||
|
{"type_ref": "ip_address", "label": "IP Address",
|
||||||
|
"metadata_fields": ["ip", "asn", "country", "isp", "geolocation"]},
|
||||||
|
{"type_ref": "phone", "label": "Phone",
|
||||||
|
"metadata_fields": ["number", "country_code", "carrier", "phone_type"]},
|
||||||
|
{"type_ref": "document", "label": "Document",
|
||||||
|
"metadata_fields": ["title", "format", "classification", "source"]},
|
||||||
|
{"type_ref": "url", "label": "URL/Link",
|
||||||
|
"metadata_fields": ["url", "domain", "context"]},
|
||||||
|
{"type_ref": "concept", "label": "Concept",
|
||||||
|
"metadata_fields": ["name", "category", "definition"]},
|
||||||
|
{"type_ref": "date_reference", "label": "Date/Time",
|
||||||
|
"metadata_fields": ["date", "precision", "context"]},
|
||||||
|
{"type_ref": "quantity", "label": "Quantity/Amount",
|
||||||
|
"metadata_fields": ["value", "unit", "context"]},
|
||||||
|
]
|
||||||
|
|
||||||
|
RELATION_TYPES = [
|
||||||
|
"employs", "works_for", "founded", "owns", "controls",
|
||||||
|
"member_of", "affiliated_with", "collaborates_with",
|
||||||
|
"communicates_with", "sent_to", "received_from",
|
||||||
|
"located_in", "headquartered_in", "operates_in",
|
||||||
|
"participated_in", "caused", "occurred_at", "occurred_on",
|
||||||
|
"mentions", "references", "describes", "authored", "published",
|
||||||
|
"funds", "transacted_with", "invested_in",
|
||||||
|
"hosts", "resolves_to", "exploits", "targets",
|
||||||
|
"related_to", "part_of", "instance_of", "has_attribute",
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── Load custom presets ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CUSTOM_PRESETS_PATH = os.path.join(ROOT, "analysis", "ontology_graph", "data", "custom_presets.json")
|
||||||
|
|
||||||
|
def load_custom_presets():
|
||||||
|
if os.path.exists(CUSTOM_PRESETS_PATH):
|
||||||
|
with open(CUSTOM_PRESETS_PATH) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return [p for p in data.get("presets", []) if not p.get("promoted", False)]
|
||||||
|
return []
|
||||||
|
|
||||||
|
# ── LLM ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def claude_haiku_json(messages):
|
||||||
|
parts = []
|
||||||
|
for msg in messages:
|
||||||
|
if msg["role"] == "system":
|
||||||
|
parts.append(f"[SYSTEM]\n{msg['content']}")
|
||||||
|
elif msg["role"] == "user":
|
||||||
|
parts.append(f"[USER]\n{msg['content']}")
|
||||||
|
prompt = "\n\n".join(parts)
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
["claude", "-p", "--model", "haiku", "--output-format", "json", prompt],
|
||||||
|
capture_output=True, text=True, timeout=120,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return {}
|
||||||
|
envelope = json.loads(result.stdout)
|
||||||
|
return extract_json_from_llm(envelope.get("result", ""))
|
||||||
|
|
||||||
|
# ── Prompt ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def build_prompt(presets, rel_types):
|
||||||
|
type_lines = []
|
||||||
|
for p in presets:
|
||||||
|
fields = ", ".join(p.get("metadata_fields", []))
|
||||||
|
type_lines.append(f"- {p['label']} (type_ref: {p['type_ref']}): [{fields}]")
|
||||||
|
|
||||||
|
return (
|
||||||
|
"You are an entity and relation extraction expert. "
|
||||||
|
"Given text, extract ALL entities and relations in a single pass.\n\n"
|
||||||
|
"ENTITY TYPES:\n" + "\n".join(type_lines) + "\n\n"
|
||||||
|
"RELATION TYPES: " + ", ".join(rel_types) + "\n\n"
|
||||||
|
'OUTPUT FORMAT (strict JSON):\n'
|
||||||
|
'{\n'
|
||||||
|
' "entities": [{"name": "...", "type_ref": "...", "attributes": {...}, "confidence": 0.9}],\n'
|
||||||
|
' "relations": [{"from_name": "...", "to_name": "...", "relation_type": "...", "confidence": 0.8, "description": "..."}]\n'
|
||||||
|
'}\n\n'
|
||||||
|
"RULES:\n"
|
||||||
|
"- Extract ALL entities explicitly mentioned\n"
|
||||||
|
"- Use exact type_ref from schema. Unknown attributes = null\n"
|
||||||
|
"- Confidence: 1.0=explicit, 0.7=strongly implied, 0.5=weakly implied\n"
|
||||||
|
"- Relations: from_name/to_name MUST match entity names exactly\n"
|
||||||
|
"- Respond in the same language as the text for descriptions"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Process chunk ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def process_chunk(chunk_text, system_prompt):
|
||||||
|
try:
|
||||||
|
resp = claude_haiku_json([
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": chunk_text},
|
||||||
|
])
|
||||||
|
return resp.get("entities", []), resp.get("relations", [])
|
||||||
|
except Exception:
|
||||||
|
return [], []
|
||||||
|
|
||||||
|
# ── Main ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main():
|
||||||
|
entity = json.load(sys.stdin)
|
||||||
|
text = (entity.get("metadata") or {}).get("full_content", "")
|
||||||
|
|
||||||
|
if not text:
|
||||||
|
json.dump({"error": "No text content in entity metadata"}, sys.stdout)
|
||||||
|
return
|
||||||
|
|
||||||
|
text = preprocess_text(text)
|
||||||
|
chunks = split_text_into_chunks(text, chunk_size=2000, overlap=200)
|
||||||
|
|
||||||
|
all_presets = ENTITY_PRESETS + load_custom_presets()
|
||||||
|
system_prompt = build_prompt(all_presets, RELATION_TYPES)
|
||||||
|
|
||||||
|
# Parallel extraction
|
||||||
|
from entity_candidate import EntityCandidate
|
||||||
|
from relation_candidate import RelationCandidate
|
||||||
|
|
||||||
|
all_entities = []
|
||||||
|
all_relations_raw = []
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||||
|
futures = {pool.submit(process_chunk, chunk, system_prompt): i for i, chunk in enumerate(chunks)}
|
||||||
|
for future in as_completed(futures):
|
||||||
|
ents, rels = future.result()
|
||||||
|
for e in ents:
|
||||||
|
name = e.get("name", "").strip()
|
||||||
|
if name and e.get("confidence", 0) >= 0.5:
|
||||||
|
all_entities.append(EntityCandidate(
|
||||||
|
name=name,
|
||||||
|
type_ref=e.get("type_ref", "concept"),
|
||||||
|
attributes=e.get("attributes", {}),
|
||||||
|
confidence=float(e.get("confidence", 0.5)),
|
||||||
|
source_chunk_indices=[futures[future]],
|
||||||
|
))
|
||||||
|
for r in rels:
|
||||||
|
fn = r.get("from_name", "").strip()
|
||||||
|
tn = r.get("to_name", "").strip()
|
||||||
|
if fn and tn:
|
||||||
|
all_relations_raw.append(RelationCandidate(
|
||||||
|
from_name=fn, to_name=tn,
|
||||||
|
relation_type=r.get("relation_type", "related_to"),
|
||||||
|
confidence=float(r.get("confidence", 0.5)),
|
||||||
|
description=r.get("description", ""),
|
||||||
|
source_chunk_index=futures[future],
|
||||||
|
))
|
||||||
|
|
||||||
|
# Dedup
|
||||||
|
if all_entities:
|
||||||
|
dedup = deduplicate_entities(all_entities, name_threshold=0.85)
|
||||||
|
final_entities = dedup.entities
|
||||||
|
entity_id_map = dedup.name_to_id
|
||||||
|
final_relations = deduplicate_relations(all_relations_raw, entity_id_map)
|
||||||
|
else:
|
||||||
|
final_entities = []
|
||||||
|
final_relations = []
|
||||||
|
|
||||||
|
# Convert to enricher output format
|
||||||
|
entities_out = []
|
||||||
|
relations_out = []
|
||||||
|
|
||||||
|
for i, e in enumerate(final_entities):
|
||||||
|
attrs = {k: str(v) for k, v in (e.attributes or {}).items() if v is not None}
|
||||||
|
entities_out.append({
|
||||||
|
"name": e.name,
|
||||||
|
"type_ref": e.type_ref,
|
||||||
|
"description": f"Extracted from text ({e.confidence:.0%} confidence)",
|
||||||
|
"tags": ["extracted", "llm"],
|
||||||
|
"metadata": attrs,
|
||||||
|
"notes": "",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Build name→index map for relations
|
||||||
|
name_to_idx = {}
|
||||||
|
for i, e in enumerate(final_entities):
|
||||||
|
name_to_idx[e.name] = i
|
||||||
|
name_to_idx[e.name.lower().strip()] = i
|
||||||
|
|
||||||
|
for r in final_relations:
|
||||||
|
from_idx = name_to_idx.get(r.from_name) or name_to_idx.get(r.from_name.lower().strip())
|
||||||
|
to_idx = name_to_idx.get(r.to_name) or name_to_idx.get(r.to_name.lower().strip())
|
||||||
|
if from_idx is not None and to_idx is not None:
|
||||||
|
relations_out.append({
|
||||||
|
"name": r.relation_type,
|
||||||
|
"from_entity": f"__NEW_{from_idx}__",
|
||||||
|
"to_entity": f"__NEW_{to_idx}__",
|
||||||
|
"description": r.description,
|
||||||
|
"weight": r.confidence,
|
||||||
|
"tags": ["extracted"],
|
||||||
|
"notes": "",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Also connect all entities to source text node
|
||||||
|
for i in range(len(entities_out)):
|
||||||
|
relations_out.append({
|
||||||
|
"name": "extracted_from",
|
||||||
|
"from_entity": f"__NEW_{i}__",
|
||||||
|
"to_entity": "__SOURCE__",
|
||||||
|
"description": "Entity extracted from text",
|
||||||
|
"weight": 1.0,
|
||||||
|
"tags": [],
|
||||||
|
"notes": "",
|
||||||
|
})
|
||||||
|
|
||||||
|
json.dump({"entities": entities_out, "relations": relations_out}, sys.stdout, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Enricher: Extract URLs from a text node."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.environ.get("FN_REGISTRY_ROOT", ""), "python", "functions", "cybersecurity"))
|
||||||
|
|
||||||
|
from cybersecurity import extract_urls
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
entity = json.load(sys.stdin)
|
||||||
|
text = (entity.get("metadata") or {}).get("full_content", "")
|
||||||
|
|
||||||
|
if not text:
|
||||||
|
text = entity.get("description", "")
|
||||||
|
|
||||||
|
if not text:
|
||||||
|
json.dump({"error": "No text content found in entity"}, sys.stdout)
|
||||||
|
return
|
||||||
|
|
||||||
|
urls = extract_urls(text)
|
||||||
|
|
||||||
|
# Deduplicate
|
||||||
|
seen = set()
|
||||||
|
unique_urls = []
|
||||||
|
for u in urls:
|
||||||
|
normalized = u.rstrip("/").lower()
|
||||||
|
if normalized not in seen:
|
||||||
|
seen.add(normalized)
|
||||||
|
unique_urls.append(u)
|
||||||
|
|
||||||
|
entities = []
|
||||||
|
relations = []
|
||||||
|
|
||||||
|
for i, url in enumerate(unique_urls):
|
||||||
|
# Extract domain from URL
|
||||||
|
domain = ""
|
||||||
|
try:
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
domain = urlparse(url).netloc
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
entities.append({
|
||||||
|
"name": url[:80],
|
||||||
|
"type_ref": "url",
|
||||||
|
"description": f"URL found in text",
|
||||||
|
"tags": ["extracted"],
|
||||||
|
"metadata": {
|
||||||
|
"url": url,
|
||||||
|
"domain": domain,
|
||||||
|
},
|
||||||
|
"notes": "",
|
||||||
|
})
|
||||||
|
relations.append({
|
||||||
|
"name": "contains",
|
||||||
|
"from_entity": "__SOURCE__",
|
||||||
|
"to_entity": f"__NEW_{i}__",
|
||||||
|
"description": "URL found in text",
|
||||||
|
"weight": 1.0,
|
||||||
|
"tags": [],
|
||||||
|
"notes": "",
|
||||||
|
})
|
||||||
|
|
||||||
|
json.dump({"entities": entities, "relations": relations}, sys.stdout, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Enricher: Fetch HTTP headers for a URL and update entity metadata."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
except ImportError:
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
entity = json.load(sys.stdin)
|
||||||
|
url = (entity.get("metadata") or {}).get("url") or entity.get("name", "")
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
json.dump({"error": "No URL found in entity"}, sys.stdout)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
resp = httpx.head(url, follow_redirects=True, timeout=10)
|
||||||
|
headers = dict(resp.headers)
|
||||||
|
status = resp.status_code
|
||||||
|
except NameError:
|
||||||
|
req = urllib.request.Request(url, method="HEAD")
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
headers = dict(resp.headers)
|
||||||
|
status = resp.status
|
||||||
|
except Exception as e:
|
||||||
|
json.dump({"error": f"Failed to fetch headers: {e}"}, sys.stdout)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Return metadata update for the source entity
|
||||||
|
result = {
|
||||||
|
"entities": [],
|
||||||
|
"relations": [],
|
||||||
|
"metadata_update": {
|
||||||
|
"entity_id": entity["id"],
|
||||||
|
"metadata": {
|
||||||
|
"status_code": status,
|
||||||
|
"server": headers.get("server", ""),
|
||||||
|
"content_type": headers.get("content-type", ""),
|
||||||
|
"x_powered_by": headers.get("x-powered-by", ""),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
json.dump(result, sys.stdout, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Enricher: Fetch URL and produce a text node."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.environ.get("FN_REGISTRY_ROOT", ""), "python", "functions", "core"))
|
||||||
|
|
||||||
|
from fetch_and_parse_url import fetch_and_parse_url
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
entity = json.load(sys.stdin)
|
||||||
|
url = (entity.get("metadata") or {}).get("url") or entity.get("name", "")
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
json.dump({"error": "No URL found in entity metadata or name"}, sys.stdout)
|
||||||
|
return
|
||||||
|
|
||||||
|
text = fetch_and_parse_url(url)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"name": f"Text: {url[:60]}",
|
||||||
|
"type_ref": "text",
|
||||||
|
"description": f"Text extracted from {url}",
|
||||||
|
"tags": ["extracted"],
|
||||||
|
"metadata": {
|
||||||
|
"content_preview": text[:500],
|
||||||
|
"source": url,
|
||||||
|
"char_count": len(text),
|
||||||
|
"full_content": text,
|
||||||
|
},
|
||||||
|
"notes": "",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"relations": [
|
||||||
|
{
|
||||||
|
"name": "extracted_from",
|
||||||
|
"from_entity": "__NEW_0__",
|
||||||
|
"to_entity": "__SOURCE__",
|
||||||
|
"description": "Text extracted from URL",
|
||||||
|
"weight": 1.0,
|
||||||
|
"tags": [],
|
||||||
|
"notes": "",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
json.dump(result, sys.stdout, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+413
File diff suppressed because one or more lines are too long
-320
File diff suppressed because one or more lines are too long
-2
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -4,8 +4,8 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>FuzzyGraph</title>
|
<title>FuzzyGraph</title>
|
||||||
<script type="module" crossorigin src="/assets/index-CYqMr7xa.js"></script>
|
<script type="module" crossorigin src="/assets/index-4p79H44C.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-Cjyz0t73.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-vp4DQNbX.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
+8
-11
@@ -9,24 +9,21 @@
|
|||||||
"preview": "vite preview --host"
|
"preview": "vite preview --host"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.3.0",
|
"@mantine/core": "^9.0.0",
|
||||||
"@phosphor-icons/react": "^2.1.10",
|
"@mantine/hooks": "^9.0.0",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@mantine/notifications": "^9.0.0",
|
||||||
"@radix-ui/react-slider": "^1.3.6",
|
"@tabler/icons-react": "^3.41.1",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"lucide-react": "^0.577.0",
|
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4"
|
||||||
"tailwind-merge": "^3.5.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.0",
|
"@vitejs/plugin-react": "^6.0.0",
|
||||||
"tailwindcss": "^4.2.2",
|
"postcss": "^8.5.8",
|
||||||
|
"postcss-preset-mantine": "^1.18.0",
|
||||||
|
"postcss-simple-vars": "^7.0.1",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"vite": "^8.0.0"
|
"vite": "^8.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
925e378ac695fa8339fd9576604d1d9f
|
994f04234280ec7657894460ce41d791
|
||||||
Generated
+360
-604
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
'postcss-preset-mantine': {},
|
||||||
|
'postcss-simple-vars': {
|
||||||
|
variables: {
|
||||||
|
'mantine-breakpoint-xs': '36em',
|
||||||
|
'mantine-breakpoint-sm': '48em',
|
||||||
|
'mantine-breakpoint-md': '62em',
|
||||||
|
'mantine-breakpoint-lg': '75em',
|
||||||
|
'mantine-breakpoint-xl': '88em',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
+162
-91
@@ -1,13 +1,16 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import type { ProjectInfo, Entity, Relation, GraphData, EntityTypePreset } from './types'
|
import { AppShell, Group, Text, Center, Box } from '@mantine/core'
|
||||||
|
import { notifications } from '@mantine/notifications'
|
||||||
|
import type { ProjectInfo, Entity, Relation, GraphData, EntityTypePreset, EnricherDef } from './types'
|
||||||
import { ProjectSidebar } from './components/ProjectSidebar'
|
import { ProjectSidebar } from './components/ProjectSidebar'
|
||||||
import { SearchBar } from '@fn_library'
|
import { SearchBar, Tabs, TabsList, TabsTrigger, TabsContent } from '@fn_library'
|
||||||
import { GraphView } from './components/GraphView'
|
import { GraphView } from './components/GraphView'
|
||||||
import { EntityTable } from './components/EntityTable'
|
import { EntityTable } from './components/EntityTable'
|
||||||
import { RelationTable } from './components/RelationTable'
|
import { RelationTable } from './components/RelationTable'
|
||||||
import { EntityDetail } from './components/EntityDetail'
|
import { EntityDetail } from './components/EntityDetail'
|
||||||
import { AssertionPanel } from './components/AssertionPanel'
|
import { AssertionPanel } from './components/AssertionPanel'
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@fn_library'
|
import { NodeContextMenu } from './components/NodeContextMenu'
|
||||||
|
import { IngestPanel } from './components/IngestPanel'
|
||||||
import * as WailsApp from './wailsjs/go/main/App'
|
import * as WailsApp from './wailsjs/go/main/App'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -21,46 +24,46 @@ export default function App() {
|
|||||||
const [activeTab, setActiveTab] = useState('graph')
|
const [activeTab, setActiveTab] = useState('graph')
|
||||||
const [selectedEntityId, setSelectedEntityId] = useState<string | null>(null)
|
const [selectedEntityId, setSelectedEntityId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Enricher state
|
||||||
|
const [enrichers, setEnrichers] = useState<EnricherDef[]>([])
|
||||||
|
const [ctxMenu, setCtxMenu] = useState<{ position: { x: number; y: number }; nodeId: string; nodeType: string } | null>(null)
|
||||||
|
const [ctxEnrichers, setCtxEnrichers] = useState<EnricherDef[]>([])
|
||||||
|
const [runningEnricher, setRunningEnricher] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('[App] mount — loading presets and projects')
|
|
||||||
refreshProjects()
|
refreshProjects()
|
||||||
WailsApp.GetEntityPresets()
|
WailsApp.GetEntityPresets()
|
||||||
.then(p => { console.log('[App] GetEntityPresets OK:', p?.length, 'presets'); setPresets(p as unknown as EntityTypePreset[]) })
|
.then(p => setPresets(p as unknown as EntityTypePreset[]))
|
||||||
.catch(e => console.error('[App] GetEntityPresets ERROR:', e))
|
.catch(e => console.error('[App] GetEntityPresets ERROR:', e))
|
||||||
WailsApp.GetRelationPresets()
|
WailsApp.GetRelationPresets()
|
||||||
.then(p => { console.log('[App] GetRelationPresets OK:', p?.length); setRelationPresets(p) })
|
.then(p => setRelationPresets(p))
|
||||||
.catch(e => console.error('[App] GetRelationPresets ERROR:', e))
|
.catch(e => console.error('[App] GetRelationPresets ERROR:', e))
|
||||||
|
WailsApp.GetEnrichers()
|
||||||
|
.then(e => setEnrichers((e || []) as unknown as EnricherDef[]))
|
||||||
|
.catch(e => console.error('[App] GetEnrichers ERROR:', e))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const refreshProjects = useCallback(() => {
|
const refreshProjects = useCallback(() => {
|
||||||
console.log('[App] refreshProjects called')
|
|
||||||
WailsApp.ListProjects()
|
WailsApp.ListProjects()
|
||||||
.then(p => {
|
.then(p => setProjects((p || []) as unknown as ProjectInfo[]))
|
||||||
const list = (p || []) as unknown as ProjectInfo[]
|
|
||||||
console.log('[App] ListProjects OK:', list.length, 'projects', JSON.stringify(list))
|
|
||||||
setProjects(list)
|
|
||||||
})
|
|
||||||
.catch(e => console.error('[App] ListProjects ERROR:', e))
|
.catch(e => console.error('[App] ListProjects ERROR:', e))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const refreshData = useCallback(() => {
|
const refreshData = useCallback(() => {
|
||||||
console.log('[App] refreshData called')
|
|
||||||
WailsApp.ListEntities()
|
WailsApp.ListEntities()
|
||||||
.then(e => { const list = (e || []) as unknown as Entity[]; console.log('[App] ListEntities OK:', list.length); setEntities(list) })
|
.then(e => setEntities((e || []) as unknown as Entity[]))
|
||||||
.catch(e => console.error('[App] ListEntities ERROR:', e))
|
.catch(e => console.error('[App] ListEntities ERROR:', e))
|
||||||
WailsApp.ListRelations()
|
WailsApp.ListRelations()
|
||||||
.then(r => { const list = (r || []) as unknown as Relation[]; console.log('[App] ListRelations OK:', list.length); setRelations(list) })
|
.then(r => setRelations((r || []) as unknown as Relation[]))
|
||||||
.catch(e => console.error('[App] ListRelations ERROR:', e))
|
.catch(e => console.error('[App] ListRelations ERROR:', e))
|
||||||
WailsApp.GetGraphData()
|
WailsApp.GetGraphData()
|
||||||
.then(g => { const data = (g || { nodes: [], edges: [] }) as unknown as GraphData; console.log('[App] GetGraphData OK: nodes=', data.nodes?.length, 'edges=', data.edges?.length); setGraphData(data) })
|
.then(g => setGraphData((g || { nodes: [], edges: [] }) as unknown as GraphData))
|
||||||
.catch(e => console.error('[App] GetGraphData ERROR:', e))
|
.catch(e => console.error('[App] GetGraphData ERROR:', e))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleSwitchProject = useCallback(async (name: string) => {
|
const handleSwitchProject = useCallback(async (name: string) => {
|
||||||
console.log('[App] handleSwitchProject:', name)
|
|
||||||
try {
|
try {
|
||||||
await WailsApp.SwitchProject(name)
|
await WailsApp.SwitchProject(name)
|
||||||
console.log('[App] SwitchProject OK')
|
|
||||||
setCurrentProject(name)
|
setCurrentProject(name)
|
||||||
refreshData()
|
refreshData()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -69,24 +72,18 @@ export default function App() {
|
|||||||
}, [refreshData])
|
}, [refreshData])
|
||||||
|
|
||||||
const handleCreateProject = useCallback(async (name: string) => {
|
const handleCreateProject = useCallback(async (name: string) => {
|
||||||
console.log('[App] handleCreateProject:', name)
|
|
||||||
try {
|
try {
|
||||||
const result = await WailsApp.CreateProject(name)
|
await WailsApp.CreateProject(name)
|
||||||
console.log('[App] CreateProject OK:', JSON.stringify(result))
|
|
||||||
refreshProjects()
|
refreshProjects()
|
||||||
console.log('[App] switching to new project...')
|
|
||||||
await handleSwitchProject(name)
|
await handleSwitchProject(name)
|
||||||
console.log('[App] switched OK')
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[App] CreateProject ERROR:', e)
|
console.error('[App] CreateProject ERROR:', e)
|
||||||
}
|
}
|
||||||
}, [refreshProjects, handleSwitchProject])
|
}, [refreshProjects, handleSwitchProject])
|
||||||
|
|
||||||
const handleDeleteProject = useCallback(async (name: string) => {
|
const handleDeleteProject = useCallback(async (name: string) => {
|
||||||
console.log('[App] handleDeleteProject:', name)
|
|
||||||
try {
|
try {
|
||||||
await WailsApp.DeleteProject(name)
|
await WailsApp.DeleteProject(name)
|
||||||
console.log('[App] DeleteProject OK')
|
|
||||||
if (currentProject === name) {
|
if (currentProject === name) {
|
||||||
setCurrentProject('')
|
setCurrentProject('')
|
||||||
setEntities([])
|
setEntities([])
|
||||||
@@ -100,7 +97,6 @@ export default function App() {
|
|||||||
}, [currentProject, refreshProjects])
|
}, [currentProject, refreshProjects])
|
||||||
|
|
||||||
const handleSearch = useCallback(async (query: string) => {
|
const handleSearch = useCallback(async (query: string) => {
|
||||||
console.log('[App] handleSearch:', query)
|
|
||||||
if (!query.trim()) {
|
if (!query.trim()) {
|
||||||
refreshData()
|
refreshData()
|
||||||
return
|
return
|
||||||
@@ -110,7 +106,6 @@ export default function App() {
|
|||||||
WailsApp.SearchEntities(query),
|
WailsApp.SearchEntities(query),
|
||||||
WailsApp.SearchGraph(query),
|
WailsApp.SearchGraph(query),
|
||||||
])
|
])
|
||||||
console.log('[App] Search OK: entities=', (ents as unknown[])?.length, 'graph nodes=', (graph as unknown as GraphData)?.nodes?.length)
|
|
||||||
setEntities((ents || []) as unknown as Entity[])
|
setEntities((ents || []) as unknown as Entity[])
|
||||||
setGraphData((graph || { nodes: [], edges: [] }) as unknown as GraphData)
|
setGraphData((graph || { nodes: [], edges: [] }) as unknown as GraphData)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -119,12 +114,10 @@ export default function App() {
|
|||||||
}, [refreshData])
|
}, [refreshData])
|
||||||
|
|
||||||
const handleNodeClick = useCallback((nodeId: string) => {
|
const handleNodeClick = useCallback((nodeId: string) => {
|
||||||
console.log('[App] handleNodeClick:', nodeId)
|
|
||||||
setSelectedEntityId(nodeId)
|
setSelectedEntityId(nodeId)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleNodeDoubleClick = useCallback(async (nodeId: string) => {
|
const handleNodeDoubleClick = useCallback(async (nodeId: string) => {
|
||||||
console.log('[App] handleNodeDoubleClick:', nodeId)
|
|
||||||
try {
|
try {
|
||||||
const ego = await WailsApp.GetEntityNeighbors(nodeId, 2)
|
const ego = await WailsApp.GetEntityNeighbors(nodeId, 2)
|
||||||
setGraphData((ego || { nodes: [], edges: [] }) as unknown as GraphData)
|
setGraphData((ego || { nodes: [], edges: [] }) as unknown as GraphData)
|
||||||
@@ -133,83 +126,161 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Context menu handler
|
||||||
|
const handleContextMenu = useCallback((event: MouseEvent, target: { type: string; id?: string; data?: Record<string, unknown> }) => {
|
||||||
|
if (target.type === 'node' && target.id && target.data) {
|
||||||
|
const nodeType = String(target.data?.entityType ?? target.data?.type ?? '')
|
||||||
|
const applicable = enrichers.filter(e => e.applies_to.includes(nodeType))
|
||||||
|
if (applicable.length > 0) {
|
||||||
|
setCtxMenu({
|
||||||
|
position: { x: event.clientX, y: event.clientY },
|
||||||
|
nodeId: target.id,
|
||||||
|
nodeType,
|
||||||
|
})
|
||||||
|
setCtxEnrichers(applicable)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setCtxMenu(null)
|
||||||
|
}
|
||||||
|
}, [enrichers])
|
||||||
|
|
||||||
|
// Run enricher
|
||||||
|
const handleRunEnricher = useCallback(async (enricherId: string) => {
|
||||||
|
if (!ctxMenu) return
|
||||||
|
const nodeId = ctxMenu.nodeId
|
||||||
|
setCtxMenu(null)
|
||||||
|
setRunningEnricher(enricherId)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await WailsApp.RunEnricher(enricherId, nodeId)
|
||||||
|
setGraphData((result || { nodes: [], edges: [] }) as unknown as GraphData)
|
||||||
|
refreshData()
|
||||||
|
notifications.show({ title: 'Enricher complete', message: `${enricherId} finished`, color: 'green' })
|
||||||
|
} catch (e: any) {
|
||||||
|
notifications.show({ title: 'Enricher failed', message: String(e), color: 'red' })
|
||||||
|
console.error('[App] RunEnricher ERROR:', e)
|
||||||
|
} finally {
|
||||||
|
setRunningEnricher(null)
|
||||||
|
}
|
||||||
|
}, [ctxMenu, refreshData])
|
||||||
|
|
||||||
|
// Ingest handlers
|
||||||
|
const handleIngestURL = useCallback(async (url: string) => {
|
||||||
|
try {
|
||||||
|
await WailsApp.IngestURL(url)
|
||||||
|
refreshData()
|
||||||
|
notifications.show({ title: 'URL ingested', message: url, color: 'blue' })
|
||||||
|
} catch (e: any) {
|
||||||
|
notifications.show({ title: 'Ingest failed', message: String(e), color: 'red' })
|
||||||
|
}
|
||||||
|
}, [refreshData])
|
||||||
|
|
||||||
|
const handleIngestFile = useCallback(async (path: string) => {
|
||||||
|
try {
|
||||||
|
await WailsApp.IngestFile(path)
|
||||||
|
refreshData()
|
||||||
|
notifications.show({ title: 'File ingested', message: path, color: 'blue' })
|
||||||
|
} catch (e: any) {
|
||||||
|
notifications.show({ title: 'Ingest failed', message: String(e), color: 'red' })
|
||||||
|
}
|
||||||
|
}, [refreshData])
|
||||||
|
|
||||||
const selectedEntity = entities.find(e => e.id === selectedEntityId) ?? null
|
const selectedEntity = entities.find(e => e.id === selectedEntityId) ?? null
|
||||||
|
|
||||||
console.log('[App] render: projects=', projects.length, 'currentProject=', currentProject, 'entities=', entities.length, 'relations=', relations.length)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden">
|
<AppShell
|
||||||
<ProjectSidebar
|
navbar={{ width: 224, breakpoint: 0 }}
|
||||||
projects={projects}
|
padding={0}
|
||||||
current={currentProject}
|
>
|
||||||
onSwitch={handleSwitchProject}
|
<AppShell.Navbar>
|
||||||
onCreate={handleCreateProject}
|
<ProjectSidebar
|
||||||
onDelete={handleDeleteProject}
|
projects={projects}
|
||||||
/>
|
current={currentProject}
|
||||||
|
onSwitch={handleSwitchProject}
|
||||||
|
onCreate={handleCreateProject}
|
||||||
|
onDelete={handleDeleteProject}
|
||||||
|
/>
|
||||||
|
</AppShell.Navbar>
|
||||||
|
|
||||||
<main className="flex-1 flex flex-col overflow-hidden">
|
<AppShell.Main style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
|
||||||
{currentProject ? (
|
{currentProject ? (
|
||||||
<>
|
<>
|
||||||
<div className="px-4 pt-3 pb-2 flex items-center gap-3 border-b" style={{ borderColor: 'var(--border)' }}>
|
<Group px="md" py="xs" gap="sm" style={{ borderBottom: '1px solid var(--mantine-color-dark-4)' }}>
|
||||||
<h2 className="text-sm font-semibold" style={{ color: 'var(--foreground)' }}>{currentProject}</h2>
|
<Text size="sm" fw={600}>{currentProject}</Text>
|
||||||
<SearchBar onSearch={handleSearch} />
|
<Box flex={1}>
|
||||||
</div>
|
<SearchBar onSearch={handleSearch} />
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col overflow-hidden">
|
<Box flex={1} style={{ display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
<TabsList className="mx-4 mt-2">
|
<Tabs value={activeTab} onTabChange={(v) => v && setActiveTab(v)}>
|
||||||
<TabsTrigger value="graph">Graph</TabsTrigger>
|
<TabsList style={{ margin: '8px 16px 0' }}>
|
||||||
<TabsTrigger value="entities">Entities ({entities.length})</TabsTrigger>
|
<TabsTrigger value="graph">Graph</TabsTrigger>
|
||||||
<TabsTrigger value="relations">Relations ({relations.length})</TabsTrigger>
|
<TabsTrigger value="entities">Entities ({entities.length})</TabsTrigger>
|
||||||
<TabsTrigger value="assertions">Assertions</TabsTrigger>
|
<TabsTrigger value="relations">Relations ({relations.length})</TabsTrigger>
|
||||||
</TabsList>
|
<TabsTrigger value="assertions">Assertions</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="graph" className="flex-1 flex overflow-hidden m-0 p-0">
|
<TabsContent value="graph" style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
<div className="flex-1 relative">
|
<IngestPanel onIngestURL={handleIngestURL} onIngestFile={handleIngestFile} />
|
||||||
<GraphView
|
<Box flex={1} pos="relative" style={{ display: 'flex', overflow: 'hidden', minHeight: 0 }}>
|
||||||
data={graphData}
|
<Box flex={1} style={{ minHeight: 0, height: '100%' }}>
|
||||||
|
<GraphView
|
||||||
|
data={graphData}
|
||||||
|
presets={presets}
|
||||||
|
onNodeClick={handleNodeClick}
|
||||||
|
onNodeDoubleClick={handleNodeDoubleClick}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{selectedEntity && (
|
||||||
|
<EntityDetail
|
||||||
|
entity={selectedEntity}
|
||||||
|
relations={relations}
|
||||||
|
onClose={() => setSelectedEntityId(null)}
|
||||||
|
onUpdate={refreshData}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<NodeContextMenu
|
||||||
|
position={ctxMenu?.position ?? null}
|
||||||
|
nodeId={ctxMenu?.nodeId ?? null}
|
||||||
|
enrichers={ctxEnrichers}
|
||||||
|
running={runningEnricher}
|
||||||
|
onRun={handleRunEnricher}
|
||||||
|
onClose={() => setCtxMenu(null)}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="entities" style={{ flex: 1, overflow: 'auto', padding: '0 16px 16px' }}>
|
||||||
|
<EntityTable
|
||||||
|
entities={entities}
|
||||||
presets={presets}
|
presets={presets}
|
||||||
onNodeClick={handleNodeClick}
|
onRefresh={refreshData}
|
||||||
onNodeDoubleClick={handleNodeDoubleClick}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</TabsContent>
|
||||||
{selectedEntity && (
|
|
||||||
<EntityDetail
|
<TabsContent value="relations" style={{ flex: 1, overflow: 'auto', padding: '0 16px 16px' }}>
|
||||||
entity={selectedEntity}
|
<RelationTable
|
||||||
relations={relations}
|
relations={relations}
|
||||||
onClose={() => setSelectedEntityId(null)}
|
entities={entities}
|
||||||
onUpdate={refreshData}
|
relationPresets={relationPresets}
|
||||||
|
onRefresh={refreshData}
|
||||||
/>
|
/>
|
||||||
)}
|
</TabsContent>
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="entities" className="flex-1 overflow-auto px-4 pb-4 m-0">
|
<TabsContent value="assertions" style={{ flex: 1, overflow: 'auto', padding: '0 16px 16px' }}>
|
||||||
<EntityTable
|
<AssertionPanel entities={entities} />
|
||||||
entities={entities}
|
</TabsContent>
|
||||||
presets={presets}
|
</Tabs>
|
||||||
onRefresh={refreshData}
|
</Box>
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="relations" className="flex-1 overflow-auto px-4 pb-4 m-0">
|
|
||||||
<RelationTable
|
|
||||||
relations={relations}
|
|
||||||
entities={entities}
|
|
||||||
relationPresets={relationPresets}
|
|
||||||
onRefresh={refreshData}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="assertions" className="flex-1 overflow-auto px-4 pb-4 m-0">
|
|
||||||
<AssertionPanel entities={entities} />
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex items-center justify-center">
|
<Center flex={1}>
|
||||||
<p style={{ color: 'var(--muted-foreground)' }}>Select or create a project to begin</p>
|
<Text c="dimmed">Select or create a project to begin</Text>
|
||||||
</div>
|
</Center>
|
||||||
)}
|
)}
|
||||||
</main>
|
</AppShell.Main>
|
||||||
</div>
|
</AppShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,3 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--background: oklch(8% 0.015 260);
|
|
||||||
--foreground: oklch(95% 0.01 260);
|
|
||||||
--muted: oklch(18% 0.02 260);
|
|
||||||
--muted-foreground: oklch(60% 0.02 260);
|
|
||||||
--border: oklch(15% 0.01 260);
|
|
||||||
--primary: oklch(65% 0.22 260);
|
|
||||||
--primary-foreground: oklch(98% 0.01 260);
|
|
||||||
--secondary: oklch(20% 0.02 260);
|
|
||||||
--secondary-foreground: oklch(95% 0.01 260);
|
|
||||||
--accent: oklch(18% 0.03 260);
|
|
||||||
--accent-foreground: oklch(95% 0.01 260);
|
|
||||||
--destructive: oklch(55% 0.22 25);
|
|
||||||
--destructive-foreground: oklch(98% 0.01 260);
|
|
||||||
--card: oklch(11% 0.015 260);
|
|
||||||
--card-foreground: oklch(95% 0.01 260);
|
|
||||||
--popover: oklch(12% 0.015 260);
|
|
||||||
--popover-foreground: oklch(95% 0.01 260);
|
|
||||||
--ring: oklch(65% 0.22 260);
|
|
||||||
--input: oklch(22% 0.02 260);
|
|
||||||
--radius: 0.5rem;
|
|
||||||
--success: oklch(65% 0.2 145);
|
|
||||||
--success-foreground: oklch(98% 0.01 145);
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: var(--background);
|
|
||||||
color: var(--foreground);
|
|
||||||
font-family: 'Geist Variable', system-ui, -apple-system, sans-serif;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="card"] {
|
|
||||||
border: none;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { Entity, Assertion, AssertionResult, AssertionInput } from '../types'
|
import { Table, Group, Text, TextInput } from '@mantine/core'
|
||||||
import { Plus, Play, Trash2 } from 'lucide-react'
|
import { IconPlus, IconPlayerPlay, IconTrash } from '@tabler/icons-react'
|
||||||
import { SimpleSelect } from '@fn_library'
|
import { Card, CardHeader, CardTitle, CardContent, SimpleSelect, Button, FnActionIcon } from '@fn_library'
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@fn_library'
|
|
||||||
import { Button } from '@fn_library'
|
|
||||||
import * as WailsApp from '../wailsjs/go/main/App'
|
import * as WailsApp from '../wailsjs/go/main/App'
|
||||||
|
import type { Entity, Assertion, AssertionResult, AssertionInput } from '../types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
entities: Entity[]
|
entities: Entity[]
|
||||||
@@ -54,37 +53,39 @@ export function AssertionPanel({ entities }: Props) {
|
|||||||
loadAssertions(selectedEntity)
|
loadAssertions(selectedEntity)
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputStyle = { background: 'var(--input)', color: 'var(--foreground)', border: '1px solid var(--border)' }
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="mt-3">
|
<Card variant="default" style={{ marginTop: 12 }}>
|
||||||
<CardHeader className="flex flex-row items-center justify-between py-3">
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Assertions</CardTitle>
|
<Group justify="space-between" align="center" py="xs">
|
||||||
<div className="flex gap-2">
|
<CardTitle>Assertions</CardTitle>
|
||||||
<SimpleSelect
|
<Group gap="sm">
|
||||||
value={selectedEntity}
|
<SimpleSelect
|
||||||
onValueChange={loadAssertions}
|
value={selectedEntity}
|
||||||
placeholder="Select entity..."
|
onValueChange={loadAssertions}
|
||||||
className="w-48"
|
placeholder="Select entity..."
|
||||||
options={entities.map(e => ({ value: e.id, label: e.name }))}
|
options={entities.map(e => ({ value: e.id, label: e.name }))}
|
||||||
/>
|
/>
|
||||||
<Button size="sm" onClick={handleEval} disabled={!selectedEntity}>
|
<Button size="sm" onClick={handleEval} disabled={!selectedEntity}>
|
||||||
<Play size={14} className="mr-1" /> Eval
|
<IconPlayerPlay size={14} style={{ marginRight: 4 }} /> Eval
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="outline" onClick={() => setShowAdd(!showAdd)} disabled={!selectedEntity}>
|
<Button size="sm" variant="outline" onClick={() => setShowAdd(!showAdd)} disabled={!selectedEntity}>
|
||||||
<Plus size={14} />
|
<IconPlus size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</Group>
|
||||||
|
</Group>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
{showAdd && (
|
{showAdd && (
|
||||||
<div className="px-4 pb-3 flex gap-2 items-end">
|
<Group px="md" pb="sm" gap="sm" align="flex-end">
|
||||||
<div className="flex-1">
|
<TextInput
|
||||||
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Name</label>
|
label="Name"
|
||||||
<input value={newName} onChange={e => setNewName(e.target.value)} className="w-full px-2 py-1 rounded text-sm" style={inputStyle} />
|
value={newName}
|
||||||
</div>
|
onChange={e => setNewName(e.currentTarget.value)}
|
||||||
<div className="w-24">
|
size="xs"
|
||||||
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Kind</label>
|
flex={1}
|
||||||
|
/>
|
||||||
|
<div style={{ width: 96 }}>
|
||||||
|
<Text size="xs" fw={500} mb={4}>Kind</Text>
|
||||||
<SimpleSelect
|
<SimpleSelect
|
||||||
value={newKind}
|
value={newKind}
|
||||||
onValueChange={setNewKind}
|
onValueChange={setNewKind}
|
||||||
@@ -97,8 +98,8 @@ export function AssertionPanel({ entities }: Props) {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-24">
|
<div style={{ width: 96 }}>
|
||||||
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Severity</label>
|
<Text size="xs" fw={500} mb={4}>Severity</Text>
|
||||||
<SimpleSelect
|
<SimpleSelect
|
||||||
value={newSeverity}
|
value={newSeverity}
|
||||||
onValueChange={setNewSeverity}
|
onValueChange={setNewSeverity}
|
||||||
@@ -109,61 +110,77 @@ export function AssertionPanel({ entities }: Props) {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<TextInput
|
||||||
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Rule (SQL expr)</label>
|
label="Rule (SQL expr)"
|
||||||
<input value={newRule} onChange={e => setNewRule(e.target.value)} className="w-full px-2 py-1 rounded text-sm" style={inputStyle} placeholder="risk_score > 70" />
|
value={newRule}
|
||||||
</div>
|
onChange={e => setNewRule(e.currentTarget.value)}
|
||||||
<button onClick={handleAdd} className="px-3 py-1 rounded text-sm" style={{ background: 'var(--primary)', color: 'var(--primary-foreground)' }}>Add</button>
|
placeholder="risk_score > 70"
|
||||||
</div>
|
size="xs"
|
||||||
|
flex={1}
|
||||||
|
/>
|
||||||
|
<Button size="sm" onClick={handleAdd}>Add</Button>
|
||||||
|
</Group>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CardContent className="p-0">
|
<CardContent style={{ padding: 0 }}>
|
||||||
<table className="w-full text-sm">
|
<Table highlightOnHover>
|
||||||
<thead>
|
<Table.Thead>
|
||||||
<tr className="border-b" style={{ borderColor: 'var(--border)' }}>
|
<Table.Tr>
|
||||||
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Name</th>
|
<Table.Th>Name</Table.Th>
|
||||||
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Kind</th>
|
<Table.Th>Kind</Table.Th>
|
||||||
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Rule</th>
|
<Table.Th>Rule</Table.Th>
|
||||||
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Severity</th>
|
<Table.Th>Severity</Table.Th>
|
||||||
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Result</th>
|
<Table.Th>Result</Table.Th>
|
||||||
<th className="text-right px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Actions</th>
|
<Table.Th ta="right">Actions</Table.Th>
|
||||||
</tr>
|
</Table.Tr>
|
||||||
</thead>
|
</Table.Thead>
|
||||||
<tbody>
|
<Table.Tbody>
|
||||||
{assertions.map(a => {
|
{assertions.map(a => {
|
||||||
const result = results.find(r => r.assertion_id === a.id)
|
const result = results.find(r => r.assertion_id === a.id)
|
||||||
return (
|
return (
|
||||||
<tr key={a.id} className="border-b" style={{ borderColor: 'var(--border)' }}>
|
<Table.Tr key={a.id}>
|
||||||
<td className="px-4 py-2">{a.name}</td>
|
<Table.Td>{a.name}</Table.Td>
|
||||||
<td className="px-4 py-2" style={{ color: 'var(--muted-foreground)' }}>{a.kind}</td>
|
<Table.Td>
|
||||||
<td className="px-4 py-2 font-mono text-xs">{a.rule}</td>
|
<Text size="sm" c="dimmed">{a.kind}</Text>
|
||||||
<td className="px-4 py-2">
|
</Table.Td>
|
||||||
<span style={{ color: a.severity === 'critical' ? 'var(--destructive)' : a.severity === 'warning' ? 'var(--chart-3, #f59e0b)' : 'var(--muted-foreground)' }}>
|
<Table.Td>
|
||||||
|
<Text size="xs" ff="monospace">{a.rule}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm" c={a.severity === 'critical' ? 'red' : a.severity === 'warning' ? 'yellow' : 'dimmed'}>
|
||||||
{a.severity}
|
{a.severity}
|
||||||
</span>
|
</Text>
|
||||||
</td>
|
</Table.Td>
|
||||||
<td className="px-4 py-2">
|
<Table.Td>
|
||||||
{result ? (
|
{result ? (
|
||||||
<span style={{ color: result.status === 'pass' ? 'var(--success)' : 'var(--destructive)' }}>
|
<Text size="sm" c={result.status === 'pass' ? 'teal' : 'red'}>
|
||||||
{result.status}
|
{result.status}
|
||||||
</span>
|
</Text>
|
||||||
) : '—'}
|
) : '—'}
|
||||||
</td>
|
</Table.Td>
|
||||||
<td className="px-4 py-2 text-right">
|
<Table.Td ta="right">
|
||||||
<button onClick={() => handleDelete(a.id)} className="p-1 rounded" style={{ color: 'var(--destructive)' }}>
|
<FnActionIcon
|
||||||
<Trash2 size={14} />
|
icon={<IconTrash size={14} />}
|
||||||
</button>
|
variant="subtle"
|
||||||
</td>
|
size="sm"
|
||||||
</tr>
|
color="red"
|
||||||
|
onClick={() => handleDelete(a.id)}
|
||||||
|
/>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{assertions.length === 0 && (
|
{assertions.length === 0 && (
|
||||||
<tr><td colSpan={6} className="px-4 py-8 text-center" style={{ color: 'var(--muted-foreground)' }}>
|
<Table.Tr>
|
||||||
{selectedEntity ? 'No assertions for this entity' : 'Select an entity to view assertions'}
|
<Table.Td colSpan={6}>
|
||||||
</td></tr>
|
<Text ta="center" c="dimmed" py="xl">
|
||||||
|
{selectedEntity ? 'No assertions for this entity' : 'Select an entity to view assertions'}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</Table.Tbody>
|
||||||
</table>
|
</Table>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import { Box, Stack, Group, Text } from '@mantine/core'
|
||||||
|
import { IconExternalLink } from '@tabler/icons-react'
|
||||||
|
import { Badge, FnActionIcon } from '@fn_library'
|
||||||
|
import { IconX } from '@tabler/icons-react'
|
||||||
import type { Entity, Relation } from '../types'
|
import type { Entity, Relation } from '../types'
|
||||||
import { X, ExternalLink } from 'lucide-react'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
entity: Entity
|
entity: Entity
|
||||||
@@ -12,80 +15,83 @@ export function EntityDetail({ entity, relations, onClose }: Props) {
|
|||||||
const directRelations = relations.filter(r => r.from_entity === entity.id || r.to_entity === entity.id)
|
const directRelations = relations.filter(r => r.from_entity === entity.id || r.to_entity === entity.id)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-72 border-l overflow-y-auto" style={{ borderColor: 'var(--border)', background: 'var(--card)' }}>
|
<Box w={288} style={{ borderLeft: '1px solid var(--mantine-color-dark-4)', overflowY: 'auto' }}>
|
||||||
<div className="p-3 flex items-center justify-between border-b" style={{ borderColor: 'var(--border)' }}>
|
<Group px="sm" py="sm" justify="space-between" style={{ borderBottom: '1px solid var(--mantine-color-dark-4)' }}>
|
||||||
<h3 className="text-sm font-semibold truncate">{entity.name}</h3>
|
<Text size="sm" fw={600} truncate flex={1}>{entity.name}</Text>
|
||||||
<button onClick={onClose} className="p-1">
|
<FnActionIcon
|
||||||
<X size={14} style={{ color: 'var(--muted-foreground)' }} />
|
icon={<IconX size={14} />}
|
||||||
</button>
|
variant="subtle"
|
||||||
</div>
|
size="xs"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
<div className="p-3 space-y-3 text-sm">
|
<Stack gap="sm" p="sm">
|
||||||
<Section label="Type">{entity.type_ref.replace(/_go_cybersecurity$/, '').replace(/^osint_/, '')}</Section>
|
<Section label="Type">{entity.type_ref.replace(/_go_cybersecurity$/, '').replace(/^osint_/, '')}</Section>
|
||||||
<Section label="Status">{entity.status}</Section>
|
<Section label="Status">{entity.status}</Section>
|
||||||
{entity.description && <Section label="Description">{entity.description}</Section>}
|
{entity.description && <Section label="Description">{entity.description}</Section>}
|
||||||
|
|
||||||
{entity.metadata && Object.keys(entity.metadata).length > 0 && (
|
{entity.metadata && Object.keys(entity.metadata).length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-semibold mb-1" style={{ color: 'var(--muted-foreground)' }}>Metadata</label>
|
<Text size="xs" fw={600} c="dimmed" mb={4}>Metadata</Text>
|
||||||
<div className="space-y-1">
|
<Stack gap={4}>
|
||||||
{Object.entries(entity.metadata).map(([k, v]) => (
|
{Object.entries(entity.metadata).map(([k, v]) => (
|
||||||
<div key={k} className="flex justify-between">
|
<Group key={k} justify="space-between">
|
||||||
<span style={{ color: 'var(--muted-foreground)' }}>{k}</span>
|
<Text size="sm" c="dimmed">{k}</Text>
|
||||||
<span className="font-mono text-xs">{String(v)}</span>
|
<Text size="xs" ff="monospace">{String(v)}</Text>
|
||||||
</div>
|
</Group>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Stack>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{entity.notes && (
|
{entity.notes && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-semibold mb-1" style={{ color: 'var(--muted-foreground)' }}>Notes</label>
|
<Text size="xs" fw={600} c="dimmed" mb={4}>Notes</Text>
|
||||||
<p className="text-xs whitespace-pre-wrap" style={{ color: 'var(--foreground)' }}>{entity.notes}</p>
|
<Text size="xs" style={{ whiteSpace: 'pre-wrap' }}>{entity.notes}</Text>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{entity.tags && entity.tags.length > 0 && (
|
{entity.tags && entity.tags.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-semibold mb-1" style={{ color: 'var(--muted-foreground)' }}>Tags</label>
|
<Text size="xs" fw={600} c="dimmed" mb={4}>Tags</Text>
|
||||||
<div className="flex flex-wrap gap-1">
|
<Group gap={4}>
|
||||||
{entity.tags.map(t => (
|
{entity.tags.map(t => (
|
||||||
<span key={t} className="px-1.5 py-0.5 rounded text-xs" style={{ background: 'var(--secondary)', color: 'var(--secondary-foreground)' }}>{t}</span>
|
<Badge key={t} variant="secondary" size="sm">{t}</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Group>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{directRelations.length > 0 && (
|
{directRelations.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-semibold mb-1" style={{ color: 'var(--muted-foreground)' }}>
|
<Text size="xs" fw={600} c="dimmed" mb={4}>
|
||||||
Relations ({directRelations.length})
|
Relations ({directRelations.length})
|
||||||
</label>
|
</Text>
|
||||||
<div className="space-y-1">
|
<Stack gap={4}>
|
||||||
{directRelations.map(r => {
|
{directRelations.map(r => {
|
||||||
const isFrom = r.from_entity === entity.id
|
const isFrom = r.from_entity === entity.id
|
||||||
return (
|
return (
|
||||||
<div key={r.id} className="flex items-center gap-1 text-xs">
|
<Group key={r.id} gap={4}>
|
||||||
<ExternalLink size={10} style={{ color: 'var(--muted-foreground)' }} />
|
<IconExternalLink size={10} style={{ color: 'var(--mantine-color-dimmed)' }} />
|
||||||
<span>{isFrom ? '' : '<-'} {r.name} {isFrom ? '->' : ''}</span>
|
<Text size="xs">{isFrom ? '' : '<-'} {r.name} {isFrom ? '->' : ''}</Text>
|
||||||
<span className="font-medium">{isFrom ? r.to_entity : r.from_entity}</span>
|
<Text size="xs" fw={500}>{isFrom ? r.to_entity : r.from_entity}</Text>
|
||||||
</div>
|
</Group>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</Stack>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Stack>
|
||||||
</aside>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Section({ label, children }: { label: string; children: React.ReactNode }) {
|
function Section({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-semibold" style={{ color: 'var(--muted-foreground)' }}>{label}</label>
|
<Text size="xs" fw={600} c="dimmed">{label}</Text>
|
||||||
<span>{children}</span>
|
<Text size="sm">{children}</Text>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Stack, Group, TextInput, Textarea, Text } from '@mantine/core'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, SimpleSelect, Button } from '@fn_library'
|
||||||
import type { Entity, EntityTypePreset, EntityInput } from '../types'
|
import type { Entity, EntityTypePreset, EntityInput } from '../types'
|
||||||
import { SimpleSelect } from '@fn_library'
|
|
||||||
import { X } from 'lucide-react'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
presets: EntityTypePreset[]
|
presets: EntityTypePreset[]
|
||||||
entity: Entity | null // null = create, non-null = edit
|
entity: Entity | null
|
||||||
onSubmit: (input: EntityInput) => void
|
onSubmit: (input: EntityInput) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,6 @@ export function EntityDialog({ presets, entity, onSubmit, onClose }: Props) {
|
|||||||
const currentPreset = presets.find(p => p.type_ref === typeRef)
|
const currentPreset = presets.find(p => p.type_ref === typeRef)
|
||||||
const metadataFields = currentPreset?.metadata_fields ?? []
|
const metadataFields = currentPreset?.metadata_fields ?? []
|
||||||
|
|
||||||
// When type changes, reset metadata fields to match new type
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!entity) {
|
if (!entity) {
|
||||||
const m: Record<string, string> = {}
|
const m: Record<string, string> = {}
|
||||||
@@ -44,7 +43,6 @@ export function EntityDialog({ presets, entity, onSubmit, onClose }: Props) {
|
|||||||
const cleanMeta: Record<string, unknown> = {}
|
const cleanMeta: Record<string, unknown> = {}
|
||||||
for (const [k, v] of Object.entries(metadata)) {
|
for (const [k, v] of Object.entries(metadata)) {
|
||||||
if (v.trim()) {
|
if (v.trim()) {
|
||||||
// Try parsing as number
|
|
||||||
const num = Number(v)
|
const num = Number(v)
|
||||||
if (!isNaN(num) && v.trim() !== '') {
|
if (!isNaN(num) && v.trim() !== '') {
|
||||||
cleanMeta[k] = num
|
cleanMeta[k] = num
|
||||||
@@ -68,28 +66,23 @@ export function EntityDialog({ presets, entity, onSubmit, onClose }: Props) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputStyle = {
|
|
||||||
background: 'var(--input)',
|
|
||||||
color: 'var(--foreground)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.6)' }}>
|
<Dialog open onOpenChange={(open: boolean) => { if (!open) onClose() }}>
|
||||||
<div className="w-[520px] max-h-[85vh] overflow-y-auto rounded-lg p-5" style={{ background: 'var(--card)', border: '1px solid var(--border)' }}>
|
<DialogContent>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<DialogHeader>
|
||||||
<h3 className="text-base font-semibold">{entity ? 'Edit Entity' : 'New Entity'}</h3>
|
<DialogTitle>{entity ? 'Edit Entity' : 'New Entity'}</DialogTitle>
|
||||||
<button onClick={onClose} className="p-1"><X size={16} style={{ color: 'var(--muted-foreground)' }} /></button>
|
</DialogHeader>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
<Stack gap="sm">
|
||||||
<div>
|
<TextInput
|
||||||
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Name</label>
|
label="Name"
|
||||||
<input value={name} onChange={e => setName(e.target.value)} className="w-full px-3 py-1.5 rounded text-sm" style={inputStyle} />
|
value={name}
|
||||||
</div>
|
onChange={e => setName(e.currentTarget.value)}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Type</label>
|
<Text size="sm" fw={500} mb={4}>Type</Text>
|
||||||
<SimpleSelect
|
<SimpleSelect
|
||||||
value={typeRef}
|
value={typeRef}
|
||||||
onValueChange={setTypeRef}
|
onValueChange={setTypeRef}
|
||||||
@@ -97,64 +90,61 @@ export function EntityDialog({ presets, entity, onSubmit, onClose }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<TextInput
|
||||||
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Description</label>
|
label="Description"
|
||||||
<input value={description} onChange={e => setDescription(e.target.value)} className="w-full px-3 py-1.5 rounded text-sm" style={inputStyle} />
|
value={description}
|
||||||
</div>
|
onChange={e => setDescription(e.currentTarget.value)}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
|
||||||
<div>
|
<TextInput
|
||||||
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Tags (comma separated)</label>
|
label="Tags (comma separated)"
|
||||||
<input value={tagsStr} onChange={e => setTagsStr(e.target.value)} className="w-full px-3 py-1.5 rounded text-sm" style={inputStyle} placeholder="osint, high-risk" />
|
value={tagsStr}
|
||||||
</div>
|
onChange={e => setTagsStr(e.currentTarget.value)}
|
||||||
|
placeholder="osint, high-risk"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
|
||||||
{metadataFields.length > 0 && (
|
{metadataFields.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs mb-1 font-semibold" style={{ color: 'var(--muted-foreground)' }}>
|
<Text size="xs" fw={600} c="dimmed" mb="xs">
|
||||||
Metadata ({currentPreset?.label})
|
Metadata ({currentPreset?.label})
|
||||||
</label>
|
</Text>
|
||||||
<div className="space-y-2">
|
<Stack gap="xs">
|
||||||
{metadataFields.map(field => (
|
{metadataFields.map(field => (
|
||||||
<div key={field} className="flex items-center gap-2">
|
<Group key={field} gap="sm" align="center">
|
||||||
<span className="text-xs w-28 text-right" style={{ color: 'var(--muted-foreground)' }}>{field}</span>
|
<Text size="xs" c="dimmed" w={112} ta="right">{field}</Text>
|
||||||
<input
|
<TextInput
|
||||||
value={metadata[field] ?? ''}
|
value={metadata[field] ?? ''}
|
||||||
onChange={e => setMetadata(prev => ({ ...prev, [field]: e.target.value }))}
|
onChange={e => setMetadata(prev => ({ ...prev, [field]: e.currentTarget.value }))}
|
||||||
className="flex-1 px-2 py-1 rounded text-sm"
|
size="xs"
|
||||||
style={inputStyle}
|
flex={1}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Group>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Stack>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<Textarea
|
||||||
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Notes</label>
|
label="Notes"
|
||||||
<textarea
|
value={notes}
|
||||||
value={notes}
|
onChange={e => setNotes(e.currentTarget.value)}
|
||||||
onChange={e => setNotes(e.target.value)}
|
rows={3}
|
||||||
rows={3}
|
placeholder="Operational notes..."
|
||||||
className="w-full px-3 py-1.5 rounded text-sm resize-none"
|
size="sm"
|
||||||
style={inputStyle}
|
/>
|
||||||
placeholder="Operational notes..."
|
</Stack>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 mt-4">
|
<DialogFooter>
|
||||||
<button onClick={onClose} className="px-3 py-1.5 rounded text-sm" style={{ background: 'var(--secondary)', color: 'var(--secondary-foreground)' }}>
|
<Group justify="flex-end" gap="sm" mt="md">
|
||||||
Cancel
|
<Button variant="secondary" onClick={onClose}>Cancel</Button>
|
||||||
</button>
|
<Button onClick={handleSubmit} disabled={!name.trim()}>
|
||||||
<button
|
{entity ? 'Update' : 'Create'}
|
||||||
onClick={handleSubmit}
|
</Button>
|
||||||
disabled={!name.trim()}
|
</Group>
|
||||||
className="px-3 py-1.5 rounded text-sm font-medium disabled:opacity-40"
|
</DialogFooter>
|
||||||
style={{ background: 'var(--primary)', color: 'var(--primary-foreground)' }}
|
</DialogContent>
|
||||||
>
|
</Dialog>
|
||||||
{entity ? 'Update' : 'Create'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { Entity, EntityTypePreset, EntityInput } from '../types'
|
import { Table, Group, Text } from '@mantine/core'
|
||||||
|
import { IconPlus, IconPencil, IconTrash } from '@tabler/icons-react'
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent, Badge, Button, FnActionIcon } from '@fn_library'
|
||||||
import { EntityDialog } from './EntityDialog'
|
import { EntityDialog } from './EntityDialog'
|
||||||
import { Plus, Pencil, Trash2 } from 'lucide-react'
|
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@fn_library'
|
|
||||||
import { Badge } from '@fn_library'
|
|
||||||
import { Button } from '@fn_library'
|
|
||||||
import { AddEntity, UpdateEntity, DeleteEntity } from '../wailsjs/go/main/App'
|
import { AddEntity, UpdateEntity, DeleteEntity } from '../wailsjs/go/main/App'
|
||||||
|
import type { Entity, EntityTypePreset, EntityInput } from '../types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
entities: Entity[]
|
entities: Entity[]
|
||||||
@@ -39,59 +38,74 @@ export function EntityTable({ entities, presets, onRefresh }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="mt-3">
|
<Card variant="default" style={{ marginTop: 12 }}>
|
||||||
<CardHeader className="flex flex-row items-center justify-between py-3">
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Entities</CardTitle>
|
<Group justify="space-between" align="center" py="xs">
|
||||||
<Button size="sm" onClick={() => setDialogOpen(true)}>
|
<CardTitle>Entities</CardTitle>
|
||||||
<Plus size={14} className="mr-1" /> Add Entity
|
<Button size="sm" onClick={() => setDialogOpen(true)}>
|
||||||
</Button>
|
<IconPlus size={14} style={{ marginRight: 4 }} /> Add Entity
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent style={{ padding: 0 }}>
|
||||||
<table className="w-full text-sm">
|
<Table highlightOnHover>
|
||||||
<thead>
|
<Table.Thead>
|
||||||
<tr className="border-b" style={{ borderColor: 'var(--border)' }}>
|
<Table.Tr>
|
||||||
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Name</th>
|
<Table.Th>Name</Table.Th>
|
||||||
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Type</th>
|
<Table.Th>Type</Table.Th>
|
||||||
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Status</th>
|
<Table.Th>Status</Table.Th>
|
||||||
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Notes</th>
|
<Table.Th>Notes</Table.Th>
|
||||||
<th className="text-right px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Actions</th>
|
<Table.Th ta="right">Actions</Table.Th>
|
||||||
</tr>
|
</Table.Tr>
|
||||||
</thead>
|
</Table.Thead>
|
||||||
<tbody>
|
<Table.Tbody>
|
||||||
{entities.map(e => {
|
{entities.map(e => {
|
||||||
const preset = presetMap[e.type_ref]
|
const preset = presetMap[e.type_ref]
|
||||||
return (
|
return (
|
||||||
<tr key={e.id} className="border-b hover:opacity-90" style={{ borderColor: 'var(--border)' }}>
|
<Table.Tr key={e.id}>
|
||||||
<td className="px-4 py-2 font-medium">{e.name}</td>
|
<Table.Td fw={500}>{e.name}</Table.Td>
|
||||||
<td className="px-4 py-2">
|
<Table.Td>
|
||||||
<Badge style={{ backgroundColor: preset?.color ?? 'var(--muted-foreground)', color: 'var(--primary-foreground)' }}>
|
<Badge style={{ backgroundColor: preset?.color ?? undefined }}>
|
||||||
{preset?.label ?? e.type_ref}
|
{preset?.label ?? e.type_ref}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</Table.Td>
|
||||||
<td className="px-4 py-2">
|
<Table.Td>
|
||||||
<span className="text-xs" style={{ color: e.status === 'active' ? 'var(--success)' : 'var(--muted-foreground)' }}>
|
<Text size="xs" c={e.status === 'active' ? 'teal' : 'dimmed'}>
|
||||||
{e.status}
|
{e.status}
|
||||||
</span>
|
</Text>
|
||||||
</td>
|
</Table.Td>
|
||||||
<td className="px-4 py-2 max-w-48 truncate" style={{ color: 'var(--muted-foreground)' }}>
|
<Table.Td maw={192}>
|
||||||
{e.notes || '—'}
|
<Text size="sm" c="dimmed" truncate>{e.notes || '—'}</Text>
|
||||||
</td>
|
</Table.Td>
|
||||||
<td className="px-4 py-2 text-right">
|
<Table.Td ta="right">
|
||||||
<button onClick={() => setEditEntity(e)} className="p-1 mr-1 rounded hover:opacity-80" style={{ color: 'var(--primary)' }}>
|
<Group gap={4} justify="flex-end">
|
||||||
<Pencil size={14} />
|
<FnActionIcon
|
||||||
</button>
|
icon={<IconPencil size={14} />}
|
||||||
<button onClick={() => handleDelete(e.id)} className="p-1 rounded hover:opacity-80" style={{ color: 'var(--destructive)' }}>
|
variant="subtle"
|
||||||
<Trash2 size={14} />
|
size="sm"
|
||||||
</button>
|
onClick={() => setEditEntity(e)}
|
||||||
</td>
|
/>
|
||||||
</tr>
|
<FnActionIcon
|
||||||
|
icon={<IconTrash size={14} />}
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
color="red"
|
||||||
|
onClick={() => handleDelete(e.id)}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{entities.length === 0 && (
|
{entities.length === 0 && (
|
||||||
<tr><td colSpan={5} className="px-4 py-8 text-center" style={{ color: 'var(--muted-foreground)' }}>No entities yet</td></tr>
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={5}>
|
||||||
|
<Text ta="center" c="dimmed" py="xl">No entities yet</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</Table.Tbody>
|
||||||
</table>
|
</Table>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
{(dialogOpen || editEntity) && (
|
{(dialogOpen || editEntity) && (
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Center, Text } from '@mantine/core'
|
||||||
import { GraphContainer } from '@graph'
|
import { GraphContainer } from '@graph'
|
||||||
import type { GraphData as LibGraphData } from '@graph'
|
import type { GraphData as LibGraphData } from '@graph'
|
||||||
import type { GraphData, EntityTypePreset } from '../types'
|
import type { GraphData, EntityTypePreset } from '../types'
|
||||||
@@ -7,10 +8,10 @@ interface Props {
|
|||||||
presets: EntityTypePreset[]
|
presets: EntityTypePreset[]
|
||||||
onNodeClick: (nodeId: string) => void
|
onNodeClick: (nodeId: string) => void
|
||||||
onNodeDoubleClick: (nodeId: string) => void
|
onNodeDoubleClick: (nodeId: string) => void
|
||||||
|
onContextMenu?: (event: MouseEvent, target: { type: 'node' | 'edge' | 'canvas'; id?: string; data?: Record<string, unknown> }) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GraphView({ data, presets, onNodeClick, onNodeDoubleClick }: Props) {
|
export function GraphView({ data, presets, onNodeClick, onNodeDoubleClick, onContextMenu }: Props) {
|
||||||
// Map our GraphData to the library's format (they're compatible but need the cast)
|
|
||||||
const libData: LibGraphData = {
|
const libData: LibGraphData = {
|
||||||
nodes: data.nodes.map(n => ({
|
nodes: data.nodes.map(n => ({
|
||||||
id: n.id,
|
id: n.id,
|
||||||
@@ -40,11 +41,11 @@ export function GraphView({ data, presets, onNodeClick, onNodeDoubleClick }: Pro
|
|||||||
|
|
||||||
if (data.nodes.length === 0) {
|
if (data.nodes.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full">
|
<Center h="100%">
|
||||||
<p className="text-sm" style={{ color: 'var(--muted-foreground)' }}>
|
<Text size="sm" c="dimmed">
|
||||||
No data to display. Add entities and relations to build the graph.
|
No data to display. Add entities and relations to build the graph.
|
||||||
</p>
|
</Text>
|
||||||
</div>
|
</Center>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +59,7 @@ export function GraphView({ data, presets, onNodeClick, onNodeDoubleClick }: Pro
|
|||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
onNodeClick={n => onNodeClick(n.id)}
|
onNodeClick={n => onNodeClick(n.id)}
|
||||||
onNodeDoubleClick={n => onNodeDoubleClick(n.id)}
|
onNodeDoubleClick={n => onNodeDoubleClick(n.id)}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
enableSelection
|
enableSelection
|
||||||
selectionMode="multiple"
|
selectionMode="multiple"
|
||||||
theme={{ nodeSize: 8, edgeSize: 1 }}
|
theme={{ nodeSize: 8, edgeSize: 1 }}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Group, TextInput, ActionIcon, Text } from '@mantine/core'
|
||||||
|
import { IconPlus, IconLink, IconFile } from '@tabler/icons-react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onIngestURL: (url: string) => void
|
||||||
|
onIngestFile: (path: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IngestPanel({ onIngestURL, onIngestFile }: Props) {
|
||||||
|
const [url, setUrl] = useState('')
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
const trimmed = url.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
|
||||||
|
onIngestURL(trimmed)
|
||||||
|
} else if (trimmed.startsWith('/') || trimmed.includes('.')) {
|
||||||
|
onIngestFile(trimmed)
|
||||||
|
} else {
|
||||||
|
onIngestURL('https://' + trimmed)
|
||||||
|
}
|
||||||
|
setUrl('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const files = e.dataTransfer.files
|
||||||
|
if (files.length > 0) {
|
||||||
|
// Wails provides the full path via dataTransfer
|
||||||
|
const path = (files[0] as any).path || files[0].name
|
||||||
|
if (path) onIngestFile(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
px="sm"
|
||||||
|
py={4}
|
||||||
|
gap="xs"
|
||||||
|
style={{ borderBottom: '1px solid var(--mantine-color-dark-5)' }}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragOver={e => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<IconLink size={14} style={{ opacity: 0.5 }} />
|
||||||
|
<TextInput
|
||||||
|
size="xs"
|
||||||
|
placeholder="Paste URL or file path..."
|
||||||
|
value={url}
|
||||||
|
onChange={e => setUrl(e.currentTarget.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleSubmit()}
|
||||||
|
flex={1}
|
||||||
|
variant="unstyled"
|
||||||
|
styles={{ input: { fontSize: 12 } }}
|
||||||
|
/>
|
||||||
|
<ActionIcon size="xs" variant="subtle" onClick={handleSubmit} disabled={!url.trim()}>
|
||||||
|
<IconPlus size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
<Text size="xs" c="dimmed">or drop file</Text>
|
||||||
|
</Group>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { Menu, Text, Loader } from '@mantine/core'
|
||||||
|
import {
|
||||||
|
IconWorldDownload,
|
||||||
|
IconFileText,
|
||||||
|
IconBrain,
|
||||||
|
IconLink,
|
||||||
|
IconServer,
|
||||||
|
IconWand,
|
||||||
|
} from '@tabler/icons-react'
|
||||||
|
import type { EnricherDef } from '../types'
|
||||||
|
|
||||||
|
const ICON_MAP: Record<string, React.ComponentType<{ size?: number }>> = {
|
||||||
|
IconWorldDownload,
|
||||||
|
IconFileText,
|
||||||
|
IconBrain,
|
||||||
|
IconLink,
|
||||||
|
IconServer,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
position: { x: number; y: number } | null
|
||||||
|
nodeId: string | null
|
||||||
|
enrichers: EnricherDef[]
|
||||||
|
running: string | null
|
||||||
|
onRun: (enricherId: string) => void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NodeContextMenu({ position, nodeId, enrichers, running, onRun, onClose }: Props) {
|
||||||
|
if (!position || !nodeId || enrichers.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu
|
||||||
|
opened
|
||||||
|
onChange={(opened) => { if (!opened) onClose() }}
|
||||||
|
position="bottom-start"
|
||||||
|
offset={0}
|
||||||
|
>
|
||||||
|
<Menu.Target>
|
||||||
|
<div style={{ position: 'fixed', left: position.x, top: position.y, width: 1, height: 1 }} />
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<Menu.Label>Enrichers</Menu.Label>
|
||||||
|
{enrichers.map(e => {
|
||||||
|
const Icon = ICON_MAP[e.icon] || IconWand
|
||||||
|
const isRunning = running === e.id
|
||||||
|
return (
|
||||||
|
<Menu.Item
|
||||||
|
key={e.id}
|
||||||
|
leftSection={isRunning ? <Loader size={16} /> : <Icon size={16} />}
|
||||||
|
disabled={running !== null}
|
||||||
|
onClick={() => onRun(e.id)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Text size="sm">{e.label}</Text>
|
||||||
|
<Text size="xs" c="dimmed">{e.description}</Text>
|
||||||
|
</div>
|
||||||
|
</Menu.Item>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { Box, Stack, Group, Text, TextInput, UnstyledButton } from '@mantine/core'
|
||||||
|
import { IconPlus, IconTrash, IconFolderOpen } from '@tabler/icons-react'
|
||||||
|
import { FnActionIcon } from '@fn_library'
|
||||||
import type { ProjectInfo } from '../types'
|
import type { ProjectInfo } from '../types'
|
||||||
import { Plus, Trash2, FolderOpen } from 'lucide-react'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
projects: ProjectInfo[]
|
projects: ProjectInfo[]
|
||||||
@@ -14,90 +16,74 @@ export function ProjectSidebar({ projects, current, onSwitch, onCreate, onDelete
|
|||||||
const [newName, setNewName] = useState('')
|
const [newName, setNewName] = useState('')
|
||||||
const [showInput, setShowInput] = useState(false)
|
const [showInput, setShowInput] = useState(false)
|
||||||
|
|
||||||
console.log('[ProjectSidebar] render: projects=', projects.length, 'current=', current, 'projects data:', JSON.stringify(projects))
|
|
||||||
|
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
const name = newName.trim()
|
const name = newName.trim()
|
||||||
console.log('[ProjectSidebar] handleCreate: name=', JSON.stringify(name))
|
|
||||||
if (name) {
|
if (name) {
|
||||||
onCreate(name)
|
onCreate(name)
|
||||||
setNewName('')
|
setNewName('')
|
||||||
setShowInput(false)
|
setShowInput(false)
|
||||||
} else {
|
|
||||||
console.log('[ProjectSidebar] handleCreate: empty name, skipping')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-56 flex flex-col border-r" style={{ borderColor: 'var(--border)', background: 'var(--card)' }}>
|
<Stack gap={0} h="100%">
|
||||||
<div className="p-3 flex items-center justify-between border-b" style={{ borderColor: 'var(--border)' }}>
|
<Group px="sm" py="sm" justify="space-between" style={{ borderBottom: '1px solid var(--mantine-color-dark-4)' }}>
|
||||||
<span className="text-xs font-bold uppercase tracking-wider" style={{ color: 'var(--muted-foreground)' }}>
|
<Text size="xs" fw={700} tt="uppercase" c="dimmed" style={{ letterSpacing: '0.05em' }}>
|
||||||
Projects
|
Projects
|
||||||
</span>
|
</Text>
|
||||||
<button
|
<FnActionIcon
|
||||||
onClick={() => { console.log('[ProjectSidebar] toggling input'); setShowInput(!showInput) }}
|
icon={<IconPlus size={16} />}
|
||||||
className="p-1 rounded hover:opacity-80"
|
variant="subtle"
|
||||||
style={{ color: 'var(--primary)' }}
|
size="sm"
|
||||||
>
|
onClick={() => setShowInput(!showInput)}
|
||||||
<Plus size={16} />
|
/>
|
||||||
</button>
|
</Group>
|
||||||
</div>
|
|
||||||
|
|
||||||
{showInput && (
|
{showInput && (
|
||||||
<div className="p-2 border-b" style={{ borderColor: 'var(--border)' }}>
|
<Box p="xs" style={{ borderBottom: '1px solid var(--mantine-color-dark-4)' }}>
|
||||||
<input
|
<TextInput
|
||||||
value={newName}
|
value={newName}
|
||||||
onChange={e => setNewName(e.target.value)}
|
onChange={e => setNewName(e.currentTarget.value)}
|
||||||
onKeyDown={e => {
|
onKeyDown={e => { if (e.key === 'Enter') handleCreate() }}
|
||||||
console.log('[ProjectSidebar] keyDown:', e.key)
|
|
||||||
if (e.key === 'Enter') handleCreate()
|
|
||||||
}}
|
|
||||||
placeholder="Project name..."
|
placeholder="Project name..."
|
||||||
|
size="xs"
|
||||||
autoFocus
|
autoFocus
|
||||||
className="w-full px-2 py-1 rounded text-sm"
|
|
||||||
style={{
|
|
||||||
background: 'var(--input)',
|
|
||||||
color: 'var(--foreground)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<Box flex={1} style={{ overflowY: 'auto' }}>
|
||||||
{projects.map(p => (
|
{projects.map(p => (
|
||||||
<button
|
<UnstyledButton
|
||||||
key={p.name}
|
key={p.name}
|
||||||
onClick={() => { console.log('[ProjectSidebar] switching to:', p.name); onSwitch(p.name) }}
|
onClick={() => onSwitch(p.name)}
|
||||||
className="flex w-full items-center gap-2 px-3 py-2 cursor-pointer group text-left"
|
w="100%"
|
||||||
style={{
|
px="sm"
|
||||||
background: p.name === current ? 'var(--accent)' : 'transparent',
|
py="xs"
|
||||||
color: p.name === current ? 'var(--accent-foreground)' : 'var(--foreground)',
|
bg={p.name === current ? 'dark.6' : undefined}
|
||||||
}}
|
style={{ display: 'block' }}
|
||||||
>
|
>
|
||||||
<FolderOpen size={14} style={{ color: 'var(--muted-foreground)' }} />
|
<Group gap="xs" wrap="nowrap">
|
||||||
<div className="flex-1 min-w-0">
|
<IconFolderOpen size={14} style={{ color: 'var(--mantine-color-dimmed)', flexShrink: 0 }} />
|
||||||
<div className="text-sm truncate">{p.name}</div>
|
<Box flex={1} miw={0}>
|
||||||
<div className="text-xs" style={{ color: 'var(--muted-foreground)' }}>
|
<Text size="sm" truncate>{p.name}</Text>
|
||||||
{p.entity_count}E / {p.relation_count}R
|
<Text size="xs" c="dimmed">{p.entity_count}E / {p.relation_count}R</Text>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
<FnActionIcon
|
||||||
<button
|
icon={<IconTrash size={12} />}
|
||||||
onClick={e => { e.stopPropagation(); console.log('[ProjectSidebar] deleting:', p.name); onDelete(p.name) }}
|
variant="subtle"
|
||||||
className="opacity-0 group-hover:opacity-100 p-1 rounded"
|
size="xs"
|
||||||
style={{ color: 'var(--destructive)' }}
|
color="red"
|
||||||
>
|
onClick={(e: React.MouseEvent) => { e.stopPropagation(); onDelete(p.name) }}
|
||||||
<Trash2 size={12} />
|
/>
|
||||||
</button>
|
</Group>
|
||||||
</button>
|
</UnstyledButton>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{projects.length === 0 && (
|
{projects.length === 0 && (
|
||||||
<p className="p-3 text-xs" style={{ color: 'var(--muted-foreground)' }}>
|
<Text size="xs" c="dimmed" p="sm">No projects yet</Text>
|
||||||
No projects yet
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</Box>
|
||||||
</aside>
|
</Stack>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { Stack, Group, TextInput, Textarea, Text, NumberInput } from '@mantine/core'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, SimpleSelect, Button } from '@fn_library'
|
||||||
import type { Entity, RelationInputDTO } from '../types'
|
import type { Entity, RelationInputDTO } from '../types'
|
||||||
import { SimpleSelect } from '@fn_library'
|
|
||||||
import { X } from 'lucide-react'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
entities: Entity[]
|
entities: Entity[]
|
||||||
@@ -15,11 +15,11 @@ export function RelationDialog({ entities, relationPresets, onSubmit, onClose }:
|
|||||||
const [fromEntity, setFromEntity] = useState(entities[0]?.id ?? '')
|
const [fromEntity, setFromEntity] = useState(entities[0]?.id ?? '')
|
||||||
const [toEntity, setToEntity] = useState(entities[1]?.id ?? entities[0]?.id ?? '')
|
const [toEntity, setToEntity] = useState(entities[1]?.id ?? entities[0]?.id ?? '')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
const [weight, setWeight] = useState('1.0')
|
const [weight, setWeight] = useState<number | string>(1.0)
|
||||||
const [notes, setNotes] = useState('')
|
const [notes, setNotes] = useState('')
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
const w = parseFloat(weight)
|
const w = typeof weight === 'number' ? weight : parseFloat(String(weight))
|
||||||
onSubmit({
|
onSubmit({
|
||||||
name,
|
name,
|
||||||
from_entity: fromEntity,
|
from_entity: fromEntity,
|
||||||
@@ -31,23 +31,16 @@ export function RelationDialog({ entities, relationPresets, onSubmit, onClose }:
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputStyle = {
|
|
||||||
background: 'var(--input)',
|
|
||||||
color: 'var(--foreground)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.6)' }}>
|
<Dialog open onOpenChange={(open: boolean) => { if (!open) onClose() }}>
|
||||||
<div className="w-[480px] rounded-lg p-5" style={{ background: 'var(--card)', border: '1px solid var(--border)' }}>
|
<DialogContent>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<DialogHeader>
|
||||||
<h3 className="text-base font-semibold">New Relation</h3>
|
<DialogTitle>New Relation</DialogTitle>
|
||||||
<button onClick={onClose} className="p-1"><X size={16} style={{ color: 'var(--muted-foreground)' }} /></button>
|
</DialogHeader>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
<Stack gap="sm">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Relation Type</label>
|
<Text size="sm" fw={500} mb={4}>Relation Type</Text>
|
||||||
<SimpleSelect
|
<SimpleSelect
|
||||||
value={name}
|
value={name}
|
||||||
onValueChange={setName}
|
onValueChange={setName}
|
||||||
@@ -55,53 +48,61 @@ export function RelationDialog({ entities, relationPresets, onSubmit, onClose }:
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<Group gap="sm" grow>
|
||||||
<div className="flex-1">
|
<div>
|
||||||
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>From</label>
|
<Text size="sm" fw={500} mb={4}>From</Text>
|
||||||
<SimpleSelect
|
<SimpleSelect
|
||||||
value={fromEntity}
|
value={fromEntity}
|
||||||
onValueChange={setFromEntity}
|
onValueChange={setFromEntity}
|
||||||
options={entities.map(e => ({ value: e.id, label: e.name }))}
|
options={entities.map(e => ({ value: e.id, label: e.name }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div>
|
||||||
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>To</label>
|
<Text size="sm" fw={500} mb={4}>To</Text>
|
||||||
<SimpleSelect
|
<SimpleSelect
|
||||||
value={toEntity}
|
value={toEntity}
|
||||||
onValueChange={setToEntity}
|
onValueChange={setToEntity}
|
||||||
options={entities.map(e => ({ value: e.id, label: e.name }))}
|
options={entities.map(e => ({ value: e.id, label: e.name }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Group>
|
||||||
|
|
||||||
<div>
|
<TextInput
|
||||||
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Description</label>
|
label="Description"
|
||||||
<input value={description} onChange={e => setDescription(e.target.value)} className="w-full px-3 py-1.5 rounded text-sm" style={inputStyle} />
|
value={description}
|
||||||
</div>
|
onChange={e => setDescription(e.currentTarget.value)}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
|
||||||
<div>
|
<NumberInput
|
||||||
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Weight (0.0 - 1.0)</label>
|
label="Weight (0.0 - 1.0)"
|
||||||
<input value={weight} onChange={e => setWeight(e.target.value)} type="number" step="0.1" min="0" max="1" className="w-full px-3 py-1.5 rounded text-sm" style={inputStyle} />
|
value={weight}
|
||||||
</div>
|
onChange={setWeight}
|
||||||
|
step={0.1}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
decimalScale={2}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
|
||||||
<div>
|
<Textarea
|
||||||
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Notes</label>
|
label="Notes"
|
||||||
<textarea value={notes} onChange={e => setNotes(e.target.value)} rows={2} className="w-full px-3 py-1.5 rounded text-sm resize-none" style={inputStyle} />
|
value={notes}
|
||||||
</div>
|
onChange={e => setNotes(e.currentTarget.value)}
|
||||||
</div>
|
rows={2}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 mt-4">
|
<DialogFooter>
|
||||||
<button onClick={onClose} className="px-3 py-1.5 rounded text-sm" style={{ background: 'var(--secondary)', color: 'var(--secondary-foreground)' }}>Cancel</button>
|
<Group justify="flex-end" gap="sm" mt="md">
|
||||||
<button
|
<Button variant="secondary" onClick={onClose}>Cancel</Button>
|
||||||
onClick={handleSubmit}
|
<Button onClick={handleSubmit} disabled={!name || fromEntity === toEntity}>
|
||||||
disabled={!name || fromEntity === toEntity}
|
Create
|
||||||
className="px-3 py-1.5 rounded text-sm font-medium disabled:opacity-40"
|
</Button>
|
||||||
style={{ background: 'var(--primary)', color: 'var(--primary-foreground)' }}
|
</Group>
|
||||||
>
|
</DialogFooter>
|
||||||
Create
|
</DialogContent>
|
||||||
</button>
|
</Dialog>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { Relation, Entity, RelationInputDTO } from '../types'
|
import { Table, Group, Text } from '@mantine/core'
|
||||||
|
import { IconPlus, IconTrash } from '@tabler/icons-react'
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent, Button, FnActionIcon } from '@fn_library'
|
||||||
import { RelationDialog } from './RelationDialog'
|
import { RelationDialog } from './RelationDialog'
|
||||||
import { Plus, Trash2 } from 'lucide-react'
|
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@fn_library'
|
|
||||||
import { Button } from '@fn_library'
|
|
||||||
import { AddRelation, DeleteRelation } from '../wailsjs/go/main/App'
|
import { AddRelation, DeleteRelation } from '../wailsjs/go/main/App'
|
||||||
|
import type { Relation, Entity, RelationInputDTO } from '../types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
relations: Relation[]
|
relations: Relation[]
|
||||||
@@ -30,43 +30,55 @@ export function RelationTable({ relations, entities, relationPresets, onRefresh
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="mt-3">
|
<Card variant="default" style={{ marginTop: 12 }}>
|
||||||
<CardHeader className="flex flex-row items-center justify-between py-3">
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Relations</CardTitle>
|
<Group justify="space-between" align="center" py="xs">
|
||||||
<Button size="sm" onClick={() => setDialogOpen(true)} disabled={entities.length < 2}>
|
<CardTitle>Relations</CardTitle>
|
||||||
<Plus size={14} className="mr-1" /> Add Relation
|
<Button size="sm" onClick={() => setDialogOpen(true)} disabled={entities.length < 2}>
|
||||||
</Button>
|
<IconPlus size={14} style={{ marginRight: 4 }} /> Add Relation
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent style={{ padding: 0 }}>
|
||||||
<table className="w-full text-sm">
|
<Table highlightOnHover>
|
||||||
<thead>
|
<Table.Thead>
|
||||||
<tr className="border-b" style={{ borderColor: 'var(--border)' }}>
|
<Table.Tr>
|
||||||
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>From</th>
|
<Table.Th>From</Table.Th>
|
||||||
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Relation</th>
|
<Table.Th>Relation</Table.Th>
|
||||||
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>To</th>
|
<Table.Th>To</Table.Th>
|
||||||
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Weight</th>
|
<Table.Th>Weight</Table.Th>
|
||||||
<th className="text-right px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Actions</th>
|
<Table.Th ta="right">Actions</Table.Th>
|
||||||
</tr>
|
</Table.Tr>
|
||||||
</thead>
|
</Table.Thead>
|
||||||
<tbody>
|
<Table.Tbody>
|
||||||
{relations.map(r => (
|
{relations.map(r => (
|
||||||
<tr key={r.id} className="border-b" style={{ borderColor: 'var(--border)' }}>
|
<Table.Tr key={r.id}>
|
||||||
<td className="px-4 py-2">{entityMap[r.from_entity] ?? r.from_entity}</td>
|
<Table.Td>{entityMap[r.from_entity] ?? r.from_entity}</Table.Td>
|
||||||
<td className="px-4 py-2 font-medium">{r.name}</td>
|
<Table.Td fw={500}>{r.name}</Table.Td>
|
||||||
<td className="px-4 py-2">{entityMap[r.to_entity] ?? r.to_entity}</td>
|
<Table.Td>{entityMap[r.to_entity] ?? r.to_entity}</Table.Td>
|
||||||
<td className="px-4 py-2" style={{ color: 'var(--muted-foreground)' }}>{r.weight?.toFixed(2) ?? '—'}</td>
|
<Table.Td>
|
||||||
<td className="px-4 py-2 text-right">
|
<Text size="sm" c="dimmed">{r.weight?.toFixed(2) ?? '—'}</Text>
|
||||||
<button onClick={() => handleDelete(r.id)} className="p-1 rounded hover:opacity-80" style={{ color: 'var(--destructive)' }}>
|
</Table.Td>
|
||||||
<Trash2 size={14} />
|
<Table.Td ta="right">
|
||||||
</button>
|
<FnActionIcon
|
||||||
</td>
|
icon={<IconTrash size={14} />}
|
||||||
</tr>
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
color="red"
|
||||||
|
onClick={() => handleDelete(r.id)}
|
||||||
|
/>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
{relations.length === 0 && (
|
{relations.length === 0 && (
|
||||||
<tr><td colSpan={5} className="px-4 py-8 text-center" style={{ color: 'var(--muted-foreground)' }}>No relations yet</td></tr>
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={5}>
|
||||||
|
<Text ta="center" c="dimmed" py="xl">No relations yet</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</Table.Tbody>
|
||||||
</table>
|
</Table>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
{dialogOpen && (
|
{dialogOpen && (
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
import { useState, useRef, useEffect } from 'react'
|
|
||||||
import { Search, X } from 'lucide-react'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onSearch: (query: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SearchBar({ onSearch }: Props) {
|
|
||||||
const [query, setQuery] = useState('')
|
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (timerRef.current) clearTimeout(timerRef.current)
|
|
||||||
timerRef.current = setTimeout(() => {
|
|
||||||
onSearch(query)
|
|
||||||
}, 300)
|
|
||||||
return () => { if (timerRef.current) clearTimeout(timerRef.current) }
|
|
||||||
}, [query, onSearch])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex-1 flex items-center gap-2 px-2 py-1 rounded" style={{ background: 'var(--input)', border: '1px solid var(--border)' }}>
|
|
||||||
<Search size={14} style={{ color: 'var(--muted-foreground)' }} />
|
|
||||||
<input
|
|
||||||
value={query}
|
|
||||||
onChange={e => setQuery(e.target.value)}
|
|
||||||
placeholder="Search entities..."
|
|
||||||
className="flex-1 bg-transparent text-sm outline-none"
|
|
||||||
style={{ color: 'var(--foreground)' }}
|
|
||||||
/>
|
|
||||||
{query && (
|
|
||||||
<button onClick={() => setQuery('')} className="p-0.5">
|
|
||||||
<X size={12} style={{ color: 'var(--muted-foreground)' }} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Vendored
+49
-12
@@ -1,17 +1,47 @@
|
|||||||
declare module '@fn_library' {
|
declare module '@fn_library' {
|
||||||
export const Tabs: React.FC<{ value: string; onValueChange: (v: string) => void; className?: string; children: React.ReactNode }>
|
import { type CSSProperties, type ReactNode, type ReactElement, type MouseEventHandler } from 'react'
|
||||||
export const TabsList: React.FC<{ className?: string; children: React.ReactNode }>
|
|
||||||
export const TabsTrigger: React.FC<{ value: string; children: React.ReactNode }>
|
// Tabs
|
||||||
export const TabsContent: React.FC<{ value: string; className?: string; children: React.ReactNode }>
|
export const Tabs: React.FC<{ defaultValue?: string | null; value?: string | null; onTabChange?: (value: string | null) => void; orientation?: 'horizontal' | 'vertical'; variant?: 'default' | 'line'; className?: string; style?: CSSProperties; children?: ReactNode }>
|
||||||
export const Card: React.FC<{ className?: string; children: React.ReactNode }>
|
export const TabsList: React.FC<{ className?: string; style?: CSSProperties; children?: ReactNode }>
|
||||||
export const CardHeader: React.FC<{ className?: string; children: React.ReactNode }>
|
export const TabsTrigger: React.FC<{ value: string; disabled?: boolean; className?: string; children?: ReactNode }>
|
||||||
export const CardTitle: React.FC<{ className?: string; children: React.ReactNode }>
|
export const TabsContent: React.FC<{ value: string; className?: string; style?: CSSProperties; children?: ReactNode }>
|
||||||
export const CardContent: React.FC<{ className?: string; children: React.ReactNode }>
|
|
||||||
export const Badge: React.FC<{ className?: string; style?: React.CSSProperties; children: React.ReactNode }>
|
// Card
|
||||||
export const Button: React.FC<{ size?: string; variant?: string; onClick?: () => void; disabled?: boolean; className?: string; children: React.ReactNode }>
|
export const Card: React.FC<{ variant?: 'default' | 'borderless' | 'ghost'; size?: 'default' | 'sm'; className?: string; style?: CSSProperties; children?: ReactNode }>
|
||||||
|
export const CardHeader: React.FC<{ className?: string; style?: CSSProperties; children?: ReactNode }>
|
||||||
|
export const CardTitle: React.FC<{ className?: string; children?: ReactNode }>
|
||||||
|
export const CardContent: React.FC<{ className?: string; style?: CSSProperties; children?: ReactNode }>
|
||||||
|
export const CardFooter: React.FC<{ className?: string; style?: CSSProperties; children?: ReactNode }>
|
||||||
|
|
||||||
|
// Badge
|
||||||
|
export const Badge: React.FC<{ variant?: 'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'success' | 'warning' | 'error' | 'info'; size?: 'default' | 'sm'; className?: string; style?: CSSProperties; children?: ReactNode }>
|
||||||
|
|
||||||
|
// Button
|
||||||
|
export const Button: React.FC<{ variant?: 'default' | 'outline' | 'secondary' | 'ghost' | 'destructive' | 'link'; size?: 'default' | 'xs' | 'sm' | 'lg' | 'icon'; onClick?: MouseEventHandler; disabled?: boolean; className?: string; style?: CSSProperties; children?: ReactNode }>
|
||||||
|
|
||||||
|
// SimpleSelect
|
||||||
export interface SimpleSelectOption { value: string; label: string; disabled?: boolean }
|
export interface SimpleSelectOption { value: string; label: string; disabled?: boolean }
|
||||||
export function SimpleSelect(props: { value: string; onValueChange: (value: string) => void; options: SimpleSelectOption[]; placeholder?: string; disabled?: boolean; size?: 'sm' | 'default'; className?: string }): React.ReactElement
|
export function SimpleSelect(props: { value: string; onValueChange: (value: string) => void; options: SimpleSelectOption[]; placeholder?: string; disabled?: boolean; size?: 'sm' | 'default'; className?: string }): ReactElement
|
||||||
export function SearchBar(props: { onSearch: (query: string) => void; placeholder?: string; debounceMs?: number; className?: string }): React.ReactElement
|
|
||||||
|
// SearchBar
|
||||||
|
export function SearchBar(props: { onSearch: (query: string) => void; placeholder?: string; debounceMs?: number; className?: string }): ReactElement
|
||||||
|
|
||||||
|
// Dialog
|
||||||
|
export const Dialog: React.FC<{ open?: boolean; onOpenChange?: (open: boolean) => void; children?: ReactNode }>
|
||||||
|
export const DialogContent: React.FC<{ className?: string; children?: ReactNode }>
|
||||||
|
export const DialogHeader: React.FC<{ className?: string; children?: ReactNode }>
|
||||||
|
export const DialogTitle: React.FC<{ className?: string; children?: ReactNode }>
|
||||||
|
export const DialogDescription: React.FC<{ className?: string; children?: ReactNode }>
|
||||||
|
export const DialogFooter: React.FC<{ className?: string; children?: ReactNode }>
|
||||||
|
export const DialogClose: React.FC<{ className?: string; children?: ReactNode }>
|
||||||
|
export const DialogTrigger: React.FC<{ className?: string; children?: ReactNode }>
|
||||||
|
|
||||||
|
// FnActionIcon
|
||||||
|
export const FnActionIcon: React.FC<{ icon: ReactNode; variant?: 'filled' | 'light' | 'outline' | 'transparent' | 'default' | 'subtle'; size?: string | number; color?: string; onClick?: MouseEventHandler; loading?: boolean; disabled?: boolean; tooltip?: string; className?: string; style?: CSSProperties }>
|
||||||
|
|
||||||
|
// Textarea (re-export)
|
||||||
|
export const Textarea: React.FC<{ label?: string; value?: string; onChange?: React.ChangeEventHandler<HTMLTextAreaElement>; rows?: number; placeholder?: string; size?: string; autoResize?: boolean; className?: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@graph' {
|
declare module '@graph' {
|
||||||
@@ -42,6 +72,12 @@ declare module '@graph' {
|
|||||||
edges: GraphEdge[]
|
edges: GraphEdge[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ContextMenuTarget {
|
||||||
|
type: 'node' | 'edge' | 'canvas'
|
||||||
|
id?: string
|
||||||
|
data?: GraphNode | GraphEdge
|
||||||
|
}
|
||||||
|
|
||||||
export const GraphContainer: React.FC<{
|
export const GraphContainer: React.FC<{
|
||||||
data: GraphData
|
data: GraphData
|
||||||
layout?: string
|
layout?: string
|
||||||
@@ -51,6 +87,7 @@ declare module '@graph' {
|
|||||||
nodeTypes?: Array<{ type: string; color: string; label: string }>
|
nodeTypes?: Array<{ type: string; color: string; label: string }>
|
||||||
onNodeClick?: (node: GraphNode) => void
|
onNodeClick?: (node: GraphNode) => void
|
||||||
onNodeDoubleClick?: (node: GraphNode) => void
|
onNodeDoubleClick?: (node: GraphNode) => void
|
||||||
|
onContextMenu?: (event: MouseEvent, target: ContextMenuTarget) => void
|
||||||
enableSelection?: boolean
|
enableSelection?: boolean
|
||||||
selectionMode?: string
|
selectionMode?: string
|
||||||
theme?: Record<string, unknown>
|
theme?: Record<string, unknown>
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
import { clsx, type ClassValue } from "clsx"
|
|
||||||
import { twMerge } from "tailwind-merge"
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
|
||||||
return twMerge(clsx(inputs))
|
|
||||||
}
|
|
||||||
+29
-1
@@ -1,10 +1,38 @@
|
|||||||
|
import '@mantine/core/styles.css'
|
||||||
|
import '@mantine/notifications/styles.css'
|
||||||
|
|
||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { MantineProvider, createTheme, type MantineColorsTuple } from '@mantine/core'
|
||||||
|
import { Notifications } from '@mantine/notifications'
|
||||||
import './app.css'
|
import './app.css'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
|
|
||||||
|
const brand: MantineColorsTuple = [
|
||||||
|
'#e5f0ff',
|
||||||
|
'#cddeff',
|
||||||
|
'#9abbff',
|
||||||
|
'#6495ff',
|
||||||
|
'#3874fe',
|
||||||
|
'#1d60fe',
|
||||||
|
'#0953ff',
|
||||||
|
'#0046e4',
|
||||||
|
'#003dcd',
|
||||||
|
'#0034b5',
|
||||||
|
]
|
||||||
|
|
||||||
|
const theme = createTheme({
|
||||||
|
colors: { brand },
|
||||||
|
primaryColor: 'brand',
|
||||||
|
defaultRadius: 'md',
|
||||||
|
fontFamily: "'Geist Variable', system-ui, -apple-system, sans-serif",
|
||||||
|
})
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<MantineProvider theme={theme} defaultColorScheme="dark">
|
||||||
|
<Notifications position="top-right" />
|
||||||
|
<App />
|
||||||
|
</MantineProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -119,3 +119,17 @@ export interface AssertionInput {
|
|||||||
severity: string
|
severity: string
|
||||||
description: string
|
description: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EnricherDef {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
applies_to: string[]
|
||||||
|
icon: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContextMenuTarget {
|
||||||
|
type: "node" | "edge" | "canvas"
|
||||||
|
id?: string
|
||||||
|
data?: GraphNode | GraphEdge
|
||||||
|
}
|
||||||
|
|||||||
+10
@@ -23,6 +23,10 @@ export function EvalAssertions(arg1:string):Promise<Array<fn_operations.Assertio
|
|||||||
|
|
||||||
export function GetCurrentProject():Promise<string>;
|
export function GetCurrentProject():Promise<string>;
|
||||||
|
|
||||||
|
export function GetEnrichers():Promise<Array<main.EnricherDef>>;
|
||||||
|
|
||||||
|
export function GetEnrichersForEntity(arg1:string):Promise<Array<main.EnricherDef>>;
|
||||||
|
|
||||||
export function GetEntity(arg1:string):Promise<fn_operations.Entity>;
|
export function GetEntity(arg1:string):Promise<fn_operations.Entity>;
|
||||||
|
|
||||||
export function GetEntityNeighbors(arg1:string,arg2:number):Promise<main.GraphData>;
|
export function GetEntityNeighbors(arg1:string,arg2:number):Promise<main.GraphData>;
|
||||||
@@ -35,6 +39,10 @@ export function GetGraphData():Promise<main.GraphData>;
|
|||||||
|
|
||||||
export function GetRelationPresets():Promise<Array<string>>;
|
export function GetRelationPresets():Promise<Array<string>>;
|
||||||
|
|
||||||
|
export function IngestFile(arg1:string):Promise<string>;
|
||||||
|
|
||||||
|
export function IngestURL(arg1:string):Promise<string>;
|
||||||
|
|
||||||
export function ListAssertions(arg1:string):Promise<Array<fn_operations.Assertion>>;
|
export function ListAssertions(arg1:string):Promise<Array<fn_operations.Assertion>>;
|
||||||
|
|
||||||
export function ListEntities():Promise<Array<fn_operations.Entity>>;
|
export function ListEntities():Promise<Array<fn_operations.Entity>>;
|
||||||
@@ -43,6 +51,8 @@ export function ListProjects():Promise<Array<main.ProjectInfo>>;
|
|||||||
|
|
||||||
export function ListRelations():Promise<Array<fn_operations.Relation>>;
|
export function ListRelations():Promise<Array<fn_operations.Relation>>;
|
||||||
|
|
||||||
|
export function RunEnricher(arg1:string,arg2:string):Promise<main.GraphData>;
|
||||||
|
|
||||||
export function SearchEntities(arg1:string):Promise<Array<fn_operations.Entity>>;
|
export function SearchEntities(arg1:string):Promise<Array<fn_operations.Entity>>;
|
||||||
|
|
||||||
export function SearchGraph(arg1:string):Promise<main.GraphData>;
|
export function SearchGraph(arg1:string):Promise<main.GraphData>;
|
||||||
|
|||||||
@@ -42,6 +42,14 @@ export function GetCurrentProject() {
|
|||||||
return window['go']['main']['App']['GetCurrentProject']();
|
return window['go']['main']['App']['GetCurrentProject']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetEnrichers() {
|
||||||
|
return window['go']['main']['App']['GetEnrichers']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetEnrichersForEntity(arg1) {
|
||||||
|
return window['go']['main']['App']['GetEnrichersForEntity'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function GetEntity(arg1) {
|
export function GetEntity(arg1) {
|
||||||
return window['go']['main']['App']['GetEntity'](arg1);
|
return window['go']['main']['App']['GetEntity'](arg1);
|
||||||
}
|
}
|
||||||
@@ -66,6 +74,14 @@ export function GetRelationPresets() {
|
|||||||
return window['go']['main']['App']['GetRelationPresets']();
|
return window['go']['main']['App']['GetRelationPresets']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function IngestFile(arg1) {
|
||||||
|
return window['go']['main']['App']['IngestFile'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IngestURL(arg1) {
|
||||||
|
return window['go']['main']['App']['IngestURL'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function ListAssertions(arg1) {
|
export function ListAssertions(arg1) {
|
||||||
return window['go']['main']['App']['ListAssertions'](arg1);
|
return window['go']['main']['App']['ListAssertions'](arg1);
|
||||||
}
|
}
|
||||||
@@ -82,6 +98,10 @@ export function ListRelations() {
|
|||||||
return window['go']['main']['App']['ListRelations']();
|
return window['go']['main']['App']['ListRelations']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RunEnricher(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['RunEnricher'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function SearchEntities(arg1) {
|
export function SearchEntities(arg1) {
|
||||||
return window['go']['main']['App']['SearchEntities'](arg1);
|
return window['go']['main']['App']['SearchEntities'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -237,6 +237,28 @@ export namespace main {
|
|||||||
this.description = source["description"];
|
this.description = source["description"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class EnricherDef {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
applies_to: string[];
|
||||||
|
script: string;
|
||||||
|
icon: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new EnricherDef(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.id = source["id"];
|
||||||
|
this.label = source["label"];
|
||||||
|
this.description = source["description"];
|
||||||
|
this.applies_to = source["applies_to"];
|
||||||
|
this.script = source["script"];
|
||||||
|
this.icon = source["icon"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class EntityInput {
|
export class EntityInput {
|
||||||
name: string;
|
name: string;
|
||||||
type_ref: string;
|
type_ref: string;
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/App.tsx","./src/declarations.d.ts","./src/main.tsx","./src/types.ts","./src/components/AssertionPanel.tsx","./src/components/EntityDetail.tsx","./src/components/EntityDialog.tsx","./src/components/EntityTable.tsx","./src/components/GraphView.tsx","./src/components/ProjectSidebar.tsx","./src/components/RelationDialog.tsx","./src/components/RelationTable.tsx","./src/components/SearchBar.tsx","./src/lib/utils.ts","./src/wailsjs/go/models.ts","./src/wailsjs/go/main/App.d.ts","./src/wailsjs/runtime/runtime.d.ts"],"version":"5.9.3"}
|
{"root":["./src/App.tsx","./src/declarations.d.ts","./src/main.tsx","./src/types.ts","./src/components/AssertionPanel.tsx","./src/components/EntityDetail.tsx","./src/components/EntityDialog.tsx","./src/components/EntityTable.tsx","./src/components/GraphView.tsx","./src/components/IngestPanel.tsx","./src/components/NodeContextMenu.tsx","./src/components/ProjectSidebar.tsx","./src/components/RelationDialog.tsx","./src/components/RelationTable.tsx","./src/wailsjs/go/models.ts","./src/wailsjs/go/main/App.d.ts","./src/wailsjs/runtime/runtime.d.ts"],"version":"5.9.3"}
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
|
||||||
import { resolve } from 'path'
|
import { resolve } from 'path'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': resolve(__dirname, './src'),
|
'@': resolve(__dirname, './src'),
|
||||||
@@ -14,6 +13,9 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
dedupe: ['react', 'react-dom'],
|
dedupe: ['react', 'react-dom'],
|
||||||
},
|
},
|
||||||
|
css: {
|
||||||
|
postcss: resolve(__dirname, './postcss.config.cjs'),
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5174,
|
port: 5174,
|
||||||
},
|
},
|
||||||
|
|||||||
Binary file not shown.
@@ -1,10 +1,10 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
// EntityTypePreset defines an OSINT entity type with its visual properties and suggested metadata fields.
|
// EntityTypePreset defines an entity type with its visual properties and suggested metadata fields.
|
||||||
type EntityTypePreset struct {
|
type EntityTypePreset struct {
|
||||||
TypeRef string `json:"type_ref"` // registry type ID
|
TypeRef string `json:"type_ref"` // short type name
|
||||||
Label string `json:"label"` // human-readable
|
Label string `json:"label"` // human-readable
|
||||||
Color string `json:"color"` // hex color for graph nodes
|
Color string `json:"color"` // hex color for graph nodes
|
||||||
MetadataFields []string `json:"metadata_fields"` // suggested metadata keys
|
MetadataFields []string `json:"metadata_fields"` // suggested metadata keys
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,41 +77,46 @@ type AssertionInput struct {
|
|||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Color map for OSINT entity types
|
// Color map for entity types (short names aligned with registry)
|
||||||
var entityTypeColors = map[string]string{
|
var entityTypeColors = map[string]string{
|
||||||
"osint_person_go_cybersecurity": "#e74c3c",
|
"person": "#e74c3c",
|
||||||
"osint_organization_go_cybersecurity": "#3498db",
|
"organization": "#3498db",
|
||||||
"osint_crypto_wallet_go_cybersecurity": "#f39c12",
|
"crypto_wallet": "#f39c12",
|
||||||
"osint_ip_address_go_cybersecurity": "#2ecc71",
|
"ip_address": "#2ecc71",
|
||||||
"osint_domain_go_cybersecurity": "#9b59b6",
|
"domain": "#9b59b6",
|
||||||
"osint_email_go_cybersecurity": "#1abc9c",
|
"email": "#1abc9c",
|
||||||
"osint_phone_go_cybersecurity": "#e67e22",
|
"phone": "#e67e22",
|
||||||
"osint_malware_go_cybersecurity": "#c0392b",
|
"malware": "#c0392b",
|
||||||
"osint_vulnerability_go_cybersecurity": "#8e44ad",
|
"vulnerability": "#8e44ad",
|
||||||
"osint_social_media_go_cybersecurity": "#2980b9",
|
"social_media": "#2980b9",
|
||||||
"osint_document_go_cybersecurity": "#7f8c8d",
|
"document": "#7f8c8d",
|
||||||
"osint_event_go_cybersecurity": "#d35400",
|
"event": "#d35400",
|
||||||
"osint_location_go_cybersecurity": "#27ae60",
|
"location": "#27ae60",
|
||||||
|
"text": "#78909c",
|
||||||
|
"url": "#8bc34a",
|
||||||
}
|
}
|
||||||
|
|
||||||
var entityTypePresets = []EntityTypePreset{
|
var entityTypePresets = []EntityTypePreset{
|
||||||
{"osint_person_go_cybersecurity", "Person", "#e74c3c", []string{"full_name", "alias", "nationality", "dob", "gender", "risk_score"}},
|
{"person", "Person", "#e74c3c", []string{"full_name", "alias", "nationality", "dob", "gender", "risk_score"}},
|
||||||
{"osint_organization_go_cybersecurity", "Organization", "#3498db", []string{"legal_name", "country", "sector", "founded", "risk_score"}},
|
{"organization", "Organization", "#3498db", []string{"legal_name", "country", "sector", "founded", "risk_score"}},
|
||||||
{"osint_crypto_wallet_go_cybersecurity", "Crypto Wallet", "#f39c12", []string{"address", "blockchain", "balance", "first_seen", "last_seen"}},
|
{"crypto_wallet", "Crypto Wallet", "#f39c12", []string{"address", "blockchain", "balance", "first_seen", "last_seen"}},
|
||||||
{"osint_ip_address_go_cybersecurity", "IP Address", "#2ecc71", []string{"ip", "asn", "country", "isp", "geolocation", "last_seen"}},
|
{"ip_address", "IP Address", "#2ecc71", []string{"ip", "asn", "country", "isp", "geolocation", "last_seen"}},
|
||||||
{"osint_domain_go_cybersecurity", "Domain", "#9b59b6", []string{"fqdn", "registrar", "created_date", "expires_date", "name_servers"}},
|
{"domain", "Domain", "#9b59b6", []string{"fqdn", "registrar", "created_date", "expires_date", "name_servers"}},
|
||||||
{"osint_email_go_cybersecurity", "Email", "#1abc9c", []string{"address", "provider", "verified", "breached"}},
|
{"email", "Email", "#1abc9c", []string{"address", "provider", "verified", "breached"}},
|
||||||
{"osint_phone_go_cybersecurity", "Phone", "#e67e22", []string{"number", "country_code", "carrier", "phone_type"}},
|
{"phone", "Phone", "#e67e22", []string{"number", "country_code", "carrier", "phone_type"}},
|
||||||
{"osint_malware_go_cybersecurity", "Malware", "#c0392b", []string{"family", "hash_sha256", "first_seen", "last_seen", "threat_level"}},
|
{"malware", "Malware", "#c0392b", []string{"family", "hash_sha256", "first_seen", "last_seen", "threat_level"}},
|
||||||
{"osint_vulnerability_go_cybersecurity", "Vulnerability", "#8e44ad", []string{"cve_id", "cvss", "affected_product", "published", "exploited"}},
|
{"vulnerability", "Vulnerability", "#8e44ad", []string{"cve_id", "cvss", "affected_product", "published", "exploited"}},
|
||||||
{"osint_social_media_go_cybersecurity", "Social Media", "#2980b9", []string{"platform", "username", "url", "followers", "verified"}},
|
{"social_media", "Social Media", "#2980b9", []string{"platform", "username", "url", "followers", "verified"}},
|
||||||
{"osint_document_go_cybersecurity", "Document", "#7f8c8d", []string{"title", "format", "classification", "hash_sha256", "source"}},
|
{"document", "Document", "#7f8c8d", []string{"title", "format", "classification", "file_path", "source"}},
|
||||||
{"osint_event_go_cybersecurity", "Event", "#d35400", []string{"event_type", "date", "location", "description", "severity"}},
|
{"event", "Event", "#d35400", []string{"event_type", "date", "location", "description", "severity"}},
|
||||||
{"osint_location_go_cybersecurity", "Location", "#27ae60", []string{"lat", "lon", "address", "country", "city"}},
|
{"location", "Location", "#27ae60", []string{"lat", "lon", "address", "country", "city"}},
|
||||||
|
{"text", "Text", "#78909c", []string{"content_preview", "source", "char_count"}},
|
||||||
|
{"url", "URL", "#8bc34a", []string{"url", "domain", "status_code"}},
|
||||||
}
|
}
|
||||||
|
|
||||||
var relationPresets = []string{
|
var relationPresets = []string{
|
||||||
"funds", "employs", "communicates_with", "owns", "operates",
|
"funds", "employs", "communicates_with", "owns", "operates",
|
||||||
"controls", "affiliated_with", "located_at", "resolves_to",
|
"controls", "affiliated_with", "located_at", "resolves_to",
|
||||||
"registered_by", "hosts", "exploits", "attributed_to", "related_to",
|
"registered_by", "hosts", "exploits", "attributed_to", "related_to",
|
||||||
|
"extracted_from", "contains", "fetched_from",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"frontend:dev:watcher": "pnpm run dev",
|
"frontend:dev:watcher": "pnpm run dev",
|
||||||
"frontend:dev:serverUrl": "auto",
|
"frontend:dev:serverUrl": "auto",
|
||||||
"wailsjsdir": "./frontend/src",
|
"wailsjsdir": "./frontend/src",
|
||||||
|
"tags": "fts5",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Egutierrez"
|
"name": "Egutierrez"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user