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:
2026-04-13 23:32:55 +02:00
parent 23198eee0c
commit c9fd4aa84c
42 changed files with 2615 additions and 1543 deletions
+198 -7
View File
@@ -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
View File
@@ -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
}
+58
View File
@@ -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()
+243
View File
@@ -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()
+71
View File
@@ -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()
+52
View File
@@ -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()
+54
View File
@@ -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()
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -1 +1 @@
925e378ac695fa8339fd9576604d1d9f
994f04234280ec7657894460ce41d791
+360 -604
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -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
View File
@@ -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>
)
}
-36
View File
@@ -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;
}
+93 -76
View File
@@ -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>
)
+41 -35
View File
@@ -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>
)
}
+59 -69
View File
@@ -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>
)
}
+61 -47
View File
@@ -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) && (
+8 -6
View File
@@ -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 }}
+62
View File
@@ -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>
)
}
+46 -60
View File
@@ -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>
)
}
+51 -50
View File
@@ -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>
)
}
+48 -36
View File
@@ -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 && (
-37
View File
@@ -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>
)
}
+49 -12
View File
@@ -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>
-6
View File
@@ -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
View File
@@ -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>,
)
+14
View File
@@ -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
View File
@@ -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>;
+20
View File
@@ -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);
}
+22
View File
@@ -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
View File
@@ -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"}
+4 -2
View File
@@ -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.
View File
+36 -31
View File
@@ -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",
}
+1
View File
@@ -8,6 +8,7 @@
"frontend:dev:watcher": "pnpm run dev",
"frontend:dev:serverUrl": "auto",
"wailsjsdir": "./frontend/src",
"tags": "fts5",
"author": {
"name": "Egutierrez"
}