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 (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
@@ -580,20 +581,210 @@ func (a *App) GetRelationPresets() []string {
|
||||
return relationPresets
|
||||
}
|
||||
|
||||
// --- Enrichers ---
|
||||
|
||||
func (a *App) GetEnrichers() []EnricherDef {
|
||||
log.Printf("[GetEnrichers] returning %d enrichers", len(enricherRegistry))
|
||||
return enricherRegistry
|
||||
}
|
||||
|
||||
func (a *App) GetEnrichersForEntity(entityID string) ([]EnricherDef, error) {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
if a.db == nil {
|
||||
return nil, fmt.Errorf("no project selected")
|
||||
}
|
||||
entity, err := a.db.GetEntity(entityID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if entity == nil {
|
||||
return nil, fmt.Errorf("entity %s not found", entityID)
|
||||
}
|
||||
result := enrichersForType(entity.TypeRef)
|
||||
log.Printf("[GetEnrichersForEntity] entityID=%s typeRef=%s -> %d enrichers", entityID, entity.TypeRef, len(result))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *App) RunEnricher(enricherID, entityID string) (GraphData, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.db == nil {
|
||||
return GraphData{}, fmt.Errorf("no project selected")
|
||||
}
|
||||
|
||||
log.Printf("[RunEnricher] enricherID=%s entityID=%s", enricherID, entityID)
|
||||
|
||||
enricher := findEnricher(enricherID)
|
||||
if enricher == nil {
|
||||
return GraphData{}, fmt.Errorf("enricher %s not found", enricherID)
|
||||
}
|
||||
|
||||
entity, err := a.db.GetEntity(entityID)
|
||||
if err != nil {
|
||||
return GraphData{}, err
|
||||
}
|
||||
if entity == nil {
|
||||
return GraphData{}, fmt.Errorf("entity %s not found", entityID)
|
||||
}
|
||||
|
||||
// Serialize entity to JSON for the Python script
|
||||
entityJSON, err := json.Marshal(map[string]any{
|
||||
"id": entity.ID,
|
||||
"name": entity.Name,
|
||||
"type_ref": entity.TypeRef,
|
||||
"metadata": entity.Metadata,
|
||||
})
|
||||
if err != nil {
|
||||
return GraphData{}, fmt.Errorf("marshaling entity: %w", err)
|
||||
}
|
||||
|
||||
// Run enricher
|
||||
enrichersDir := filepath.Join(filepath.Dir(os.Args[0]), "enrichers")
|
||||
// Fallback: try relative to working directory
|
||||
if _, err := os.Stat(enrichersDir); err != nil {
|
||||
enrichersDir = "enrichers"
|
||||
}
|
||||
// Fallback: try relative to project dir
|
||||
if _, err := os.Stat(filepath.Join(enrichersDir, enricher.Script)); err != nil {
|
||||
// Try from the app source directory
|
||||
if exePath, err2 := os.Executable(); err2 == nil {
|
||||
enrichersDir = filepath.Join(filepath.Dir(exePath), "enrichers")
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[RunEnricher] executing %s in %s", enricher.Script, enrichersDir)
|
||||
result, err := runEnricherScript(a.registryRoot, enrichersDir, enricher.Script, entityJSON)
|
||||
if err != nil {
|
||||
log.Printf("[RunEnricher] ERROR: %v", err)
|
||||
return GraphData{}, err
|
||||
}
|
||||
|
||||
log.Printf("[RunEnricher] result: %d entities, %d relations", len(result.Entities), len(result.Relations))
|
||||
|
||||
// Insert results into operations.db
|
||||
if err := a.insertEnricherResults(result, entityID); err != nil {
|
||||
log.Printf("[RunEnricher] ERROR inserting results: %v", err)
|
||||
return GraphData{}, err
|
||||
}
|
||||
|
||||
// Return full graph
|
||||
data, err := buildGraphData(a.db)
|
||||
if err != nil {
|
||||
return GraphData{}, err
|
||||
}
|
||||
log.Printf("[RunEnricher] OK: graph now has %d nodes, %d edges", len(data.Nodes), len(data.Edges))
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// --- Ingest ---
|
||||
|
||||
func (a *App) IngestURL(url string) (string, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.db == nil {
|
||||
return "", fmt.Errorf("no project selected")
|
||||
}
|
||||
|
||||
url = strings.TrimSpace(url)
|
||||
if url == "" {
|
||||
return "", fmt.Errorf("URL cannot be empty")
|
||||
}
|
||||
|
||||
log.Printf("[IngestURL] url=%s", url)
|
||||
|
||||
id := makeEntityID(url, "url")
|
||||
now := time.Now()
|
||||
e := &ops.Entity{
|
||||
ID: id,
|
||||
Name: url,
|
||||
TypeRef: "url",
|
||||
Status: ops.StatusActive,
|
||||
Description: "Ingested URL",
|
||||
Domain: "fuzzygraph",
|
||||
Tags: []string{"ingested"},
|
||||
Source: "fuzzygraph",
|
||||
Metadata: map[string]any{"url": url},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if a.registryDB != nil {
|
||||
if err := ops.InsertEntityWithSnapshot(a.db, a.registryDB, e); err != nil {
|
||||
if err2 := a.db.InsertEntity(e); err2 != nil {
|
||||
return "", err2
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err := a.db.InsertEntity(e); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[IngestURL] OK: %s", id)
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (a *App) IngestFile(filePath string) (string, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.db == nil {
|
||||
return "", fmt.Errorf("no project selected")
|
||||
}
|
||||
|
||||
filePath = strings.TrimSpace(filePath)
|
||||
if filePath == "" {
|
||||
return "", fmt.Errorf("file path cannot be empty")
|
||||
}
|
||||
|
||||
log.Printf("[IngestFile] path=%s", filePath)
|
||||
|
||||
name := filepath.Base(filePath)
|
||||
ext := strings.TrimPrefix(filepath.Ext(filePath), ".")
|
||||
id := makeEntityID(name, "document")
|
||||
now := time.Now()
|
||||
|
||||
e := &ops.Entity{
|
||||
ID: id,
|
||||
Name: name,
|
||||
TypeRef: "document",
|
||||
Status: ops.StatusActive,
|
||||
Description: fmt.Sprintf("Ingested document (%s)", ext),
|
||||
Domain: "fuzzygraph",
|
||||
Tags: []string{"ingested"},
|
||||
Source: "fuzzygraph",
|
||||
Metadata: map[string]any{"file_path": filePath, "format": ext},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if a.registryDB != nil {
|
||||
if err := ops.InsertEntityWithSnapshot(a.db, a.registryDB, e); err != nil {
|
||||
if err2 := a.db.InsertEntity(e); err2 != nil {
|
||||
return "", err2
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err := a.db.InsertEntity(e); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[IngestFile] OK: %s", id)
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func makeEntityID(name, typeRef string) string {
|
||||
clean := strings.ToLower(strings.TrimSpace(name))
|
||||
clean = strings.ReplaceAll(clean, " ", "_")
|
||||
clean = strings.ReplaceAll(clean, "-", "_")
|
||||
|
||||
parts := strings.Split(typeRef, "_")
|
||||
shortType := typeRef
|
||||
if len(parts) >= 2 {
|
||||
shortType = parts[1]
|
||||
// Truncate long names (URLs etc.)
|
||||
if len(clean) > 60 {
|
||||
clean = clean[:60]
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s_%s", clean, shortType)
|
||||
return fmt.Sprintf("%s_%s", clean, typeRef)
|
||||
}
|
||||
|
||||
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 name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>FuzzyGraph</title>
|
||||
<script type="module" crossorigin src="/assets/index-CYqMr7xa.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Cjyz0t73.css">
|
||||
<script type="module" crossorigin src="/assets/index-4p79H44C.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-vp4DQNbX.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+8
-11
@@ -9,24 +9,21 @@
|
||||
"preview": "vite preview --host"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@mantine/core": "^9.0.0",
|
||||
"@mantine/hooks": "^9.0.0",
|
||||
"@mantine/notifications": "^9.0.0",
|
||||
"@tabler/icons-react": "^3.41.1",
|
||||
"@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-dom": "^19.2.4",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
"react-dom": "^19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@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",
|
||||
"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 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 { SearchBar } from '@fn_library'
|
||||
import { SearchBar, Tabs, TabsList, TabsTrigger, TabsContent } from '@fn_library'
|
||||
import { GraphView } from './components/GraphView'
|
||||
import { EntityTable } from './components/EntityTable'
|
||||
import { RelationTable } from './components/RelationTable'
|
||||
import { EntityDetail } from './components/EntityDetail'
|
||||
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'
|
||||
|
||||
export default function App() {
|
||||
@@ -21,46 +24,46 @@ export default function App() {
|
||||
const [activeTab, setActiveTab] = useState('graph')
|
||||
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(() => {
|
||||
console.log('[App] mount — loading presets and projects')
|
||||
refreshProjects()
|
||||
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))
|
||||
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))
|
||||
WailsApp.GetEnrichers()
|
||||
.then(e => setEnrichers((e || []) as unknown as EnricherDef[]))
|
||||
.catch(e => console.error('[App] GetEnrichers ERROR:', e))
|
||||
}, [])
|
||||
|
||||
const refreshProjects = useCallback(() => {
|
||||
console.log('[App] refreshProjects called')
|
||||
WailsApp.ListProjects()
|
||||
.then(p => {
|
||||
const list = (p || []) as unknown as ProjectInfo[]
|
||||
console.log('[App] ListProjects OK:', list.length, 'projects', JSON.stringify(list))
|
||||
setProjects(list)
|
||||
})
|
||||
.then(p => setProjects((p || []) as unknown as ProjectInfo[]))
|
||||
.catch(e => console.error('[App] ListProjects ERROR:', e))
|
||||
}, [])
|
||||
|
||||
const refreshData = useCallback(() => {
|
||||
console.log('[App] refreshData called')
|
||||
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))
|
||||
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))
|
||||
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))
|
||||
}, [])
|
||||
|
||||
const handleSwitchProject = useCallback(async (name: string) => {
|
||||
console.log('[App] handleSwitchProject:', name)
|
||||
try {
|
||||
await WailsApp.SwitchProject(name)
|
||||
console.log('[App] SwitchProject OK')
|
||||
setCurrentProject(name)
|
||||
refreshData()
|
||||
} catch (e) {
|
||||
@@ -69,24 +72,18 @@ export default function App() {
|
||||
}, [refreshData])
|
||||
|
||||
const handleCreateProject = useCallback(async (name: string) => {
|
||||
console.log('[App] handleCreateProject:', name)
|
||||
try {
|
||||
const result = await WailsApp.CreateProject(name)
|
||||
console.log('[App] CreateProject OK:', JSON.stringify(result))
|
||||
await WailsApp.CreateProject(name)
|
||||
refreshProjects()
|
||||
console.log('[App] switching to new project...')
|
||||
await handleSwitchProject(name)
|
||||
console.log('[App] switched OK')
|
||||
} catch (e) {
|
||||
console.error('[App] CreateProject ERROR:', e)
|
||||
}
|
||||
}, [refreshProjects, handleSwitchProject])
|
||||
|
||||
const handleDeleteProject = useCallback(async (name: string) => {
|
||||
console.log('[App] handleDeleteProject:', name)
|
||||
try {
|
||||
await WailsApp.DeleteProject(name)
|
||||
console.log('[App] DeleteProject OK')
|
||||
if (currentProject === name) {
|
||||
setCurrentProject('')
|
||||
setEntities([])
|
||||
@@ -100,7 +97,6 @@ export default function App() {
|
||||
}, [currentProject, refreshProjects])
|
||||
|
||||
const handleSearch = useCallback(async (query: string) => {
|
||||
console.log('[App] handleSearch:', query)
|
||||
if (!query.trim()) {
|
||||
refreshData()
|
||||
return
|
||||
@@ -110,7 +106,6 @@ export default function App() {
|
||||
WailsApp.SearchEntities(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[])
|
||||
setGraphData((graph || { nodes: [], edges: [] }) as unknown as GraphData)
|
||||
} catch (e) {
|
||||
@@ -119,12 +114,10 @@ export default function App() {
|
||||
}, [refreshData])
|
||||
|
||||
const handleNodeClick = useCallback((nodeId: string) => {
|
||||
console.log('[App] handleNodeClick:', nodeId)
|
||||
setSelectedEntityId(nodeId)
|
||||
}, [])
|
||||
|
||||
const handleNodeDoubleClick = useCallback(async (nodeId: string) => {
|
||||
console.log('[App] handleNodeDoubleClick:', nodeId)
|
||||
try {
|
||||
const ego = await WailsApp.GetEntityNeighbors(nodeId, 2)
|
||||
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
|
||||
|
||||
console.log('[App] render: projects=', projects.length, 'currentProject=', currentProject, 'entities=', entities.length, 'relations=', relations.length)
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
<ProjectSidebar
|
||||
projects={projects}
|
||||
current={currentProject}
|
||||
onSwitch={handleSwitchProject}
|
||||
onCreate={handleCreateProject}
|
||||
onDelete={handleDeleteProject}
|
||||
/>
|
||||
<AppShell
|
||||
navbar={{ width: 224, breakpoint: 0 }}
|
||||
padding={0}
|
||||
>
|
||||
<AppShell.Navbar>
|
||||
<ProjectSidebar
|
||||
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 ? (
|
||||
<>
|
||||
<div className="px-4 pt-3 pb-2 flex items-center gap-3 border-b" style={{ borderColor: 'var(--border)' }}>
|
||||
<h2 className="text-sm font-semibold" style={{ color: 'var(--foreground)' }}>{currentProject}</h2>
|
||||
<SearchBar onSearch={handleSearch} />
|
||||
</div>
|
||||
<Group px="md" py="xs" gap="sm" style={{ borderBottom: '1px solid var(--mantine-color-dark-4)' }}>
|
||||
<Text size="sm" fw={600}>{currentProject}</Text>
|
||||
<Box flex={1}>
|
||||
<SearchBar onSearch={handleSearch} />
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col overflow-hidden">
|
||||
<TabsList className="mx-4 mt-2">
|
||||
<TabsTrigger value="graph">Graph</TabsTrigger>
|
||||
<TabsTrigger value="entities">Entities ({entities.length})</TabsTrigger>
|
||||
<TabsTrigger value="relations">Relations ({relations.length})</TabsTrigger>
|
||||
<TabsTrigger value="assertions">Assertions</TabsTrigger>
|
||||
</TabsList>
|
||||
<Box flex={1} style={{ display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<Tabs value={activeTab} onTabChange={(v) => v && setActiveTab(v)}>
|
||||
<TabsList style={{ margin: '8px 16px 0' }}>
|
||||
<TabsTrigger value="graph">Graph</TabsTrigger>
|
||||
<TabsTrigger value="entities">Entities ({entities.length})</TabsTrigger>
|
||||
<TabsTrigger value="relations">Relations ({relations.length})</TabsTrigger>
|
||||
<TabsTrigger value="assertions">Assertions</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="graph" className="flex-1 flex overflow-hidden m-0 p-0">
|
||||
<div className="flex-1 relative">
|
||||
<GraphView
|
||||
data={graphData}
|
||||
<TabsContent value="graph" style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<IngestPanel onIngestURL={handleIngestURL} onIngestFile={handleIngestFile} />
|
||||
<Box flex={1} pos="relative" style={{ display: 'flex', overflow: 'hidden', minHeight: 0 }}>
|
||||
<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}
|
||||
onNodeClick={handleNodeClick}
|
||||
onNodeDoubleClick={handleNodeDoubleClick}
|
||||
onRefresh={refreshData}
|
||||
/>
|
||||
</div>
|
||||
{selectedEntity && (
|
||||
<EntityDetail
|
||||
entity={selectedEntity}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="relations" style={{ flex: 1, overflow: 'auto', padding: '0 16px 16px' }}>
|
||||
<RelationTable
|
||||
relations={relations}
|
||||
onClose={() => setSelectedEntityId(null)}
|
||||
onUpdate={refreshData}
|
||||
entities={entities}
|
||||
relationPresets={relationPresets}
|
||||
onRefresh={refreshData}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="entities" className="flex-1 overflow-auto px-4 pb-4 m-0">
|
||||
<EntityTable
|
||||
entities={entities}
|
||||
presets={presets}
|
||||
onRefresh={refreshData}
|
||||
/>
|
||||
</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>
|
||||
<TabsContent value="assertions" style={{ flex: 1, overflow: 'auto', padding: '0 16px 16px' }}>
|
||||
<AssertionPanel entities={entities} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<p style={{ color: 'var(--muted-foreground)' }}>Select or create a project to begin</p>
|
||||
</div>
|
||||
<Center flex={1}>
|
||||
<Text c="dimmed">Select or create a project to begin</Text>
|
||||
</Center>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</AppShell.Main>
|
||||
</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 {
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: 'Geist Variable', system-ui, -apple-system, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
[data-slot="card"] {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useState } from 'react'
|
||||
import type { Entity, Assertion, AssertionResult, AssertionInput } from '../types'
|
||||
import { Plus, Play, Trash2 } from 'lucide-react'
|
||||
import { SimpleSelect } from '@fn_library'
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@fn_library'
|
||||
import { Button } from '@fn_library'
|
||||
import { Table, Group, Text, TextInput } from '@mantine/core'
|
||||
import { IconPlus, IconPlayerPlay, IconTrash } from '@tabler/icons-react'
|
||||
import { Card, CardHeader, CardTitle, CardContent, SimpleSelect, Button, FnActionIcon } from '@fn_library'
|
||||
import * as WailsApp from '../wailsjs/go/main/App'
|
||||
import type { Entity, Assertion, AssertionResult, AssertionInput } from '../types'
|
||||
|
||||
interface Props {
|
||||
entities: Entity[]
|
||||
@@ -54,37 +53,39 @@ export function AssertionPanel({ entities }: Props) {
|
||||
loadAssertions(selectedEntity)
|
||||
}
|
||||
|
||||
const inputStyle = { background: 'var(--input)', color: 'var(--foreground)', border: '1px solid var(--border)' }
|
||||
|
||||
return (
|
||||
<Card className="mt-3">
|
||||
<CardHeader className="flex flex-row items-center justify-between py-3">
|
||||
<CardTitle className="text-base">Assertions</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<SimpleSelect
|
||||
value={selectedEntity}
|
||||
onValueChange={loadAssertions}
|
||||
placeholder="Select entity..."
|
||||
className="w-48"
|
||||
options={entities.map(e => ({ value: e.id, label: e.name }))}
|
||||
/>
|
||||
<Button size="sm" onClick={handleEval} disabled={!selectedEntity}>
|
||||
<Play size={14} className="mr-1" /> Eval
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setShowAdd(!showAdd)} disabled={!selectedEntity}>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
<Card variant="default" style={{ marginTop: 12 }}>
|
||||
<CardHeader>
|
||||
<Group justify="space-between" align="center" py="xs">
|
||||
<CardTitle>Assertions</CardTitle>
|
||||
<Group gap="sm">
|
||||
<SimpleSelect
|
||||
value={selectedEntity}
|
||||
onValueChange={loadAssertions}
|
||||
placeholder="Select entity..."
|
||||
options={entities.map(e => ({ value: e.id, label: e.name }))}
|
||||
/>
|
||||
<Button size="sm" onClick={handleEval} disabled={!selectedEntity}>
|
||||
<IconPlayerPlay size={14} style={{ marginRight: 4 }} /> Eval
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setShowAdd(!showAdd)} disabled={!selectedEntity}>
|
||||
<IconPlus size={14} />
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</CardHeader>
|
||||
|
||||
{showAdd && (
|
||||
<div className="px-4 pb-3 flex gap-2 items-end">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Name</label>
|
||||
<input value={newName} onChange={e => setNewName(e.target.value)} className="w-full px-2 py-1 rounded text-sm" style={inputStyle} />
|
||||
</div>
|
||||
<div className="w-24">
|
||||
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Kind</label>
|
||||
<Group px="md" pb="sm" gap="sm" align="flex-end">
|
||||
<TextInput
|
||||
label="Name"
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.currentTarget.value)}
|
||||
size="xs"
|
||||
flex={1}
|
||||
/>
|
||||
<div style={{ width: 96 }}>
|
||||
<Text size="xs" fw={500} mb={4}>Kind</Text>
|
||||
<SimpleSelect
|
||||
value={newKind}
|
||||
onValueChange={setNewKind}
|
||||
@@ -97,8 +98,8 @@ export function AssertionPanel({ entities }: Props) {
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-24">
|
||||
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Severity</label>
|
||||
<div style={{ width: 96 }}>
|
||||
<Text size="xs" fw={500} mb={4}>Severity</Text>
|
||||
<SimpleSelect
|
||||
value={newSeverity}
|
||||
onValueChange={setNewSeverity}
|
||||
@@ -109,61 +110,77 @@ export function AssertionPanel({ entities }: Props) {
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Rule (SQL expr)</label>
|
||||
<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" />
|
||||
</div>
|
||||
<button onClick={handleAdd} className="px-3 py-1 rounded text-sm" style={{ background: 'var(--primary)', color: 'var(--primary-foreground)' }}>Add</button>
|
||||
</div>
|
||||
<TextInput
|
||||
label="Rule (SQL expr)"
|
||||
value={newRule}
|
||||
onChange={e => setNewRule(e.currentTarget.value)}
|
||||
placeholder="risk_score > 70"
|
||||
size="xs"
|
||||
flex={1}
|
||||
/>
|
||||
<Button size="sm" onClick={handleAdd}>Add</Button>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<CardContent className="p-0">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b" style={{ borderColor: 'var(--border)' }}>
|
||||
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Name</th>
|
||||
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Kind</th>
|
||||
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Rule</th>
|
||||
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Severity</th>
|
||||
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Result</th>
|
||||
<th className="text-right px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<CardContent style={{ padding: 0 }}>
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>Kind</Table.Th>
|
||||
<Table.Th>Rule</Table.Th>
|
||||
<Table.Th>Severity</Table.Th>
|
||||
<Table.Th>Result</Table.Th>
|
||||
<Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{assertions.map(a => {
|
||||
const result = results.find(r => r.assertion_id === a.id)
|
||||
return (
|
||||
<tr key={a.id} className="border-b" style={{ borderColor: 'var(--border)' }}>
|
||||
<td className="px-4 py-2">{a.name}</td>
|
||||
<td className="px-4 py-2" style={{ color: 'var(--muted-foreground)' }}>{a.kind}</td>
|
||||
<td className="px-4 py-2 font-mono text-xs">{a.rule}</td>
|
||||
<td className="px-4 py-2">
|
||||
<span style={{ color: a.severity === 'critical' ? 'var(--destructive)' : a.severity === 'warning' ? 'var(--chart-3, #f59e0b)' : 'var(--muted-foreground)' }}>
|
||||
<Table.Tr key={a.id}>
|
||||
<Table.Td>{a.name}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">{a.kind}</Text>
|
||||
</Table.Td>
|
||||
<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}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{result ? (
|
||||
<span style={{ color: result.status === 'pass' ? 'var(--success)' : 'var(--destructive)' }}>
|
||||
<Text size="sm" c={result.status === 'pass' ? 'teal' : 'red'}>
|
||||
{result.status}
|
||||
</span>
|
||||
</Text>
|
||||
) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<button onClick={() => handleDelete(a.id)} className="p-1 rounded" style={{ color: 'var(--destructive)' }}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<FnActionIcon
|
||||
icon={<IconTrash size={14} />}
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
color="red"
|
||||
onClick={() => handleDelete(a.id)}
|
||||
/>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)
|
||||
})}
|
||||
{assertions.length === 0 && (
|
||||
<tr><td colSpan={6} className="px-4 py-8 text-center" style={{ color: 'var(--muted-foreground)' }}>
|
||||
{selectedEntity ? 'No assertions for this entity' : 'Select an entity to view assertions'}
|
||||
</td></tr>
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={6}>
|
||||
<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>
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</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 { X, ExternalLink } from 'lucide-react'
|
||||
|
||||
interface Props {
|
||||
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)
|
||||
|
||||
return (
|
||||
<aside className="w-72 border-l overflow-y-auto" style={{ borderColor: 'var(--border)', background: 'var(--card)' }}>
|
||||
<div className="p-3 flex items-center justify-between border-b" style={{ borderColor: 'var(--border)' }}>
|
||||
<h3 className="text-sm font-semibold truncate">{entity.name}</h3>
|
||||
<button onClick={onClose} className="p-1">
|
||||
<X size={14} style={{ color: 'var(--muted-foreground)' }} />
|
||||
</button>
|
||||
</div>
|
||||
<Box w={288} style={{ borderLeft: '1px solid var(--mantine-color-dark-4)', overflowY: 'auto' }}>
|
||||
<Group px="sm" py="sm" justify="space-between" style={{ borderBottom: '1px solid var(--mantine-color-dark-4)' }}>
|
||||
<Text size="sm" fw={600} truncate flex={1}>{entity.name}</Text>
|
||||
<FnActionIcon
|
||||
icon={<IconX size={14} />}
|
||||
variant="subtle"
|
||||
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="Status">{entity.status}</Section>
|
||||
{entity.description && <Section label="Description">{entity.description}</Section>}
|
||||
|
||||
{entity.metadata && Object.keys(entity.metadata).length > 0 && (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold mb-1" style={{ color: 'var(--muted-foreground)' }}>Metadata</label>
|
||||
<div className="space-y-1">
|
||||
<Text size="xs" fw={600} c="dimmed" mb={4}>Metadata</Text>
|
||||
<Stack gap={4}>
|
||||
{Object.entries(entity.metadata).map(([k, v]) => (
|
||||
<div key={k} className="flex justify-between">
|
||||
<span style={{ color: 'var(--muted-foreground)' }}>{k}</span>
|
||||
<span className="font-mono text-xs">{String(v)}</span>
|
||||
</div>
|
||||
<Group key={k} justify="space-between">
|
||||
<Text size="sm" c="dimmed">{k}</Text>
|
||||
<Text size="xs" ff="monospace">{String(v)}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entity.notes && (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold mb-1" style={{ color: 'var(--muted-foreground)' }}>Notes</label>
|
||||
<p className="text-xs whitespace-pre-wrap" style={{ color: 'var(--foreground)' }}>{entity.notes}</p>
|
||||
<Text size="xs" fw={600} c="dimmed" mb={4}>Notes</Text>
|
||||
<Text size="xs" style={{ whiteSpace: 'pre-wrap' }}>{entity.notes}</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entity.tags && entity.tags.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold mb-1" style={{ color: 'var(--muted-foreground)' }}>Tags</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Text size="xs" fw={600} c="dimmed" mb={4}>Tags</Text>
|
||||
<Group gap={4}>
|
||||
{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>
|
||||
)}
|
||||
|
||||
{directRelations.length > 0 && (
|
||||
<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})
|
||||
</label>
|
||||
<div className="space-y-1">
|
||||
</Text>
|
||||
<Stack gap={4}>
|
||||
{directRelations.map(r => {
|
||||
const isFrom = r.from_entity === entity.id
|
||||
return (
|
||||
<div key={r.id} className="flex items-center gap-1 text-xs">
|
||||
<ExternalLink size={10} style={{ color: 'var(--muted-foreground)' }} />
|
||||
<span>{isFrom ? '' : '<-'} {r.name} {isFrom ? '->' : ''}</span>
|
||||
<span className="font-medium">{isFrom ? r.to_entity : r.from_entity}</span>
|
||||
</div>
|
||||
<Group key={r.id} gap={4}>
|
||||
<IconExternalLink size={10} style={{ color: 'var(--mantine-color-dimmed)' }} />
|
||||
<Text size="xs">{isFrom ? '' : '<-'} {r.name} {isFrom ? '->' : ''}</Text>
|
||||
<Text size="xs" fw={500}>{isFrom ? r.to_entity : r.from_entity}</Text>
|
||||
</Group>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</Stack>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold" style={{ color: 'var(--muted-foreground)' }}>{label}</label>
|
||||
<span>{children}</span>
|
||||
<Text size="xs" fw={600} c="dimmed">{label}</Text>
|
||||
<Text size="sm">{children}</Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
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 { SimpleSelect } from '@fn_library'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
interface Props {
|
||||
presets: EntityTypePreset[]
|
||||
entity: Entity | null // null = create, non-null = edit
|
||||
entity: Entity | null
|
||||
onSubmit: (input: EntityInput) => 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 metadataFields = currentPreset?.metadata_fields ?? []
|
||||
|
||||
// When type changes, reset metadata fields to match new type
|
||||
useEffect(() => {
|
||||
if (!entity) {
|
||||
const m: Record<string, string> = {}
|
||||
@@ -44,7 +43,6 @@ export function EntityDialog({ presets, entity, onSubmit, onClose }: Props) {
|
||||
const cleanMeta: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(metadata)) {
|
||||
if (v.trim()) {
|
||||
// Try parsing as number
|
||||
const num = Number(v)
|
||||
if (!isNaN(num) && v.trim() !== '') {
|
||||
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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.6)' }}>
|
||||
<div className="w-[520px] max-h-[85vh] overflow-y-auto rounded-lg p-5" style={{ background: 'var(--card)', border: '1px solid var(--border)' }}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-base font-semibold">{entity ? 'Edit Entity' : 'New Entity'}</h3>
|
||||
<button onClick={onClose} className="p-1"><X size={16} style={{ color: 'var(--muted-foreground)' }} /></button>
|
||||
</div>
|
||||
<Dialog open onOpenChange={(open: boolean) => { if (!open) onClose() }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{entity ? 'Edit Entity' : 'New Entity'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Name</label>
|
||||
<input value={name} onChange={e => setName(e.target.value)} className="w-full px-3 py-1.5 rounded text-sm" style={inputStyle} />
|
||||
</div>
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label="Name"
|
||||
value={name}
|
||||
onChange={e => setName(e.currentTarget.value)}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<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
|
||||
value={typeRef}
|
||||
onValueChange={setTypeRef}
|
||||
@@ -97,64 +90,61 @@ export function EntityDialog({ presets, entity, onSubmit, onClose }: Props) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Description</label>
|
||||
<input value={description} onChange={e => setDescription(e.target.value)} className="w-full px-3 py-1.5 rounded text-sm" style={inputStyle} />
|
||||
</div>
|
||||
<TextInput
|
||||
label="Description"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.currentTarget.value)}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Tags (comma separated)</label>
|
||||
<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" />
|
||||
</div>
|
||||
<TextInput
|
||||
label="Tags (comma separated)"
|
||||
value={tagsStr}
|
||||
onChange={e => setTagsStr(e.currentTarget.value)}
|
||||
placeholder="osint, high-risk"
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
{metadataFields.length > 0 && (
|
||||
<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})
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
</Text>
|
||||
<Stack gap="xs">
|
||||
{metadataFields.map(field => (
|
||||
<div key={field} className="flex items-center gap-2">
|
||||
<span className="text-xs w-28 text-right" style={{ color: 'var(--muted-foreground)' }}>{field}</span>
|
||||
<input
|
||||
<Group key={field} gap="sm" align="center">
|
||||
<Text size="xs" c="dimmed" w={112} ta="right">{field}</Text>
|
||||
<TextInput
|
||||
value={metadata[field] ?? ''}
|
||||
onChange={e => setMetadata(prev => ({ ...prev, [field]: e.target.value }))}
|
||||
className="flex-1 px-2 py-1 rounded text-sm"
|
||||
style={inputStyle}
|
||||
onChange={e => setMetadata(prev => ({ ...prev, [field]: e.currentTarget.value }))}
|
||||
size="xs"
|
||||
flex={1}
|
||||
/>
|
||||
</div>
|
||||
</Group>
|
||||
))}
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Notes</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-1.5 rounded text-sm resize-none"
|
||||
style={inputStyle}
|
||||
placeholder="Operational notes..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Textarea
|
||||
label="Notes"
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.currentTarget.value)}
|
||||
rows={3}
|
||||
placeholder="Operational notes..."
|
||||
size="sm"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<button onClick={onClose} className="px-3 py-1.5 rounded text-sm" style={{ background: 'var(--secondary)', color: 'var(--secondary-foreground)' }}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!name.trim()}
|
||||
className="px-3 py-1.5 rounded text-sm font-medium disabled:opacity-40"
|
||||
style={{ background: 'var(--primary)', color: 'var(--primary-foreground)' }}
|
||||
>
|
||||
{entity ? 'Update' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Group justify="flex-end" gap="sm" mt="md">
|
||||
<Button variant="secondary" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={handleSubmit} disabled={!name.trim()}>
|
||||
{entity ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</Group>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
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 { 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 type { Entity, EntityTypePreset, EntityInput } from '../types'
|
||||
|
||||
interface Props {
|
||||
entities: Entity[]
|
||||
@@ -39,59 +38,74 @@ export function EntityTable({ entities, presets, onRefresh }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="mt-3">
|
||||
<CardHeader className="flex flex-row items-center justify-between py-3">
|
||||
<CardTitle className="text-base">Entities</CardTitle>
|
||||
<Button size="sm" onClick={() => setDialogOpen(true)}>
|
||||
<Plus size={14} className="mr-1" /> Add Entity
|
||||
</Button>
|
||||
<Card variant="default" style={{ marginTop: 12 }}>
|
||||
<CardHeader>
|
||||
<Group justify="space-between" align="center" py="xs">
|
||||
<CardTitle>Entities</CardTitle>
|
||||
<Button size="sm" onClick={() => setDialogOpen(true)}>
|
||||
<IconPlus size={14} style={{ marginRight: 4 }} /> Add Entity
|
||||
</Button>
|
||||
</Group>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b" style={{ borderColor: 'var(--border)' }}>
|
||||
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Name</th>
|
||||
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Type</th>
|
||||
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Status</th>
|
||||
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Notes</th>
|
||||
<th className="text-right px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<CardContent style={{ padding: 0 }}>
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Notes</Table.Th>
|
||||
<Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{entities.map(e => {
|
||||
const preset = presetMap[e.type_ref]
|
||||
return (
|
||||
<tr key={e.id} className="border-b hover:opacity-90" style={{ borderColor: 'var(--border)' }}>
|
||||
<td className="px-4 py-2 font-medium">{e.name}</td>
|
||||
<td className="px-4 py-2">
|
||||
<Badge style={{ backgroundColor: preset?.color ?? 'var(--muted-foreground)', color: 'var(--primary-foreground)' }}>
|
||||
<Table.Tr key={e.id}>
|
||||
<Table.Td fw={500}>{e.name}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge style={{ backgroundColor: preset?.color ?? undefined }}>
|
||||
{preset?.label ?? e.type_ref}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className="text-xs" style={{ color: e.status === 'active' ? 'var(--success)' : 'var(--muted-foreground)' }}>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c={e.status === 'active' ? 'teal' : 'dimmed'}>
|
||||
{e.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 max-w-48 truncate" style={{ color: 'var(--muted-foreground)' }}>
|
||||
{e.notes || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<button onClick={() => setEditEntity(e)} className="p-1 mr-1 rounded hover:opacity-80" style={{ color: 'var(--primary)' }}>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(e.id)} className="p-1 rounded hover:opacity-80" style={{ color: 'var(--destructive)' }}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td maw={192}>
|
||||
<Text size="sm" c="dimmed" truncate>{e.notes || '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Group gap={4} justify="flex-end">
|
||||
<FnActionIcon
|
||||
icon={<IconPencil size={14} />}
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={() => setEditEntity(e)}
|
||||
/>
|
||||
<FnActionIcon
|
||||
icon={<IconTrash size={14} />}
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
color="red"
|
||||
onClick={() => handleDelete(e.id)}
|
||||
/>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)
|
||||
})}
|
||||
{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>
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
|
||||
{(dialogOpen || editEntity) && (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Center, Text } from '@mantine/core'
|
||||
import { GraphContainer } from '@graph'
|
||||
import type { GraphData as LibGraphData } from '@graph'
|
||||
import type { GraphData, EntityTypePreset } from '../types'
|
||||
@@ -7,10 +8,10 @@ interface Props {
|
||||
presets: EntityTypePreset[]
|
||||
onNodeClick: (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) {
|
||||
// Map our GraphData to the library's format (they're compatible but need the cast)
|
||||
export function GraphView({ data, presets, onNodeClick, onNodeDoubleClick, onContextMenu }: Props) {
|
||||
const libData: LibGraphData = {
|
||||
nodes: data.nodes.map(n => ({
|
||||
id: n.id,
|
||||
@@ -40,11 +41,11 @@ export function GraphView({ data, presets, onNodeClick, onNodeDoubleClick }: Pro
|
||||
|
||||
if (data.nodes.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-sm" style={{ color: 'var(--muted-foreground)' }}>
|
||||
<Center h="100%">
|
||||
<Text size="sm" c="dimmed">
|
||||
No data to display. Add entities and relations to build the graph.
|
||||
</p>
|
||||
</div>
|
||||
</Text>
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -58,6 +59,7 @@ export function GraphView({ data, presets, onNodeClick, onNodeDoubleClick }: Pro
|
||||
nodeTypes={nodeTypes}
|
||||
onNodeClick={n => onNodeClick(n.id)}
|
||||
onNodeDoubleClick={n => onNodeDoubleClick(n.id)}
|
||||
onContextMenu={onContextMenu}
|
||||
enableSelection
|
||||
selectionMode="multiple"
|
||||
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 { 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 { Plus, Trash2, FolderOpen } from 'lucide-react'
|
||||
|
||||
interface Props {
|
||||
projects: ProjectInfo[]
|
||||
@@ -14,90 +16,74 @@ export function ProjectSidebar({ projects, current, onSwitch, onCreate, onDelete
|
||||
const [newName, setNewName] = useState('')
|
||||
const [showInput, setShowInput] = useState(false)
|
||||
|
||||
console.log('[ProjectSidebar] render: projects=', projects.length, 'current=', current, 'projects data:', JSON.stringify(projects))
|
||||
|
||||
const handleCreate = () => {
|
||||
const name = newName.trim()
|
||||
console.log('[ProjectSidebar] handleCreate: name=', JSON.stringify(name))
|
||||
if (name) {
|
||||
onCreate(name)
|
||||
setNewName('')
|
||||
setShowInput(false)
|
||||
} else {
|
||||
console.log('[ProjectSidebar] handleCreate: empty name, skipping')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="w-56 flex flex-col border-r" style={{ borderColor: 'var(--border)', background: 'var(--card)' }}>
|
||||
<div className="p-3 flex items-center justify-between border-b" style={{ borderColor: 'var(--border)' }}>
|
||||
<span className="text-xs font-bold uppercase tracking-wider" style={{ color: 'var(--muted-foreground)' }}>
|
||||
<Stack gap={0} h="100%">
|
||||
<Group px="sm" py="sm" justify="space-between" style={{ borderBottom: '1px solid var(--mantine-color-dark-4)' }}>
|
||||
<Text size="xs" fw={700} tt="uppercase" c="dimmed" style={{ letterSpacing: '0.05em' }}>
|
||||
Projects
|
||||
</span>
|
||||
<button
|
||||
onClick={() => { console.log('[ProjectSidebar] toggling input'); setShowInput(!showInput) }}
|
||||
className="p-1 rounded hover:opacity-80"
|
||||
style={{ color: 'var(--primary)' }}
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</Text>
|
||||
<FnActionIcon
|
||||
icon={<IconPlus size={16} />}
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={() => setShowInput(!showInput)}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{showInput && (
|
||||
<div className="p-2 border-b" style={{ borderColor: 'var(--border)' }}>
|
||||
<input
|
||||
<Box p="xs" style={{ borderBottom: '1px solid var(--mantine-color-dark-4)' }}>
|
||||
<TextInput
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
console.log('[ProjectSidebar] keyDown:', e.key)
|
||||
if (e.key === 'Enter') handleCreate()
|
||||
}}
|
||||
onChange={e => setNewName(e.currentTarget.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleCreate() }}
|
||||
placeholder="Project name..."
|
||||
size="xs"
|
||||
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 => (
|
||||
<button
|
||||
<UnstyledButton
|
||||
key={p.name}
|
||||
onClick={() => { console.log('[ProjectSidebar] switching to:', p.name); onSwitch(p.name) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 cursor-pointer group text-left"
|
||||
style={{
|
||||
background: p.name === current ? 'var(--accent)' : 'transparent',
|
||||
color: p.name === current ? 'var(--accent-foreground)' : 'var(--foreground)',
|
||||
}}
|
||||
onClick={() => onSwitch(p.name)}
|
||||
w="100%"
|
||||
px="sm"
|
||||
py="xs"
|
||||
bg={p.name === current ? 'dark.6' : undefined}
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
<FolderOpen size={14} style={{ color: 'var(--muted-foreground)' }} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm truncate">{p.name}</div>
|
||||
<div className="text-xs" style={{ color: 'var(--muted-foreground)' }}>
|
||||
{p.entity_count}E / {p.relation_count}R
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); console.log('[ProjectSidebar] deleting:', p.name); onDelete(p.name) }}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 rounded"
|
||||
style={{ color: 'var(--destructive)' }}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</button>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<IconFolderOpen size={14} style={{ color: 'var(--mantine-color-dimmed)', flexShrink: 0 }} />
|
||||
<Box flex={1} miw={0}>
|
||||
<Text size="sm" truncate>{p.name}</Text>
|
||||
<Text size="xs" c="dimmed">{p.entity_count}E / {p.relation_count}R</Text>
|
||||
</Box>
|
||||
<FnActionIcon
|
||||
icon={<IconTrash size={12} />}
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
color="red"
|
||||
onClick={(e: React.MouseEvent) => { e.stopPropagation(); onDelete(p.name) }}
|
||||
/>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
|
||||
{projects.length === 0 && (
|
||||
<p className="p-3 text-xs" style={{ color: 'var(--muted-foreground)' }}>
|
||||
No projects yet
|
||||
</p>
|
||||
<Text size="xs" c="dimmed" p="sm">No projects yet</Text>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</Box>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { SimpleSelect } from '@fn_library'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
interface Props {
|
||||
entities: Entity[]
|
||||
@@ -15,11 +15,11 @@ export function RelationDialog({ entities, relationPresets, onSubmit, onClose }:
|
||||
const [fromEntity, setFromEntity] = useState(entities[0]?.id ?? '')
|
||||
const [toEntity, setToEntity] = useState(entities[1]?.id ?? entities[0]?.id ?? '')
|
||||
const [description, setDescription] = useState('')
|
||||
const [weight, setWeight] = useState('1.0')
|
||||
const [weight, setWeight] = useState<number | string>(1.0)
|
||||
const [notes, setNotes] = useState('')
|
||||
|
||||
const handleSubmit = () => {
|
||||
const w = parseFloat(weight)
|
||||
const w = typeof weight === 'number' ? weight : parseFloat(String(weight))
|
||||
onSubmit({
|
||||
name,
|
||||
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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.6)' }}>
|
||||
<div className="w-[480px] rounded-lg p-5" style={{ background: 'var(--card)', border: '1px solid var(--border)' }}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-base font-semibold">New Relation</h3>
|
||||
<button onClick={onClose} className="p-1"><X size={16} style={{ color: 'var(--muted-foreground)' }} /></button>
|
||||
</div>
|
||||
<Dialog open onOpenChange={(open: boolean) => { if (!open) onClose() }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Relation</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Stack gap="sm">
|
||||
<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
|
||||
value={name}
|
||||
onValueChange={setName}
|
||||
@@ -55,53 +48,61 @@ export function RelationDialog({ entities, relationPresets, onSubmit, onClose }:
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>From</label>
|
||||
<Group gap="sm" grow>
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb={4}>From</Text>
|
||||
<SimpleSelect
|
||||
value={fromEntity}
|
||||
onValueChange={setFromEntity}
|
||||
options={entities.map(e => ({ value: e.id, label: e.name }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>To</label>
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb={4}>To</Text>
|
||||
<SimpleSelect
|
||||
value={toEntity}
|
||||
onValueChange={setToEntity}
|
||||
options={entities.map(e => ({ value: e.id, label: e.name }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Description</label>
|
||||
<input value={description} onChange={e => setDescription(e.target.value)} className="w-full px-3 py-1.5 rounded text-sm" style={inputStyle} />
|
||||
</div>
|
||||
<TextInput
|
||||
label="Description"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.currentTarget.value)}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Weight (0.0 - 1.0)</label>
|
||||
<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} />
|
||||
</div>
|
||||
<NumberInput
|
||||
label="Weight (0.0 - 1.0)"
|
||||
value={weight}
|
||||
onChange={setWeight}
|
||||
step={0.1}
|
||||
min={0}
|
||||
max={1}
|
||||
decimalScale={2}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Notes</label>
|
||||
<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} />
|
||||
</div>
|
||||
</div>
|
||||
<Textarea
|
||||
label="Notes"
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.currentTarget.value)}
|
||||
rows={2}
|
||||
size="sm"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<button onClick={onClose} className="px-3 py-1.5 rounded text-sm" style={{ background: 'var(--secondary)', color: 'var(--secondary-foreground)' }}>Cancel</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!name || fromEntity === toEntity}
|
||||
className="px-3 py-1.5 rounded text-sm font-medium disabled:opacity-40"
|
||||
style={{ background: 'var(--primary)', color: 'var(--primary-foreground)' }}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Group justify="flex-end" gap="sm" mt="md">
|
||||
<Button variant="secondary" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={handleSubmit} disabled={!name || fromEntity === toEntity}>
|
||||
Create
|
||||
</Button>
|
||||
</Group>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
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 { 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 type { Relation, Entity, RelationInputDTO } from '../types'
|
||||
|
||||
interface Props {
|
||||
relations: Relation[]
|
||||
@@ -30,43 +30,55 @@ export function RelationTable({ relations, entities, relationPresets, onRefresh
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="mt-3">
|
||||
<CardHeader className="flex flex-row items-center justify-between py-3">
|
||||
<CardTitle className="text-base">Relations</CardTitle>
|
||||
<Button size="sm" onClick={() => setDialogOpen(true)} disabled={entities.length < 2}>
|
||||
<Plus size={14} className="mr-1" /> Add Relation
|
||||
</Button>
|
||||
<Card variant="default" style={{ marginTop: 12 }}>
|
||||
<CardHeader>
|
||||
<Group justify="space-between" align="center" py="xs">
|
||||
<CardTitle>Relations</CardTitle>
|
||||
<Button size="sm" onClick={() => setDialogOpen(true)} disabled={entities.length < 2}>
|
||||
<IconPlus size={14} style={{ marginRight: 4 }} /> Add Relation
|
||||
</Button>
|
||||
</Group>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b" style={{ borderColor: 'var(--border)' }}>
|
||||
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>From</th>
|
||||
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Relation</th>
|
||||
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>To</th>
|
||||
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Weight</th>
|
||||
<th className="text-right px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<CardContent style={{ padding: 0 }}>
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>From</Table.Th>
|
||||
<Table.Th>Relation</Table.Th>
|
||||
<Table.Th>To</Table.Th>
|
||||
<Table.Th>Weight</Table.Th>
|
||||
<Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{relations.map(r => (
|
||||
<tr key={r.id} className="border-b" style={{ borderColor: 'var(--border)' }}>
|
||||
<td className="px-4 py-2">{entityMap[r.from_entity] ?? r.from_entity}</td>
|
||||
<td className="px-4 py-2 font-medium">{r.name}</td>
|
||||
<td className="px-4 py-2">{entityMap[r.to_entity] ?? r.to_entity}</td>
|
||||
<td className="px-4 py-2" style={{ color: 'var(--muted-foreground)' }}>{r.weight?.toFixed(2) ?? '—'}</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<button onClick={() => handleDelete(r.id)} className="p-1 rounded hover:opacity-80" style={{ color: 'var(--destructive)' }}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<Table.Tr key={r.id}>
|
||||
<Table.Td>{entityMap[r.from_entity] ?? r.from_entity}</Table.Td>
|
||||
<Table.Td fw={500}>{r.name}</Table.Td>
|
||||
<Table.Td>{entityMap[r.to_entity] ?? r.to_entity}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">{r.weight?.toFixed(2) ?? '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<FnActionIcon
|
||||
icon={<IconTrash size={14} />}
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
color="red"
|
||||
onClick={() => handleDelete(r.id)}
|
||||
/>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{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>
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
|
||||
{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' {
|
||||
export const Tabs: React.FC<{ value: string; onValueChange: (v: string) => void; className?: string; children: React.ReactNode }>
|
||||
export const TabsList: React.FC<{ className?: string; children: React.ReactNode }>
|
||||
export const TabsTrigger: React.FC<{ value: string; children: React.ReactNode }>
|
||||
export const TabsContent: React.FC<{ value: string; className?: string; children: React.ReactNode }>
|
||||
export const Card: React.FC<{ className?: string; children: React.ReactNode }>
|
||||
export const CardHeader: React.FC<{ className?: string; children: React.ReactNode }>
|
||||
export const CardTitle: React.FC<{ className?: string; children: React.ReactNode }>
|
||||
export const CardContent: React.FC<{ className?: string; children: React.ReactNode }>
|
||||
export const Badge: React.FC<{ className?: string; style?: React.CSSProperties; children: React.ReactNode }>
|
||||
export const Button: React.FC<{ size?: string; variant?: string; onClick?: () => void; disabled?: boolean; className?: string; children: React.ReactNode }>
|
||||
import { type CSSProperties, type ReactNode, type ReactElement, type MouseEventHandler } from 'react'
|
||||
|
||||
// Tabs
|
||||
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 TabsList: React.FC<{ className?: string; style?: CSSProperties; children?: ReactNode }>
|
||||
export const TabsTrigger: React.FC<{ value: string; disabled?: boolean; className?: string; children?: ReactNode }>
|
||||
export const TabsContent: React.FC<{ value: string; className?: string; style?: CSSProperties; children?: ReactNode }>
|
||||
|
||||
// Card
|
||||
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 function SimpleSelect(props: { value: string; onValueChange: (value: string) => void; options: SimpleSelectOption[]; placeholder?: string; disabled?: boolean; size?: 'sm' | 'default'; className?: string }): React.ReactElement
|
||||
export function SearchBar(props: { onSearch: (query: string) => void; placeholder?: string; debounceMs?: number; 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
|
||||
|
||||
// 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' {
|
||||
@@ -42,6 +72,12 @@ declare module '@graph' {
|
||||
edges: GraphEdge[]
|
||||
}
|
||||
|
||||
export interface ContextMenuTarget {
|
||||
type: 'node' | 'edge' | 'canvas'
|
||||
id?: string
|
||||
data?: GraphNode | GraphEdge
|
||||
}
|
||||
|
||||
export const GraphContainer: React.FC<{
|
||||
data: GraphData
|
||||
layout?: string
|
||||
@@ -51,6 +87,7 @@ declare module '@graph' {
|
||||
nodeTypes?: Array<{ type: string; color: string; label: string }>
|
||||
onNodeClick?: (node: GraphNode) => void
|
||||
onNodeDoubleClick?: (node: GraphNode) => void
|
||||
onContextMenu?: (event: MouseEvent, target: ContextMenuTarget) => void
|
||||
enableSelection?: boolean
|
||||
selectionMode?: string
|
||||
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 { createRoot } from 'react-dom/client'
|
||||
import { MantineProvider, createTheme, type MantineColorsTuple } from '@mantine/core'
|
||||
import { Notifications } from '@mantine/notifications'
|
||||
import './app.css'
|
||||
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(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<MantineProvider theme={theme} defaultColorScheme="dark">
|
||||
<Notifications position="top-right" />
|
||||
<App />
|
||||
</MantineProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -119,3 +119,17 @@ export interface AssertionInput {
|
||||
severity: 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 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 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 IngestFile(arg1:string):Promise<string>;
|
||||
|
||||
export function IngestURL(arg1:string):Promise<string>;
|
||||
|
||||
export function ListAssertions(arg1:string):Promise<Array<fn_operations.Assertion>>;
|
||||
|
||||
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 RunEnricher(arg1:string,arg2:string):Promise<main.GraphData>;
|
||||
|
||||
export function SearchEntities(arg1:string):Promise<Array<fn_operations.Entity>>;
|
||||
|
||||
export function SearchGraph(arg1:string):Promise<main.GraphData>;
|
||||
|
||||
@@ -42,6 +42,14 @@ export function 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) {
|
||||
return window['go']['main']['App']['GetEntity'](arg1);
|
||||
}
|
||||
@@ -66,6 +74,14 @@ export function 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) {
|
||||
return window['go']['main']['App']['ListAssertions'](arg1);
|
||||
}
|
||||
@@ -82,6 +98,10 @@ export function ListRelations() {
|
||||
return window['go']['main']['App']['ListRelations']();
|
||||
}
|
||||
|
||||
export function RunEnricher(arg1, arg2) {
|
||||
return window['go']['main']['App']['RunEnricher'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function SearchEntities(arg1) {
|
||||
return window['go']['main']['App']['SearchEntities'](arg1);
|
||||
}
|
||||
|
||||
@@ -237,6 +237,28 @@ export namespace main {
|
||||
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 {
|
||||
name: 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 react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, './src'),
|
||||
@@ -14,6 +13,9 @@ export default defineConfig({
|
||||
},
|
||||
dedupe: ['react', 'react-dom'],
|
||||
},
|
||||
css: {
|
||||
postcss: resolve(__dirname, './postcss.config.cjs'),
|
||||
},
|
||||
server: {
|
||||
port: 5174,
|
||||
},
|
||||
|
||||
Binary file not shown.
@@ -1,10 +1,10 @@
|
||||
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 {
|
||||
TypeRef string `json:"type_ref"` // registry type ID
|
||||
Label string `json:"label"` // human-readable
|
||||
Color string `json:"color"` // hex color for graph nodes
|
||||
TypeRef string `json:"type_ref"` // short type name
|
||||
Label string `json:"label"` // human-readable
|
||||
Color string `json:"color"` // hex color for graph nodes
|
||||
MetadataFields []string `json:"metadata_fields"` // suggested metadata keys
|
||||
}
|
||||
|
||||
@@ -77,41 +77,46 @@ type AssertionInput struct {
|
||||
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{
|
||||
"osint_person_go_cybersecurity": "#e74c3c",
|
||||
"osint_organization_go_cybersecurity": "#3498db",
|
||||
"osint_crypto_wallet_go_cybersecurity": "#f39c12",
|
||||
"osint_ip_address_go_cybersecurity": "#2ecc71",
|
||||
"osint_domain_go_cybersecurity": "#9b59b6",
|
||||
"osint_email_go_cybersecurity": "#1abc9c",
|
||||
"osint_phone_go_cybersecurity": "#e67e22",
|
||||
"osint_malware_go_cybersecurity": "#c0392b",
|
||||
"osint_vulnerability_go_cybersecurity": "#8e44ad",
|
||||
"osint_social_media_go_cybersecurity": "#2980b9",
|
||||
"osint_document_go_cybersecurity": "#7f8c8d",
|
||||
"osint_event_go_cybersecurity": "#d35400",
|
||||
"osint_location_go_cybersecurity": "#27ae60",
|
||||
"person": "#e74c3c",
|
||||
"organization": "#3498db",
|
||||
"crypto_wallet": "#f39c12",
|
||||
"ip_address": "#2ecc71",
|
||||
"domain": "#9b59b6",
|
||||
"email": "#1abc9c",
|
||||
"phone": "#e67e22",
|
||||
"malware": "#c0392b",
|
||||
"vulnerability": "#8e44ad",
|
||||
"social_media": "#2980b9",
|
||||
"document": "#7f8c8d",
|
||||
"event": "#d35400",
|
||||
"location": "#27ae60",
|
||||
"text": "#78909c",
|
||||
"url": "#8bc34a",
|
||||
}
|
||||
|
||||
var entityTypePresets = []EntityTypePreset{
|
||||
{"osint_person_go_cybersecurity", "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"}},
|
||||
{"osint_crypto_wallet_go_cybersecurity", "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"}},
|
||||
{"osint_domain_go_cybersecurity", "Domain", "#9b59b6", []string{"fqdn", "registrar", "created_date", "expires_date", "name_servers"}},
|
||||
{"osint_email_go_cybersecurity", "Email", "#1abc9c", []string{"address", "provider", "verified", "breached"}},
|
||||
{"osint_phone_go_cybersecurity", "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"}},
|
||||
{"osint_vulnerability_go_cybersecurity", "Vulnerability", "#8e44ad", []string{"cve_id", "cvss", "affected_product", "published", "exploited"}},
|
||||
{"osint_social_media_go_cybersecurity", "Social Media", "#2980b9", []string{"platform", "username", "url", "followers", "verified"}},
|
||||
{"osint_document_go_cybersecurity", "Document", "#7f8c8d", []string{"title", "format", "classification", "hash_sha256", "source"}},
|
||||
{"osint_event_go_cybersecurity", "Event", "#d35400", []string{"event_type", "date", "location", "description", "severity"}},
|
||||
{"osint_location_go_cybersecurity", "Location", "#27ae60", []string{"lat", "lon", "address", "country", "city"}},
|
||||
{"person", "Person", "#e74c3c", []string{"full_name", "alias", "nationality", "dob", "gender", "risk_score"}},
|
||||
{"organization", "Organization", "#3498db", []string{"legal_name", "country", "sector", "founded", "risk_score"}},
|
||||
{"crypto_wallet", "Crypto Wallet", "#f39c12", []string{"address", "blockchain", "balance", "first_seen", "last_seen"}},
|
||||
{"ip_address", "IP Address", "#2ecc71", []string{"ip", "asn", "country", "isp", "geolocation", "last_seen"}},
|
||||
{"domain", "Domain", "#9b59b6", []string{"fqdn", "registrar", "created_date", "expires_date", "name_servers"}},
|
||||
{"email", "Email", "#1abc9c", []string{"address", "provider", "verified", "breached"}},
|
||||
{"phone", "Phone", "#e67e22", []string{"number", "country_code", "carrier", "phone_type"}},
|
||||
{"malware", "Malware", "#c0392b", []string{"family", "hash_sha256", "first_seen", "last_seen", "threat_level"}},
|
||||
{"vulnerability", "Vulnerability", "#8e44ad", []string{"cve_id", "cvss", "affected_product", "published", "exploited"}},
|
||||
{"social_media", "Social Media", "#2980b9", []string{"platform", "username", "url", "followers", "verified"}},
|
||||
{"document", "Document", "#7f8c8d", []string{"title", "format", "classification", "file_path", "source"}},
|
||||
{"event", "Event", "#d35400", []string{"event_type", "date", "location", "description", "severity"}},
|
||||
{"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{
|
||||
"funds", "employs", "communicates_with", "owns", "operates",
|
||||
"controls", "affiliated_with", "located_at", "resolves_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:serverUrl": "auto",
|
||||
"wailsjsdir": "./frontend/src",
|
||||
"tags": "fts5",
|
||||
"author": {
|
||||
"name": "Egutierrez"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user