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