init: fuzzygraph app from fn_registry

This commit is contained in:
2026-04-06 00:56:50 +02:00
commit 23198eee0c
42 changed files with 5539 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
frontend/node_modules/
node_modules/
fuzzygraph
fuzzygraph.log
build/bin/
*.db
+606
View File
@@ -0,0 +1,606 @@
package main
import (
"context"
"crypto/rand"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
ops "fn-registry/fn_operations"
"fn-registry/registry"
)
// App is the Wails-bound application struct.
type App struct {
ctx context.Context
mu sync.RWMutex
projectsDir string
registryRoot string
registryDB *registry.DB
db *ops.DB
currentProj string
}
// NewApp creates a new App instance.
func NewApp(projectsDir, registryRoot string) *App {
log.Printf("[NewApp] projectsDir=%s registryRoot=%s", projectsDir, registryRoot)
return &App{
projectsDir: projectsDir,
registryRoot: registryRoot,
}
}
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
log.Println("[startup] called")
// Open registry.db for type lookups and snapshots
regPath := filepath.Join(a.registryRoot, "registry.db")
log.Printf("[startup] opening registry.db at %s", regPath)
if db, err := registry.Open(regPath); err == nil {
a.registryDB = db
log.Println("[startup] registry.db opened OK")
} else {
log.Printf("[startup] WARNING: registry.db failed: %v", err)
}
// Ensure projects directory exists
log.Printf("[startup] ensuring projectsDir exists: %s", a.projectsDir)
if err := os.MkdirAll(a.projectsDir, 0o755); err != nil {
log.Printf("[startup] ERROR creating projectsDir: %v", err)
}
// List what's there already
entries, err := os.ReadDir(a.projectsDir)
if err != nil {
log.Printf("[startup] ERROR reading projectsDir: %v", err)
} else {
log.Printf("[startup] projectsDir has %d entries", len(entries))
for _, e := range entries {
log.Printf("[startup] - %s (dir=%v)", e.Name(), e.IsDir())
}
}
log.Println("[startup] done")
}
func (a *App) shutdown(_ context.Context) {
log.Println("[shutdown] called")
a.mu.Lock()
defer a.mu.Unlock()
if a.db != nil {
a.db.Close()
log.Println("[shutdown] closed project db")
}
if a.registryDB != nil {
a.registryDB.Close()
log.Println("[shutdown] closed registry db")
}
}
// --- Projects ---
func (a *App) ListProjects() ([]ProjectInfo, error) {
a.mu.RLock()
defer a.mu.RUnlock()
log.Printf("[ListProjects] projectsDir=%s", a.projectsDir)
projects, err := listProjectDirs(a.projectsDir)
if err != nil {
log.Printf("[ListProjects] ERROR: %v", err)
return nil, err
}
log.Printf("[ListProjects] found %d projects", len(projects))
for _, p := range projects {
log.Printf("[ListProjects] - %s (entities=%d relations=%d)", p.Name, p.EntityCount, p.RelCount)
}
return projects, nil
}
func (a *App) CreateProject(name string) (ProjectInfo, error) {
a.mu.Lock()
defer a.mu.Unlock()
name = strings.TrimSpace(name)
log.Printf("[CreateProject] name=%q projectsDir=%s", name, a.projectsDir)
if name == "" {
log.Println("[CreateProject] ERROR: empty name")
return ProjectInfo{}, fmt.Errorf("project name cannot be empty")
}
targetDir := filepath.Join(a.projectsDir, name)
log.Printf("[CreateProject] targetDir=%s", targetDir)
if err := createProject(a.projectsDir, name); err != nil {
log.Printf("[CreateProject] ERROR: %v", err)
return ProjectInfo{}, err
}
// Verify it was created
dbPath := filepath.Join(targetDir, "operations.db")
if _, err := os.Stat(dbPath); err != nil {
log.Printf("[CreateProject] WARNING: operations.db not found after create: %v", err)
} else {
log.Printf("[CreateProject] OK: operations.db exists at %s", dbPath)
}
log.Printf("[CreateProject] success: %s", name)
return ProjectInfo{Name: name}, nil
}
func (a *App) SwitchProject(name string) error {
a.mu.Lock()
defer a.mu.Unlock()
log.Printf("[SwitchProject] name=%q (current=%q)", name, a.currentProj)
if a.db != nil {
a.db.Close()
a.db = nil
log.Println("[SwitchProject] closed previous db")
}
dbPath := filepath.Join(a.projectsDir, name, "operations.db")
log.Printf("[SwitchProject] opening %s", dbPath)
db, err := ops.Open(dbPath)
if err != nil {
log.Printf("[SwitchProject] ERROR: %v", err)
return fmt.Errorf("opening project %s: %w", name, err)
}
a.db = db
a.currentProj = name
log.Printf("[SwitchProject] OK: now on project %s", name)
return nil
}
func (a *App) DeleteProject(name string) error {
a.mu.Lock()
defer a.mu.Unlock()
log.Printf("[DeleteProject] name=%q", name)
if a.currentProj == name && a.db != nil {
a.db.Close()
a.db = nil
a.currentProj = ""
log.Println("[DeleteProject] closed active project db")
}
err := deleteProject(a.projectsDir, name)
if err != nil {
log.Printf("[DeleteProject] ERROR: %v", err)
} else {
log.Printf("[DeleteProject] OK: deleted %s", name)
}
return err
}
func (a *App) GetCurrentProject() string {
a.mu.RLock()
defer a.mu.RUnlock()
log.Printf("[GetCurrentProject] -> %q", a.currentProj)
return a.currentProj
}
// --- Entities ---
func (a *App) ListEntities() ([]ops.Entity, error) {
a.mu.RLock()
defer a.mu.RUnlock()
if a.db == nil {
log.Println("[ListEntities] ERROR: no project selected")
return nil, fmt.Errorf("no project selected")
}
entities, err := a.db.ListEntities("", "")
if err != nil {
log.Printf("[ListEntities] ERROR: %v", err)
} else {
log.Printf("[ListEntities] found %d entities", len(entities))
}
return entities, err
}
func (a *App) GetEntity(id string) (*ops.Entity, error) {
a.mu.RLock()
defer a.mu.RUnlock()
if a.db == nil {
return nil, fmt.Errorf("no project selected")
}
log.Printf("[GetEntity] id=%s", id)
return a.db.GetEntity(id)
}
func (a *App) AddEntity(input EntityInput) (string, error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.db == nil {
log.Println("[AddEntity] ERROR: no project selected")
return "", fmt.Errorf("no project selected")
}
id := makeEntityID(input.Name, input.TypeRef)
log.Printf("[AddEntity] name=%q typeRef=%q -> id=%s", input.Name, input.TypeRef, id)
now := time.Now()
e := &ops.Entity{
ID: id,
Name: input.Name,
TypeRef: input.TypeRef,
Status: ops.StatusActive,
Description: input.Description,
Domain: "osint",
Tags: input.Tags,
Source: "fuzzygraph",
Metadata: input.Metadata,
Notes: input.Notes,
CreatedAt: now,
UpdatedAt: now,
}
// Use InsertEntityWithSnapshot if registry is available
if a.registryDB != nil {
log.Println("[AddEntity] using InsertEntityWithSnapshot")
if err := ops.InsertEntityWithSnapshot(a.db, a.registryDB, e); err != nil {
log.Printf("[AddEntity] ERROR: %v", err)
return "", err
}
} else {
log.Println("[AddEntity] using plain InsertEntity (no registry)")
if err := a.db.InsertEntity(e); err != nil {
log.Printf("[AddEntity] ERROR: %v", err)
return "", err
}
}
log.Printf("[AddEntity] OK: %s", id)
return id, nil
}
func (a *App) UpdateEntity(id string, input EntityInput) error {
a.mu.Lock()
defer a.mu.Unlock()
if a.db == nil {
return fmt.Errorf("no project selected")
}
log.Printf("[UpdateEntity] id=%s", id)
existing, err := a.db.GetEntity(id)
if err != nil {
log.Printf("[UpdateEntity] ERROR getting: %v", err)
return err
}
if existing == nil {
log.Printf("[UpdateEntity] ERROR: not found")
return fmt.Errorf("entity %s not found", id)
}
existing.Name = input.Name
existing.TypeRef = input.TypeRef
existing.Description = input.Description
existing.Tags = input.Tags
existing.Metadata = input.Metadata
existing.Notes = input.Notes
existing.UpdatedAt = time.Now()
if err := a.db.UpdateEntity(existing); err != nil {
log.Printf("[UpdateEntity] ERROR: %v", err)
return err
}
log.Printf("[UpdateEntity] OK: %s", id)
return nil
}
func (a *App) DeleteEntity(id string) error {
a.mu.Lock()
defer a.mu.Unlock()
if a.db == nil {
return fmt.Errorf("no project selected")
}
log.Printf("[DeleteEntity] id=%s", id)
return a.db.DeleteEntity(id)
}
// --- Relations ---
func (a *App) ListRelations() ([]ops.Relation, error) {
a.mu.RLock()
defer a.mu.RUnlock()
if a.db == nil {
log.Println("[ListRelations] ERROR: no project selected")
return nil, fmt.Errorf("no project selected")
}
relations, err := a.db.ListRelations("")
if err != nil {
log.Printf("[ListRelations] ERROR: %v", err)
} else {
log.Printf("[ListRelations] found %d relations", len(relations))
}
return relations, err
}
func (a *App) AddRelation(input RelationInputDTO) (string, error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.db == nil {
return "", fmt.Errorf("no project selected")
}
id := generateID()
log.Printf("[AddRelation] name=%q from=%s to=%s id=%s", input.Name, input.FromEntity, input.ToEntity, id)
now := time.Now()
r := &ops.Relation{
ID: id,
Name: input.Name,
FromEntity: input.FromEntity,
ToEntity: input.ToEntity,
Description: input.Description,
Purity: "impure",
Direction: ops.DirUnidirectional,
Weight: input.Weight,
Status: ops.RelImplemented,
Tags: input.Tags,
Notes: input.Notes,
CreatedAt: now,
UpdatedAt: now,
}
if err := a.db.InsertRelation(r); err != nil {
log.Printf("[AddRelation] ERROR: %v", err)
return "", err
}
log.Printf("[AddRelation] OK: %s", id)
return id, nil
}
func (a *App) UpdateRelation(id string, input RelationInputDTO) error {
a.mu.Lock()
defer a.mu.Unlock()
if a.db == nil {
return fmt.Errorf("no project selected")
}
log.Printf("[UpdateRelation] id=%s", id)
existing, err := a.db.GetRelation(id)
if err != nil {
return err
}
if existing == nil {
return fmt.Errorf("relation %s not found", id)
}
existing.Name = input.Name
existing.FromEntity = input.FromEntity
existing.ToEntity = input.ToEntity
existing.Description = input.Description
existing.Weight = input.Weight
existing.Tags = input.Tags
existing.Notes = input.Notes
existing.UpdatedAt = time.Now()
return a.db.UpdateRelation(existing)
}
func (a *App) DeleteRelation(id string) error {
a.mu.Lock()
defer a.mu.Unlock()
if a.db == nil {
return fmt.Errorf("no project selected")
}
log.Printf("[DeleteRelation] id=%s", id)
return a.db.DeleteRelation(id)
}
// --- Graph ---
func (a *App) GetGraphData() (GraphData, error) {
a.mu.RLock()
defer a.mu.RUnlock()
if a.db == nil {
log.Println("[GetGraphData] no project selected")
return GraphData{}, fmt.Errorf("no project selected")
}
data, err := buildGraphData(a.db)
if err != nil {
log.Printf("[GetGraphData] ERROR: %v", err)
} else {
log.Printf("[GetGraphData] nodes=%d edges=%d", len(data.Nodes), len(data.Edges))
}
return data, err
}
func (a *App) GetEntityNeighbors(entityID string, depth int) (GraphData, error) {
a.mu.RLock()
defer a.mu.RUnlock()
if a.db == nil {
return GraphData{}, fmt.Errorf("no project selected")
}
log.Printf("[GetEntityNeighbors] entityID=%s depth=%d", entityID, depth)
return buildEgoGraph(a.db, entityID, depth)
}
func (a *App) GetFilteredGraph(typeFilters []string) (GraphData, error) {
a.mu.RLock()
defer a.mu.RUnlock()
if a.db == nil {
return GraphData{}, fmt.Errorf("no project selected")
}
log.Printf("[GetFilteredGraph] filters=%v", typeFilters)
full, err := buildGraphData(a.db)
if err != nil {
return GraphData{}, err
}
if len(typeFilters) == 0 {
return full, nil
}
allowed := map[string]bool{}
for _, t := range typeFilters {
allowed[t] = true
}
nodeIDs := map[string]bool{}
var nodes []GraphNode
for _, n := range full.Nodes {
if allowed[n.Type] {
nodes = append(nodes, n)
nodeIDs[n.ID] = true
}
}
var edges []GraphEdge
for _, e := range full.Edges {
if nodeIDs[e.Source] && nodeIDs[e.Target] {
edges = append(edges, e)
}
}
return GraphData{Nodes: nodes, Edges: edges}, nil
}
// --- Search ---
func (a *App) SearchEntities(query string) ([]ops.Entity, error) {
a.mu.RLock()
defer a.mu.RUnlock()
if a.db == nil {
return nil, fmt.Errorf("no project selected")
}
log.Printf("[SearchEntities] query=%q", query)
results, err := searchEntitiesFTS(a.db, query)
if err != nil {
log.Printf("[SearchEntities] ERROR: %v", err)
} else {
log.Printf("[SearchEntities] found %d results", len(results))
}
return results, err
}
func (a *App) SearchGraph(query string) (GraphData, error) {
a.mu.RLock()
defer a.mu.RUnlock()
if a.db == nil {
return GraphData{}, fmt.Errorf("no project selected")
}
log.Printf("[SearchGraph] query=%q", query)
return searchGraph(a.db, query)
}
// --- Assertions ---
func (a *App) ListAssertions(entityID string) ([]ops.Assertion, error) {
a.mu.RLock()
defer a.mu.RUnlock()
if a.db == nil {
return nil, fmt.Errorf("no project selected")
}
log.Printf("[ListAssertions] entityID=%q", entityID)
active := true
return a.db.ListAssertions(entityID, &active)
}
func (a *App) AddAssertion(input AssertionInput) (string, error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.db == nil {
return "", fmt.Errorf("no project selected")
}
id := generateID()
log.Printf("[AddAssertion] name=%q entityID=%s id=%s", input.Name, input.EntityID, id)
assertion := &ops.Assertion{
ID: id,
EntityID: input.EntityID,
Name: input.Name,
Kind: input.Kind,
Rule: input.Rule,
Severity: ops.Severity(input.Severity),
Description: input.Description,
Active: true,
CreatedAt: time.Now(),
}
if err := a.db.InsertAssertion(assertion); err != nil {
log.Printf("[AddAssertion] ERROR: %v", err)
return "", err
}
log.Printf("[AddAssertion] OK: %s", id)
return id, nil
}
func (a *App) DeleteAssertion(id string) error {
a.mu.Lock()
defer a.mu.Unlock()
if a.db == nil {
return fmt.Errorf("no project selected")
}
log.Printf("[DeleteAssertion] id=%s", id)
return a.db.DeleteAssertion(id)
}
func (a *App) EvalAssertions(entityID string) ([]ops.AssertionResult, error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.db == nil {
return nil, fmt.Errorf("no project selected")
}
log.Printf("[EvalAssertions] entityID=%s", entityID)
results, err := ops.EvalEntityAssertions(a.db, entityID, "")
if err != nil {
log.Printf("[EvalAssertions] ERROR: %v", err)
} else {
log.Printf("[EvalAssertions] %d results", len(results))
}
return results, err
}
// --- Presets ---
func (a *App) GetEntityPresets() []EntityTypePreset {
log.Printf("[GetEntityPresets] returning %d presets", len(entityTypePresets))
return entityTypePresets
}
func (a *App) GetRelationPresets() []string {
log.Printf("[GetRelationPresets] returning %d presets", len(relationPresets))
return relationPresets
}
// --- Helpers ---
func makeEntityID(name, typeRef string) string {
clean := strings.ToLower(strings.TrimSpace(name))
clean = strings.ReplaceAll(clean, " ", "_")
clean = strings.ReplaceAll(clean, "-", "_")
parts := strings.Split(typeRef, "_")
shortType := typeRef
if len(parts) >= 2 {
shortType = parts[1]
}
return fmt.Sprintf("%s_%s", clean, shortType)
}
func generateID() string {
b := make([]byte, 16)
rand.Read(b)
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
Executable
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
# Launch fuzzygraph in dev mode with FTS5 support
cd "$(dirname "$0")"
CGO_ENABLED=1 wails dev -tags fts5
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FuzzyGraph</title>
<script type="module" crossorigin src="/assets/index-CYqMr7xa.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cjyz0t73.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FuzzyGraph</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+33
View File
@@ -0,0 +1,33 @@
{
"name": "fuzzygraph",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite --host",
"build": "tsc -b && vite build",
"preview": "vite preview --host"
},
"dependencies": {
"@base-ui/react": "^1.3.0",
"@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-slider": "^1.3.6",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.577.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.0",
"tailwindcss": "^4.2.2",
"typescript": "~5.9.3",
"vite": "^8.0.0"
}
}
+1
View File
@@ -0,0 +1 @@
925e378ac695fa8339fd9576604d1d9f
+1289
View File
File diff suppressed because it is too large Load Diff
+215
View File
@@ -0,0 +1,215 @@
import { useEffect, useState, useCallback } from 'react'
import type { ProjectInfo, Entity, Relation, GraphData, EntityTypePreset } from './types'
import { ProjectSidebar } from './components/ProjectSidebar'
import { SearchBar } from '@fn_library'
import { GraphView } from './components/GraphView'
import { EntityTable } from './components/EntityTable'
import { RelationTable } from './components/RelationTable'
import { EntityDetail } from './components/EntityDetail'
import { AssertionPanel } from './components/AssertionPanel'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@fn_library'
import * as WailsApp from './wailsjs/go/main/App'
export default function App() {
const [projects, setProjects] = useState<ProjectInfo[]>([])
const [currentProject, setCurrentProject] = useState<string>('')
const [entities, setEntities] = useState<Entity[]>([])
const [relations, setRelations] = useState<Relation[]>([])
const [graphData, setGraphData] = useState<GraphData>({ nodes: [], edges: [] })
const [presets, setPresets] = useState<EntityTypePreset[]>([])
const [relationPresets, setRelationPresets] = useState<string[]>([])
const [activeTab, setActiveTab] = useState('graph')
const [selectedEntityId, setSelectedEntityId] = useState<string | null>(null)
useEffect(() => {
console.log('[App] mount — loading presets and projects')
refreshProjects()
WailsApp.GetEntityPresets()
.then(p => { console.log('[App] GetEntityPresets OK:', p?.length, 'presets'); setPresets(p as unknown as EntityTypePreset[]) })
.catch(e => console.error('[App] GetEntityPresets ERROR:', e))
WailsApp.GetRelationPresets()
.then(p => { console.log('[App] GetRelationPresets OK:', p?.length); setRelationPresets(p) })
.catch(e => console.error('[App] GetRelationPresets ERROR:', e))
}, [])
const refreshProjects = useCallback(() => {
console.log('[App] refreshProjects called')
WailsApp.ListProjects()
.then(p => {
const list = (p || []) as unknown as ProjectInfo[]
console.log('[App] ListProjects OK:', list.length, 'projects', JSON.stringify(list))
setProjects(list)
})
.catch(e => console.error('[App] ListProjects ERROR:', e))
}, [])
const refreshData = useCallback(() => {
console.log('[App] refreshData called')
WailsApp.ListEntities()
.then(e => { const list = (e || []) as unknown as Entity[]; console.log('[App] ListEntities OK:', list.length); setEntities(list) })
.catch(e => console.error('[App] ListEntities ERROR:', e))
WailsApp.ListRelations()
.then(r => { const list = (r || []) as unknown as Relation[]; console.log('[App] ListRelations OK:', list.length); setRelations(list) })
.catch(e => console.error('[App] ListRelations ERROR:', e))
WailsApp.GetGraphData()
.then(g => { const data = (g || { nodes: [], edges: [] }) as unknown as GraphData; console.log('[App] GetGraphData OK: nodes=', data.nodes?.length, 'edges=', data.edges?.length); setGraphData(data) })
.catch(e => console.error('[App] GetGraphData ERROR:', e))
}, [])
const handleSwitchProject = useCallback(async (name: string) => {
console.log('[App] handleSwitchProject:', name)
try {
await WailsApp.SwitchProject(name)
console.log('[App] SwitchProject OK')
setCurrentProject(name)
refreshData()
} catch (e) {
console.error('[App] SwitchProject ERROR:', e)
}
}, [refreshData])
const handleCreateProject = useCallback(async (name: string) => {
console.log('[App] handleCreateProject:', name)
try {
const result = await WailsApp.CreateProject(name)
console.log('[App] CreateProject OK:', JSON.stringify(result))
refreshProjects()
console.log('[App] switching to new project...')
await handleSwitchProject(name)
console.log('[App] switched OK')
} catch (e) {
console.error('[App] CreateProject ERROR:', e)
}
}, [refreshProjects, handleSwitchProject])
const handleDeleteProject = useCallback(async (name: string) => {
console.log('[App] handleDeleteProject:', name)
try {
await WailsApp.DeleteProject(name)
console.log('[App] DeleteProject OK')
if (currentProject === name) {
setCurrentProject('')
setEntities([])
setRelations([])
setGraphData({ nodes: [], edges: [] })
}
refreshProjects()
} catch (e) {
console.error('[App] DeleteProject ERROR:', e)
}
}, [currentProject, refreshProjects])
const handleSearch = useCallback(async (query: string) => {
console.log('[App] handleSearch:', query)
if (!query.trim()) {
refreshData()
return
}
try {
const [ents, graph] = await Promise.all([
WailsApp.SearchEntities(query),
WailsApp.SearchGraph(query),
])
console.log('[App] Search OK: entities=', (ents as unknown[])?.length, 'graph nodes=', (graph as unknown as GraphData)?.nodes?.length)
setEntities((ents || []) as unknown as Entity[])
setGraphData((graph || { nodes: [], edges: [] }) as unknown as GraphData)
} catch (e) {
console.error('[App] Search ERROR:', e)
}
}, [refreshData])
const handleNodeClick = useCallback((nodeId: string) => {
console.log('[App] handleNodeClick:', nodeId)
setSelectedEntityId(nodeId)
}, [])
const handleNodeDoubleClick = useCallback(async (nodeId: string) => {
console.log('[App] handleNodeDoubleClick:', nodeId)
try {
const ego = await WailsApp.GetEntityNeighbors(nodeId, 2)
setGraphData((ego || { nodes: [], edges: [] }) as unknown as GraphData)
} catch (e) {
console.error('[App] GetEntityNeighbors ERROR:', e)
}
}, [])
const selectedEntity = entities.find(e => e.id === selectedEntityId) ?? null
console.log('[App] render: projects=', projects.length, 'currentProject=', currentProject, 'entities=', entities.length, 'relations=', relations.length)
return (
<div className="flex h-screen overflow-hidden">
<ProjectSidebar
projects={projects}
current={currentProject}
onSwitch={handleSwitchProject}
onCreate={handleCreateProject}
onDelete={handleDeleteProject}
/>
<main className="flex-1 flex flex-col overflow-hidden">
{currentProject ? (
<>
<div className="px-4 pt-3 pb-2 flex items-center gap-3 border-b" style={{ borderColor: 'var(--border)' }}>
<h2 className="text-sm font-semibold" style={{ color: 'var(--foreground)' }}>{currentProject}</h2>
<SearchBar onSearch={handleSearch} />
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col overflow-hidden">
<TabsList className="mx-4 mt-2">
<TabsTrigger value="graph">Graph</TabsTrigger>
<TabsTrigger value="entities">Entities ({entities.length})</TabsTrigger>
<TabsTrigger value="relations">Relations ({relations.length})</TabsTrigger>
<TabsTrigger value="assertions">Assertions</TabsTrigger>
</TabsList>
<TabsContent value="graph" className="flex-1 flex overflow-hidden m-0 p-0">
<div className="flex-1 relative">
<GraphView
data={graphData}
presets={presets}
onNodeClick={handleNodeClick}
onNodeDoubleClick={handleNodeDoubleClick}
/>
</div>
{selectedEntity && (
<EntityDetail
entity={selectedEntity}
relations={relations}
onClose={() => setSelectedEntityId(null)}
onUpdate={refreshData}
/>
)}
</TabsContent>
<TabsContent value="entities" className="flex-1 overflow-auto px-4 pb-4 m-0">
<EntityTable
entities={entities}
presets={presets}
onRefresh={refreshData}
/>
</TabsContent>
<TabsContent value="relations" className="flex-1 overflow-auto px-4 pb-4 m-0">
<RelationTable
relations={relations}
entities={entities}
relationPresets={relationPresets}
onRefresh={refreshData}
/>
</TabsContent>
<TabsContent value="assertions" className="flex-1 overflow-auto px-4 pb-4 m-0">
<AssertionPanel entities={entities} />
</TabsContent>
</Tabs>
</>
) : (
<div className="flex-1 flex items-center justify-center">
<p style={{ color: 'var(--muted-foreground)' }}>Select or create a project to begin</p>
</div>
)}
</main>
</div>
)
}
+39
View File
@@ -0,0 +1,39 @@
@import "tailwindcss";
:root {
--background: oklch(8% 0.015 260);
--foreground: oklch(95% 0.01 260);
--muted: oklch(18% 0.02 260);
--muted-foreground: oklch(60% 0.02 260);
--border: oklch(15% 0.01 260);
--primary: oklch(65% 0.22 260);
--primary-foreground: oklch(98% 0.01 260);
--secondary: oklch(20% 0.02 260);
--secondary-foreground: oklch(95% 0.01 260);
--accent: oklch(18% 0.03 260);
--accent-foreground: oklch(95% 0.01 260);
--destructive: oklch(55% 0.22 25);
--destructive-foreground: oklch(98% 0.01 260);
--card: oklch(11% 0.015 260);
--card-foreground: oklch(95% 0.01 260);
--popover: oklch(12% 0.015 260);
--popover-foreground: oklch(95% 0.01 260);
--ring: oklch(65% 0.22 260);
--input: oklch(22% 0.02 260);
--radius: 0.5rem;
--success: oklch(65% 0.2 145);
--success-foreground: oklch(98% 0.01 145);
}
body {
background-color: var(--background);
color: var(--foreground);
font-family: 'Geist Variable', system-ui, -apple-system, sans-serif;
-webkit-font-smoothing: antialiased;
}
[data-slot="card"] {
border: none;
box-shadow: none;
}
+170
View File
@@ -0,0 +1,170 @@
import { useState } from 'react'
import type { Entity, Assertion, AssertionResult, AssertionInput } from '../types'
import { Plus, Play, Trash2 } from 'lucide-react'
import { SimpleSelect } from '@fn_library'
import { Card, CardHeader, CardTitle, CardContent } from '@fn_library'
import { Button } from '@fn_library'
import * as WailsApp from '../wailsjs/go/main/App'
interface Props {
entities: Entity[]
}
export function AssertionPanel({ entities }: Props) {
const [assertions, setAssertions] = useState<Assertion[]>([])
const [results, setResults] = useState<AssertionResult[]>([])
const [selectedEntity, setSelectedEntity] = useState(entities[0]?.id ?? '')
const [showAdd, setShowAdd] = useState(false)
const [newName, setNewName] = useState('')
const [newKind, setNewKind] = useState('range')
const [newRule, setNewRule] = useState('')
const [newSeverity, setNewSeverity] = useState('warning')
const loadAssertions = async (entityId: string) => {
setSelectedEntity(entityId)
const list = await WailsApp.ListAssertions(entityId) as unknown as Assertion[]
setAssertions(list || [])
setResults([])
}
const handleAdd = async () => {
if (!newName || !newRule) return
const input: AssertionInput = {
entity_id: selectedEntity,
name: newName,
kind: newKind,
rule: newRule,
severity: newSeverity,
description: '',
}
await WailsApp.AddAssertion(input as never)
setShowAdd(false)
setNewName('')
setNewRule('')
loadAssertions(selectedEntity)
}
const handleEval = async () => {
const res = await WailsApp.EvalAssertions(selectedEntity) as unknown as AssertionResult[]
setResults(res || [])
}
const handleDelete = async (id: string) => {
await WailsApp.DeleteAssertion(id)
loadAssertions(selectedEntity)
}
const inputStyle = { background: 'var(--input)', color: 'var(--foreground)', border: '1px solid var(--border)' }
return (
<Card className="mt-3">
<CardHeader className="flex flex-row items-center justify-between py-3">
<CardTitle className="text-base">Assertions</CardTitle>
<div className="flex gap-2">
<SimpleSelect
value={selectedEntity}
onValueChange={loadAssertions}
placeholder="Select entity..."
className="w-48"
options={entities.map(e => ({ value: e.id, label: e.name }))}
/>
<Button size="sm" onClick={handleEval} disabled={!selectedEntity}>
<Play size={14} className="mr-1" /> Eval
</Button>
<Button size="sm" variant="outline" onClick={() => setShowAdd(!showAdd)} disabled={!selectedEntity}>
<Plus size={14} />
</Button>
</div>
</CardHeader>
{showAdd && (
<div className="px-4 pb-3 flex gap-2 items-end">
<div className="flex-1">
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Name</label>
<input value={newName} onChange={e => setNewName(e.target.value)} className="w-full px-2 py-1 rounded text-sm" style={inputStyle} />
</div>
<div className="w-24">
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Kind</label>
<SimpleSelect
value={newKind}
onValueChange={setNewKind}
options={[
{ value: 'range', label: 'range' },
{ value: 'null', label: 'null' },
{ value: 'statistical', label: 'statistical' },
{ value: 'consistency', label: 'consistency' },
{ value: 'freshness', label: 'freshness' },
]}
/>
</div>
<div className="w-24">
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Severity</label>
<SimpleSelect
value={newSeverity}
onValueChange={setNewSeverity}
options={[
{ value: 'critical', label: 'critical' },
{ value: 'warning', label: 'warning' },
{ value: 'info', label: 'info' },
]}
/>
</div>
<div className="flex-1">
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Rule (SQL expr)</label>
<input value={newRule} onChange={e => setNewRule(e.target.value)} className="w-full px-2 py-1 rounded text-sm" style={inputStyle} placeholder="risk_score > 70" />
</div>
<button onClick={handleAdd} className="px-3 py-1 rounded text-sm" style={{ background: 'var(--primary)', color: 'var(--primary-foreground)' }}>Add</button>
</div>
)}
<CardContent className="p-0">
<table className="w-full text-sm">
<thead>
<tr className="border-b" style={{ borderColor: 'var(--border)' }}>
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Name</th>
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Kind</th>
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Rule</th>
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Severity</th>
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Result</th>
<th className="text-right px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Actions</th>
</tr>
</thead>
<tbody>
{assertions.map(a => {
const result = results.find(r => r.assertion_id === a.id)
return (
<tr key={a.id} className="border-b" style={{ borderColor: 'var(--border)' }}>
<td className="px-4 py-2">{a.name}</td>
<td className="px-4 py-2" style={{ color: 'var(--muted-foreground)' }}>{a.kind}</td>
<td className="px-4 py-2 font-mono text-xs">{a.rule}</td>
<td className="px-4 py-2">
<span style={{ color: a.severity === 'critical' ? 'var(--destructive)' : a.severity === 'warning' ? 'var(--chart-3, #f59e0b)' : 'var(--muted-foreground)' }}>
{a.severity}
</span>
</td>
<td className="px-4 py-2">
{result ? (
<span style={{ color: result.status === 'pass' ? 'var(--success)' : 'var(--destructive)' }}>
{result.status}
</span>
) : '—'}
</td>
<td className="px-4 py-2 text-right">
<button onClick={() => handleDelete(a.id)} className="p-1 rounded" style={{ color: 'var(--destructive)' }}>
<Trash2 size={14} />
</button>
</td>
</tr>
)
})}
{assertions.length === 0 && (
<tr><td colSpan={6} className="px-4 py-8 text-center" style={{ color: 'var(--muted-foreground)' }}>
{selectedEntity ? 'No assertions for this entity' : 'Select an entity to view assertions'}
</td></tr>
)}
</tbody>
</table>
</CardContent>
</Card>
)
}
+91
View File
@@ -0,0 +1,91 @@
import type { Entity, Relation } from '../types'
import { X, ExternalLink } from 'lucide-react'
interface Props {
entity: Entity
relations: Relation[]
onClose: () => void
onUpdate: () => void
}
export function EntityDetail({ entity, relations, onClose }: Props) {
const directRelations = relations.filter(r => r.from_entity === entity.id || r.to_entity === entity.id)
return (
<aside className="w-72 border-l overflow-y-auto" style={{ borderColor: 'var(--border)', background: 'var(--card)' }}>
<div className="p-3 flex items-center justify-between border-b" style={{ borderColor: 'var(--border)' }}>
<h3 className="text-sm font-semibold truncate">{entity.name}</h3>
<button onClick={onClose} className="p-1">
<X size={14} style={{ color: 'var(--muted-foreground)' }} />
</button>
</div>
<div className="p-3 space-y-3 text-sm">
<Section label="Type">{entity.type_ref.replace(/_go_cybersecurity$/, '').replace(/^osint_/, '')}</Section>
<Section label="Status">{entity.status}</Section>
{entity.description && <Section label="Description">{entity.description}</Section>}
{entity.metadata && Object.keys(entity.metadata).length > 0 && (
<div>
<label className="block text-xs font-semibold mb-1" style={{ color: 'var(--muted-foreground)' }}>Metadata</label>
<div className="space-y-1">
{Object.entries(entity.metadata).map(([k, v]) => (
<div key={k} className="flex justify-between">
<span style={{ color: 'var(--muted-foreground)' }}>{k}</span>
<span className="font-mono text-xs">{String(v)}</span>
</div>
))}
</div>
</div>
)}
{entity.notes && (
<div>
<label className="block text-xs font-semibold mb-1" style={{ color: 'var(--muted-foreground)' }}>Notes</label>
<p className="text-xs whitespace-pre-wrap" style={{ color: 'var(--foreground)' }}>{entity.notes}</p>
</div>
)}
{entity.tags && entity.tags.length > 0 && (
<div>
<label className="block text-xs font-semibold mb-1" style={{ color: 'var(--muted-foreground)' }}>Tags</label>
<div className="flex flex-wrap gap-1">
{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>
))}
</div>
</div>
)}
{directRelations.length > 0 && (
<div>
<label className="block text-xs font-semibold mb-1" style={{ color: 'var(--muted-foreground)' }}>
Relations ({directRelations.length})
</label>
<div className="space-y-1">
{directRelations.map(r => {
const isFrom = r.from_entity === entity.id
return (
<div key={r.id} className="flex items-center gap-1 text-xs">
<ExternalLink size={10} style={{ color: 'var(--muted-foreground)' }} />
<span>{isFrom ? '' : '<-'} {r.name} {isFrom ? '->' : ''}</span>
<span className="font-medium">{isFrom ? r.to_entity : r.from_entity}</span>
</div>
)
})}
</div>
</div>
)}
</div>
</aside>
)
}
function Section({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<label className="block text-xs font-semibold" style={{ color: 'var(--muted-foreground)' }}>{label}</label>
<span>{children}</span>
</div>
)
}
+160
View File
@@ -0,0 +1,160 @@
import { useState, useEffect } from 'react'
import type { Entity, EntityTypePreset, EntityInput } from '../types'
import { SimpleSelect } from '@fn_library'
import { X } from 'lucide-react'
interface Props {
presets: EntityTypePreset[]
entity: Entity | null // null = create, non-null = edit
onSubmit: (input: EntityInput) => void
onClose: () => void
}
export function EntityDialog({ presets, entity, onSubmit, onClose }: Props) {
const [name, setName] = useState(entity?.name ?? '')
const [typeRef, setTypeRef] = useState(entity?.type_ref ?? presets[0]?.type_ref ?? '')
const [description, setDescription] = useState(entity?.description ?? '')
const [notes, setNotes] = useState(entity?.notes ?? '')
const [tagsStr, setTagsStr] = useState((entity?.tags ?? []).join(', '))
const [metadata, setMetadata] = useState<Record<string, string>>(() => {
const m: Record<string, string> = {}
if (entity?.metadata) {
for (const [k, v] of Object.entries(entity.metadata)) {
m[k] = String(v ?? '')
}
}
return m
})
const currentPreset = presets.find(p => p.type_ref === typeRef)
const metadataFields = currentPreset?.metadata_fields ?? []
// When type changes, reset metadata fields to match new type
useEffect(() => {
if (!entity) {
const m: Record<string, string> = {}
for (const f of metadataFields) {
m[f] = metadata[f] ?? ''
}
setMetadata(m)
}
}, [typeRef]) // eslint-disable-line react-hooks/exhaustive-deps
const handleSubmit = () => {
const cleanMeta: Record<string, unknown> = {}
for (const [k, v] of Object.entries(metadata)) {
if (v.trim()) {
// Try parsing as number
const num = Number(v)
if (!isNaN(num) && v.trim() !== '') {
cleanMeta[k] = num
} else if (v === 'true') {
cleanMeta[k] = true
} else if (v === 'false') {
cleanMeta[k] = false
} else {
cleanMeta[k] = v.trim()
}
}
}
onSubmit({
name: name.trim(),
type_ref: typeRef,
description: description.trim(),
tags: tagsStr.split(',').map(t => t.trim()).filter(Boolean),
metadata: cleanMeta,
notes: notes.trim(),
})
}
const inputStyle = {
background: 'var(--input)',
color: 'var(--foreground)',
border: '1px solid var(--border)',
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.6)' }}>
<div className="w-[520px] max-h-[85vh] overflow-y-auto rounded-lg p-5" style={{ background: 'var(--card)', border: '1px solid var(--border)' }}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-base font-semibold">{entity ? 'Edit Entity' : 'New Entity'}</h3>
<button onClick={onClose} className="p-1"><X size={16} style={{ color: 'var(--muted-foreground)' }} /></button>
</div>
<div className="space-y-3">
<div>
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Name</label>
<input value={name} onChange={e => setName(e.target.value)} className="w-full px-3 py-1.5 rounded text-sm" style={inputStyle} />
</div>
<div>
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Type</label>
<SimpleSelect
value={typeRef}
onValueChange={setTypeRef}
options={presets.map(p => ({ value: p.type_ref, label: p.label }))}
/>
</div>
<div>
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Description</label>
<input value={description} onChange={e => setDescription(e.target.value)} className="w-full px-3 py-1.5 rounded text-sm" style={inputStyle} />
</div>
<div>
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Tags (comma separated)</label>
<input value={tagsStr} onChange={e => setTagsStr(e.target.value)} className="w-full px-3 py-1.5 rounded text-sm" style={inputStyle} placeholder="osint, high-risk" />
</div>
{metadataFields.length > 0 && (
<div>
<label className="block text-xs mb-1 font-semibold" style={{ color: 'var(--muted-foreground)' }}>
Metadata ({currentPreset?.label})
</label>
<div className="space-y-2">
{metadataFields.map(field => (
<div key={field} className="flex items-center gap-2">
<span className="text-xs w-28 text-right" style={{ color: 'var(--muted-foreground)' }}>{field}</span>
<input
value={metadata[field] ?? ''}
onChange={e => setMetadata(prev => ({ ...prev, [field]: e.target.value }))}
className="flex-1 px-2 py-1 rounded text-sm"
style={inputStyle}
/>
</div>
))}
</div>
</div>
)}
<div>
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Notes</label>
<textarea
value={notes}
onChange={e => setNotes(e.target.value)}
rows={3}
className="w-full px-3 py-1.5 rounded text-sm resize-none"
style={inputStyle}
placeholder="Operational notes..."
/>
</div>
</div>
<div className="flex justify-end gap-2 mt-4">
<button onClick={onClose} className="px-3 py-1.5 rounded text-sm" style={{ background: 'var(--secondary)', color: 'var(--secondary-foreground)' }}>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={!name.trim()}
className="px-3 py-1.5 rounded text-sm font-medium disabled:opacity-40"
style={{ background: 'var(--primary)', color: 'var(--primary-foreground)' }}
>
{entity ? 'Update' : 'Create'}
</button>
</div>
</div>
</div>
)
}
+107
View File
@@ -0,0 +1,107 @@
import { useState } from 'react'
import type { Entity, EntityTypePreset, EntityInput } from '../types'
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'
interface Props {
entities: Entity[]
presets: EntityTypePreset[]
onRefresh: () => void
}
export function EntityTable({ entities, presets, onRefresh }: Props) {
const [dialogOpen, setDialogOpen] = useState(false)
const [editEntity, setEditEntity] = useState<Entity | null>(null)
const presetMap = Object.fromEntries(presets.map(p => [p.type_ref, p]))
const handleAdd = async (input: EntityInput) => {
await AddEntity(input as never)
setDialogOpen(false)
onRefresh()
}
const handleUpdate = async (input: EntityInput) => {
if (editEntity) {
await UpdateEntity(editEntity.id, input as never)
setEditEntity(null)
onRefresh()
}
}
const handleDelete = async (id: string) => {
await DeleteEntity(id)
onRefresh()
}
return (
<Card className="mt-3">
<CardHeader className="flex flex-row items-center justify-between py-3">
<CardTitle className="text-base">Entities</CardTitle>
<Button size="sm" onClick={() => setDialogOpen(true)}>
<Plus size={14} className="mr-1" /> Add Entity
</Button>
</CardHeader>
<CardContent className="p-0">
<table className="w-full text-sm">
<thead>
<tr className="border-b" style={{ borderColor: 'var(--border)' }}>
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Name</th>
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Type</th>
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Status</th>
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Notes</th>
<th className="text-right px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Actions</th>
</tr>
</thead>
<tbody>
{entities.map(e => {
const preset = presetMap[e.type_ref]
return (
<tr key={e.id} className="border-b hover:opacity-90" style={{ borderColor: 'var(--border)' }}>
<td className="px-4 py-2 font-medium">{e.name}</td>
<td className="px-4 py-2">
<Badge style={{ backgroundColor: preset?.color ?? 'var(--muted-foreground)', color: 'var(--primary-foreground)' }}>
{preset?.label ?? e.type_ref}
</Badge>
</td>
<td className="px-4 py-2">
<span className="text-xs" style={{ color: e.status === 'active' ? 'var(--success)' : 'var(--muted-foreground)' }}>
{e.status}
</span>
</td>
<td className="px-4 py-2 max-w-48 truncate" style={{ color: 'var(--muted-foreground)' }}>
{e.notes || '—'}
</td>
<td className="px-4 py-2 text-right">
<button onClick={() => setEditEntity(e)} className="p-1 mr-1 rounded hover:opacity-80" style={{ color: 'var(--primary)' }}>
<Pencil size={14} />
</button>
<button onClick={() => handleDelete(e.id)} className="p-1 rounded hover:opacity-80" style={{ color: 'var(--destructive)' }}>
<Trash2 size={14} />
</button>
</td>
</tr>
)
})}
{entities.length === 0 && (
<tr><td colSpan={5} className="px-4 py-8 text-center" style={{ color: 'var(--muted-foreground)' }}>No entities yet</td></tr>
)}
</tbody>
</table>
</CardContent>
{(dialogOpen || editEntity) && (
<EntityDialog
presets={presets}
entity={editEntity}
onSubmit={editEntity ? handleUpdate : handleAdd}
onClose={() => { setDialogOpen(false); setEditEntity(null) }}
/>
)}
</Card>
)
}
+67
View File
@@ -0,0 +1,67 @@
import { GraphContainer } from '@graph'
import type { GraphData as LibGraphData } from '@graph'
import type { GraphData, EntityTypePreset } from '../types'
interface Props {
data: GraphData
presets: EntityTypePreset[]
onNodeClick: (nodeId: string) => void
onNodeDoubleClick: (nodeId: string) => void
}
export function GraphView({ data, presets, onNodeClick, onNodeDoubleClick }: Props) {
// Map our GraphData to the library's format (they're compatible but need the cast)
const libData: LibGraphData = {
nodes: data.nodes.map(n => ({
id: n.id,
label: n.label,
type: n.type,
color: n.color,
size: n.size,
x: n.x,
y: n.y,
})),
edges: data.edges.map(e => ({
id: e.id,
source: e.source,
target: e.target,
label: e.label,
color: e.color,
size: e.size,
type: e.type as 'arrow' | 'line',
})),
}
const nodeTypes = presets.map(p => ({
type: p.type_ref,
color: p.color,
label: p.label,
}))
if (data.nodes.length === 0) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-sm" style={{ color: 'var(--muted-foreground)' }}>
No data to display. Add entities and relations to build the graph.
</p>
</div>
)
}
return (
<GraphContainer
data={libData}
layout="organic"
showToolbar
showLegend
showMinimap
nodeTypes={nodeTypes}
onNodeClick={n => onNodeClick(n.id)}
onNodeDoubleClick={n => onNodeDoubleClick(n.id)}
enableSelection
selectionMode="multiple"
theme={{ nodeSize: 8, edgeSize: 1 }}
height="100%"
/>
)
}
+103
View File
@@ -0,0 +1,103 @@
import { useState } from 'react'
import type { ProjectInfo } from '../types'
import { Plus, Trash2, FolderOpen } from 'lucide-react'
interface Props {
projects: ProjectInfo[]
current: string
onSwitch: (name: string) => void
onCreate: (name: string) => void
onDelete: (name: string) => void
}
export function ProjectSidebar({ projects, current, onSwitch, onCreate, onDelete }: Props) {
const [newName, setNewName] = useState('')
const [showInput, setShowInput] = useState(false)
console.log('[ProjectSidebar] render: projects=', projects.length, 'current=', current, 'projects data:', JSON.stringify(projects))
const handleCreate = () => {
const name = newName.trim()
console.log('[ProjectSidebar] handleCreate: name=', JSON.stringify(name))
if (name) {
onCreate(name)
setNewName('')
setShowInput(false)
} else {
console.log('[ProjectSidebar] handleCreate: empty name, skipping')
}
}
return (
<aside className="w-56 flex flex-col border-r" style={{ borderColor: 'var(--border)', background: 'var(--card)' }}>
<div className="p-3 flex items-center justify-between border-b" style={{ borderColor: 'var(--border)' }}>
<span className="text-xs font-bold uppercase tracking-wider" style={{ color: 'var(--muted-foreground)' }}>
Projects
</span>
<button
onClick={() => { console.log('[ProjectSidebar] toggling input'); setShowInput(!showInput) }}
className="p-1 rounded hover:opacity-80"
style={{ color: 'var(--primary)' }}
>
<Plus size={16} />
</button>
</div>
{showInput && (
<div className="p-2 border-b" style={{ borderColor: 'var(--border)' }}>
<input
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => {
console.log('[ProjectSidebar] keyDown:', e.key)
if (e.key === 'Enter') handleCreate()
}}
placeholder="Project name..."
autoFocus
className="w-full px-2 py-1 rounded text-sm"
style={{
background: 'var(--input)',
color: 'var(--foreground)',
border: '1px solid var(--border)',
}}
/>
</div>
)}
<div className="flex-1 overflow-y-auto">
{projects.map(p => (
<button
key={p.name}
onClick={() => { console.log('[ProjectSidebar] switching to:', p.name); onSwitch(p.name) }}
className="flex w-full items-center gap-2 px-3 py-2 cursor-pointer group text-left"
style={{
background: p.name === current ? 'var(--accent)' : 'transparent',
color: p.name === current ? 'var(--accent-foreground)' : 'var(--foreground)',
}}
>
<FolderOpen size={14} style={{ color: 'var(--muted-foreground)' }} />
<div className="flex-1 min-w-0">
<div className="text-sm truncate">{p.name}</div>
<div className="text-xs" style={{ color: 'var(--muted-foreground)' }}>
{p.entity_count}E / {p.relation_count}R
</div>
</div>
<button
onClick={e => { e.stopPropagation(); console.log('[ProjectSidebar] deleting:', p.name); onDelete(p.name) }}
className="opacity-0 group-hover:opacity-100 p-1 rounded"
style={{ color: 'var(--destructive)' }}
>
<Trash2 size={12} />
</button>
</button>
))}
{projects.length === 0 && (
<p className="p-3 text-xs" style={{ color: 'var(--muted-foreground)' }}>
No projects yet
</p>
)}
</div>
</aside>
)
}
+107
View File
@@ -0,0 +1,107 @@
import { useState } from 'react'
import type { Entity, RelationInputDTO } from '../types'
import { SimpleSelect } from '@fn_library'
import { X } from 'lucide-react'
interface Props {
entities: Entity[]
relationPresets: string[]
onSubmit: (input: RelationInputDTO) => void
onClose: () => void
}
export function RelationDialog({ entities, relationPresets, onSubmit, onClose }: Props) {
const [name, setName] = useState(relationPresets[0] ?? '')
const [fromEntity, setFromEntity] = useState(entities[0]?.id ?? '')
const [toEntity, setToEntity] = useState(entities[1]?.id ?? entities[0]?.id ?? '')
const [description, setDescription] = useState('')
const [weight, setWeight] = useState('1.0')
const [notes, setNotes] = useState('')
const handleSubmit = () => {
const w = parseFloat(weight)
onSubmit({
name,
from_entity: fromEntity,
to_entity: toEntity,
description,
weight: isNaN(w) ? null : w,
tags: [],
notes,
})
}
const inputStyle = {
background: 'var(--input)',
color: 'var(--foreground)',
border: '1px solid var(--border)',
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.6)' }}>
<div className="w-[480px] rounded-lg p-5" style={{ background: 'var(--card)', border: '1px solid var(--border)' }}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-base font-semibold">New Relation</h3>
<button onClick={onClose} className="p-1"><X size={16} style={{ color: 'var(--muted-foreground)' }} /></button>
</div>
<div className="space-y-3">
<div>
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Relation Type</label>
<SimpleSelect
value={name}
onValueChange={setName}
options={relationPresets.map(p => ({ value: p, label: p }))}
/>
</div>
<div className="flex gap-3">
<div className="flex-1">
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>From</label>
<SimpleSelect
value={fromEntity}
onValueChange={setFromEntity}
options={entities.map(e => ({ value: e.id, label: e.name }))}
/>
</div>
<div className="flex-1">
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>To</label>
<SimpleSelect
value={toEntity}
onValueChange={setToEntity}
options={entities.map(e => ({ value: e.id, label: e.name }))}
/>
</div>
</div>
<div>
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Description</label>
<input value={description} onChange={e => setDescription(e.target.value)} className="w-full px-3 py-1.5 rounded text-sm" style={inputStyle} />
</div>
<div>
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Weight (0.0 - 1.0)</label>
<input value={weight} onChange={e => setWeight(e.target.value)} type="number" step="0.1" min="0" max="1" className="w-full px-3 py-1.5 rounded text-sm" style={inputStyle} />
</div>
<div>
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Notes</label>
<textarea value={notes} onChange={e => setNotes(e.target.value)} rows={2} className="w-full px-3 py-1.5 rounded text-sm resize-none" style={inputStyle} />
</div>
</div>
<div className="flex justify-end gap-2 mt-4">
<button onClick={onClose} className="px-3 py-1.5 rounded text-sm" style={{ background: 'var(--secondary)', color: 'var(--secondary-foreground)' }}>Cancel</button>
<button
onClick={handleSubmit}
disabled={!name || fromEntity === toEntity}
className="px-3 py-1.5 rounded text-sm font-medium disabled:opacity-40"
style={{ background: 'var(--primary)', color: 'var(--primary-foreground)' }}
>
Create
</button>
</div>
</div>
</div>
)
}
+82
View File
@@ -0,0 +1,82 @@
import { useState } from 'react'
import type { Relation, Entity, RelationInputDTO } from '../types'
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'
interface Props {
relations: Relation[]
entities: Entity[]
relationPresets: string[]
onRefresh: () => void
}
export function RelationTable({ relations, entities, relationPresets, onRefresh }: Props) {
const [dialogOpen, setDialogOpen] = useState(false)
const entityMap = Object.fromEntries(entities.map(e => [e.id, e.name]))
const handleAdd = async (input: RelationInputDTO) => {
await AddRelation(input as never)
setDialogOpen(false)
onRefresh()
}
const handleDelete = async (id: string) => {
await DeleteRelation(id)
onRefresh()
}
return (
<Card className="mt-3">
<CardHeader className="flex flex-row items-center justify-between py-3">
<CardTitle className="text-base">Relations</CardTitle>
<Button size="sm" onClick={() => setDialogOpen(true)} disabled={entities.length < 2}>
<Plus size={14} className="mr-1" /> Add Relation
</Button>
</CardHeader>
<CardContent className="p-0">
<table className="w-full text-sm">
<thead>
<tr className="border-b" style={{ borderColor: 'var(--border)' }}>
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>From</th>
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Relation</th>
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>To</th>
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Weight</th>
<th className="text-right px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Actions</th>
</tr>
</thead>
<tbody>
{relations.map(r => (
<tr key={r.id} className="border-b" style={{ borderColor: 'var(--border)' }}>
<td className="px-4 py-2">{entityMap[r.from_entity] ?? r.from_entity}</td>
<td className="px-4 py-2 font-medium">{r.name}</td>
<td className="px-4 py-2">{entityMap[r.to_entity] ?? r.to_entity}</td>
<td className="px-4 py-2" style={{ color: 'var(--muted-foreground)' }}>{r.weight?.toFixed(2) ?? '—'}</td>
<td className="px-4 py-2 text-right">
<button onClick={() => handleDelete(r.id)} className="p-1 rounded hover:opacity-80" style={{ color: 'var(--destructive)' }}>
<Trash2 size={14} />
</button>
</td>
</tr>
))}
{relations.length === 0 && (
<tr><td colSpan={5} className="px-4 py-8 text-center" style={{ color: 'var(--muted-foreground)' }}>No relations yet</td></tr>
)}
</tbody>
</table>
</CardContent>
{dialogOpen && (
<RelationDialog
entities={entities}
relationPresets={relationPresets}
onSubmit={handleAdd}
onClose={() => setDialogOpen(false)}
/>
)}
</Card>
)
}
+37
View File
@@ -0,0 +1,37 @@
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>
)
}
+61
View File
@@ -0,0 +1,61 @@
declare module '@fn_library' {
export const Tabs: React.FC<{ value: string; onValueChange: (v: string) => void; className?: string; children: React.ReactNode }>
export const TabsList: React.FC<{ className?: string; children: React.ReactNode }>
export const TabsTrigger: React.FC<{ value: string; children: React.ReactNode }>
export const TabsContent: React.FC<{ value: string; className?: string; children: React.ReactNode }>
export const Card: React.FC<{ className?: string; children: React.ReactNode }>
export const CardHeader: React.FC<{ className?: string; children: React.ReactNode }>
export const CardTitle: React.FC<{ className?: string; children: React.ReactNode }>
export const CardContent: React.FC<{ className?: string; children: React.ReactNode }>
export const Badge: React.FC<{ className?: string; style?: React.CSSProperties; children: React.ReactNode }>
export const Button: React.FC<{ size?: string; variant?: string; onClick?: () => void; disabled?: boolean; className?: string; children: React.ReactNode }>
export interface SimpleSelectOption { value: string; label: string; disabled?: boolean }
export function SimpleSelect(props: { value: string; onValueChange: (value: string) => void; options: SimpleSelectOption[]; placeholder?: string; disabled?: boolean; size?: 'sm' | 'default'; className?: string }): React.ReactElement
export function SearchBar(props: { onSearch: (query: string) => void; placeholder?: string; debounceMs?: number; className?: string }): React.ReactElement
}
declare module '@graph' {
export interface GraphNode {
id: string
label?: string
type?: string
color?: string
size?: number
x?: number
y?: number
[key: string]: unknown
}
export interface GraphEdge {
id: string
source: string
target: string
label?: string
color?: string
size?: number
type?: 'arrow' | 'line'
[key: string]: unknown
}
export interface GraphData {
nodes: GraphNode[]
edges: GraphEdge[]
}
export const GraphContainer: React.FC<{
data: GraphData
layout?: string
showToolbar?: boolean
showLegend?: boolean
showMinimap?: boolean
nodeTypes?: Array<{ type: string; color: string; label: string }>
onNodeClick?: (node: GraphNode) => void
onNodeDoubleClick?: (node: GraphNode) => void
enableSelection?: boolean
selectionMode?: string
theme?: Record<string, unknown>
height?: string | number
}>
}
declare module '*.css' {}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './app.css'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+121
View File
@@ -0,0 +1,121 @@
export interface ProjectInfo {
name: string
description: string
entity_count: number
relation_count: number
}
export interface Entity {
id: string
name: string
type_ref: string
status: string
description: string
domain: string
tags: string[]
source: string
metadata: Record<string, unknown>
notes: string
created_at: string
updated_at: string
}
export interface Relation {
id: string
name: string
from_entity: string
to_entity: string
via: string
description: string
purity: string
direction: string
weight: number | null
status: string
tags: string[]
notes: string
created_at: string
updated_at: string
}
export interface EntityTypePreset {
type_ref: string
label: string
color: string
metadata_fields: string[]
}
export interface GraphData {
nodes: GraphNode[]
edges: GraphEdge[]
}
export interface GraphNode {
id: string
label: string
type: string
color: string
size: number
x: number
y: number
extra?: Record<string, unknown>
}
export interface GraphEdge {
id: string
source: string
target: string
label: string
color: string
size: number
type: string
}
export interface EntityInput {
name: string
type_ref: string
description: string
tags: string[]
metadata: Record<string, unknown>
notes: string
}
export interface RelationInputDTO {
name: string
from_entity: string
to_entity: string
description: string
weight: number | null
tags: string[]
notes: string
}
export interface Assertion {
id: string
entity_id: string
name: string
kind: string
rule: string
severity: string
description: string
active: boolean
created_at: string
}
export interface AssertionResult {
id: string
assertion_id: string
execution_id: string
status: string
value: Record<string, unknown>
message: string
evaluated_at: string
}
export interface AssertionInput {
entity_id: string
name: string
kind: string
rule: string
severity: string
description: string
}
+54
View File
@@ -0,0 +1,54 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {main} from '../models';
import {fn_operations} from '../models';
export function AddAssertion(arg1:main.AssertionInput):Promise<string>;
export function AddEntity(arg1:main.EntityInput):Promise<string>;
export function AddRelation(arg1:main.RelationInputDTO):Promise<string>;
export function CreateProject(arg1:string):Promise<main.ProjectInfo>;
export function DeleteAssertion(arg1:string):Promise<void>;
export function DeleteEntity(arg1:string):Promise<void>;
export function DeleteProject(arg1:string):Promise<void>;
export function DeleteRelation(arg1:string):Promise<void>;
export function EvalAssertions(arg1:string):Promise<Array<fn_operations.AssertionResult>>;
export function GetCurrentProject():Promise<string>;
export function GetEntity(arg1:string):Promise<fn_operations.Entity>;
export function GetEntityNeighbors(arg1:string,arg2:number):Promise<main.GraphData>;
export function GetEntityPresets():Promise<Array<main.EntityTypePreset>>;
export function GetFilteredGraph(arg1:Array<string>):Promise<main.GraphData>;
export function GetGraphData():Promise<main.GraphData>;
export function GetRelationPresets():Promise<Array<string>>;
export function ListAssertions(arg1:string):Promise<Array<fn_operations.Assertion>>;
export function ListEntities():Promise<Array<fn_operations.Entity>>;
export function ListProjects():Promise<Array<main.ProjectInfo>>;
export function ListRelations():Promise<Array<fn_operations.Relation>>;
export function SearchEntities(arg1:string):Promise<Array<fn_operations.Entity>>;
export function SearchGraph(arg1:string):Promise<main.GraphData>;
export function SwitchProject(arg1:string):Promise<void>;
export function UpdateEntity(arg1:string,arg2:main.EntityInput):Promise<void>;
export function UpdateRelation(arg1:string,arg2:main.RelationInputDTO):Promise<void>;
+103
View File
@@ -0,0 +1,103 @@
// @ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function AddAssertion(arg1) {
return window['go']['main']['App']['AddAssertion'](arg1);
}
export function AddEntity(arg1) {
return window['go']['main']['App']['AddEntity'](arg1);
}
export function AddRelation(arg1) {
return window['go']['main']['App']['AddRelation'](arg1);
}
export function CreateProject(arg1) {
return window['go']['main']['App']['CreateProject'](arg1);
}
export function DeleteAssertion(arg1) {
return window['go']['main']['App']['DeleteAssertion'](arg1);
}
export function DeleteEntity(arg1) {
return window['go']['main']['App']['DeleteEntity'](arg1);
}
export function DeleteProject(arg1) {
return window['go']['main']['App']['DeleteProject'](arg1);
}
export function DeleteRelation(arg1) {
return window['go']['main']['App']['DeleteRelation'](arg1);
}
export function EvalAssertions(arg1) {
return window['go']['main']['App']['EvalAssertions'](arg1);
}
export function GetCurrentProject() {
return window['go']['main']['App']['GetCurrentProject']();
}
export function GetEntity(arg1) {
return window['go']['main']['App']['GetEntity'](arg1);
}
export function GetEntityNeighbors(arg1, arg2) {
return window['go']['main']['App']['GetEntityNeighbors'](arg1, arg2);
}
export function GetEntityPresets() {
return window['go']['main']['App']['GetEntityPresets']();
}
export function GetFilteredGraph(arg1) {
return window['go']['main']['App']['GetFilteredGraph'](arg1);
}
export function GetGraphData() {
return window['go']['main']['App']['GetGraphData']();
}
export function GetRelationPresets() {
return window['go']['main']['App']['GetRelationPresets']();
}
export function ListAssertions(arg1) {
return window['go']['main']['App']['ListAssertions'](arg1);
}
export function ListEntities() {
return window['go']['main']['App']['ListEntities']();
}
export function ListProjects() {
return window['go']['main']['App']['ListProjects']();
}
export function ListRelations() {
return window['go']['main']['App']['ListRelations']();
}
export function SearchEntities(arg1) {
return window['go']['main']['App']['SearchEntities'](arg1);
}
export function SearchGraph(arg1) {
return window['go']['main']['App']['SearchGraph'](arg1);
}
export function SwitchProject(arg1) {
return window['go']['main']['App']['SwitchProject'](arg1);
}
export function UpdateEntity(arg1, arg2) {
return window['go']['main']['App']['UpdateEntity'](arg1, arg2);
}
export function UpdateRelation(arg1, arg2) {
return window['go']['main']['App']['UpdateRelation'](arg1, arg2);
}
+408
View File
@@ -0,0 +1,408 @@
export namespace fn_operations {
export class Assertion {
id: string;
entity_id: string;
name: string;
kind: string;
rule: string;
severity: string;
description: string;
active: boolean;
// Go type: time
created_at: any;
static createFrom(source: any = {}) {
return new Assertion(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.entity_id = source["entity_id"];
this.name = source["name"];
this.kind = source["kind"];
this.rule = source["rule"];
this.severity = source["severity"];
this.description = source["description"];
this.active = source["active"];
this.created_at = this.convertValues(source["created_at"], null);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class AssertionResult {
id: string;
assertion_id: string;
execution_id: string;
status: string;
value: Record<string, any>;
message: string;
// Go type: time
evaluated_at: any;
static createFrom(source: any = {}) {
return new AssertionResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.assertion_id = source["assertion_id"];
this.execution_id = source["execution_id"];
this.status = source["status"];
this.value = source["value"];
this.message = source["message"];
this.evaluated_at = this.convertValues(source["evaluated_at"], null);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class Entity {
id: string;
name: string;
type_ref: string;
status: string;
description: string;
domain: string;
tags: string[];
source: string;
metadata: Record<string, any>;
notes: string;
// Go type: time
created_at: any;
// Go type: time
updated_at: any;
static createFrom(source: any = {}) {
return new Entity(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.type_ref = source["type_ref"];
this.status = source["status"];
this.description = source["description"];
this.domain = source["domain"];
this.tags = source["tags"];
this.source = source["source"];
this.metadata = source["metadata"];
this.notes = source["notes"];
this.created_at = this.convertValues(source["created_at"], null);
this.updated_at = this.convertValues(source["updated_at"], null);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class Relation {
id: string;
name: string;
from_entity: string;
to_entity: string;
via: string;
description: string;
purity: string;
direction: string;
weight?: number;
status: string;
// Go type: time
started_at?: any;
// Go type: time
ended_at?: any;
order?: number;
tags: string[];
notes: string;
// Go type: time
created_at: any;
// Go type: time
updated_at: any;
static createFrom(source: any = {}) {
return new Relation(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.from_entity = source["from_entity"];
this.to_entity = source["to_entity"];
this.via = source["via"];
this.description = source["description"];
this.purity = source["purity"];
this.direction = source["direction"];
this.weight = source["weight"];
this.status = source["status"];
this.started_at = this.convertValues(source["started_at"], null);
this.ended_at = this.convertValues(source["ended_at"], null);
this.order = source["order"];
this.tags = source["tags"];
this.notes = source["notes"];
this.created_at = this.convertValues(source["created_at"], null);
this.updated_at = this.convertValues(source["updated_at"], null);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}
export namespace main {
export class AssertionInput {
entity_id: string;
name: string;
kind: string;
rule: string;
severity: string;
description: string;
static createFrom(source: any = {}) {
return new AssertionInput(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.entity_id = source["entity_id"];
this.name = source["name"];
this.kind = source["kind"];
this.rule = source["rule"];
this.severity = source["severity"];
this.description = source["description"];
}
}
export class EntityInput {
name: string;
type_ref: string;
description: string;
tags: string[];
metadata: Record<string, any>;
notes: string;
static createFrom(source: any = {}) {
return new EntityInput(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.type_ref = source["type_ref"];
this.description = source["description"];
this.tags = source["tags"];
this.metadata = source["metadata"];
this.notes = source["notes"];
}
}
export class EntityTypePreset {
type_ref: string;
label: string;
color: string;
metadata_fields: string[];
static createFrom(source: any = {}) {
return new EntityTypePreset(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.type_ref = source["type_ref"];
this.label = source["label"];
this.color = source["color"];
this.metadata_fields = source["metadata_fields"];
}
}
export class GraphEdge {
id: string;
source: string;
target: string;
label: string;
color: string;
size: number;
type: string;
static createFrom(source: any = {}) {
return new GraphEdge(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.source = source["source"];
this.target = source["target"];
this.label = source["label"];
this.color = source["color"];
this.size = source["size"];
this.type = source["type"];
}
}
export class GraphNode {
id: string;
label: string;
type: string;
color: string;
size: number;
x: number;
y: number;
extra?: Record<string, any>;
static createFrom(source: any = {}) {
return new GraphNode(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.label = source["label"];
this.type = source["type"];
this.color = source["color"];
this.size = source["size"];
this.x = source["x"];
this.y = source["y"];
this.extra = source["extra"];
}
}
export class GraphData {
nodes: GraphNode[];
edges: GraphEdge[];
static createFrom(source: any = {}) {
return new GraphData(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.nodes = this.convertValues(source["nodes"], GraphNode);
this.edges = this.convertValues(source["edges"], GraphEdge);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class ProjectInfo {
name: string;
description: string;
entity_count: number;
relation_count: number;
static createFrom(source: any = {}) {
return new ProjectInfo(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.description = source["description"];
this.entity_count = source["entity_count"];
this.relation_count = source["relation_count"];
}
}
export class RelationInputDTO {
name: string;
from_entity: string;
to_entity: string;
description: string;
weight?: number;
tags: string[];
notes: string;
static createFrom(source: any = {}) {
return new RelationInputDTO(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.from_entity = source["from_entity"];
this.to_entity = source["to_entity"];
this.description = source["description"];
this.weight = source["weight"];
this.tags = source["tags"];
this.notes = source["notes"];
}
}
}
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@wailsapp/runtime",
"version": "2.0.0",
"description": "Wails Javascript runtime library",
"main": "runtime.js",
"types": "runtime.d.ts",
"scripts": {
},
"repository": {
"type": "git",
"url": "git+https://github.com/wailsapp/wails.git"
},
"keywords": [
"Wails",
"Javascript",
"Go"
],
"author": "Lea Anthony <lea.anthony@gmail.com>",
"license": "MIT",
"bugs": {
"url": "https://github.com/wailsapp/wails/issues"
},
"homepage": "https://github.com/wailsapp/wails#readme"
}
+249
View File
@@ -0,0 +1,249 @@
/*
_ __ _ __
| | / /___ _(_) /____
| | /| / / __ `/ / / ___/
| |/ |/ / /_/ / / (__ )
|__/|__/\__,_/_/_/____/
The electron alternative for Go
(c) Lea Anthony 2019-present
*/
export interface Position {
x: number;
y: number;
}
export interface Size {
w: number;
h: number;
}
export interface Screen {
isCurrent: boolean;
isPrimary: boolean;
width : number
height : number
}
// Environment information such as platform, buildtype, ...
export interface EnvironmentInfo {
buildType: string;
platform: string;
arch: string;
}
// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit)
// emits the given event. Optional data may be passed with the event.
// This will trigger any event listeners.
export function EventsEmit(eventName: string, ...data: any): void;
// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name.
export function EventsOn(eventName: string, callback: (...data: any) => void): () => void;
// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple)
// sets up a listener for the given event name, but will only trigger a given number times.
export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void;
// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce)
// sets up a listener for the given event name, but will only trigger once.
export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void;
// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff)
// unregisters the listener for the given event name.
export function EventsOff(eventName: string, ...additionalEventNames: string[]): void;
// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall)
// unregisters all listeners.
export function EventsOffAll(): void;
// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint)
// logs the given message as a raw message
export function LogPrint(message: string): void;
// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace)
// logs the given message at the `trace` log level.
export function LogTrace(message: string): void;
// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug)
// logs the given message at the `debug` log level.
export function LogDebug(message: string): void;
// [LogError](https://wails.io/docs/reference/runtime/log#logerror)
// logs the given message at the `error` log level.
export function LogError(message: string): void;
// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal)
// logs the given message at the `fatal` log level.
// The application will quit after calling this method.
export function LogFatal(message: string): void;
// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo)
// logs the given message at the `info` log level.
export function LogInfo(message: string): void;
// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning)
// logs the given message at the `warning` log level.
export function LogWarning(message: string): void;
// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload)
// Forces a reload by the main application as well as connected browsers.
export function WindowReload(): void;
// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp)
// Reloads the application frontend.
export function WindowReloadApp(): void;
// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop)
// Sets the window AlwaysOnTop or not on top.
export function WindowSetAlwaysOnTop(b: boolean): void;
// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme)
// *Windows only*
// Sets window theme to system default (dark/light).
export function WindowSetSystemDefaultTheme(): void;
// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme)
// *Windows only*
// Sets window to light theme.
export function WindowSetLightTheme(): void;
// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme)
// *Windows only*
// Sets window to dark theme.
export function WindowSetDarkTheme(): void;
// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter)
// Centers the window on the monitor the window is currently on.
export function WindowCenter(): void;
// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle)
// Sets the text in the window title bar.
export function WindowSetTitle(title: string): void;
// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen)
// Makes the window full screen.
export function WindowFullscreen(): void;
// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen)
// Restores the previous window dimensions and position prior to full screen.
export function WindowUnfullscreen(): void;
// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen)
// Returns the state of the window, i.e. whether the window is in full screen mode or not.
export function WindowIsFullscreen(): Promise<boolean>;
// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
// Sets the width and height of the window.
export function WindowSetSize(width: number, height: number): void;
// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize)
// Gets the width and height of the window.
export function WindowGetSize(): Promise<Size>;
// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize)
// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions.
// Setting a size of 0,0 will disable this constraint.
export function WindowSetMaxSize(width: number, height: number): void;
// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize)
// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions.
// Setting a size of 0,0 will disable this constraint.
export function WindowSetMinSize(width: number, height: number): void;
// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition)
// Sets the window position relative to the monitor the window is currently on.
export function WindowSetPosition(x: number, y: number): void;
// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition)
// Gets the window position relative to the monitor the window is currently on.
export function WindowGetPosition(): Promise<Position>;
// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide)
// Hides the window.
export function WindowHide(): void;
// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow)
// Shows the window, if it is currently hidden.
export function WindowShow(): void;
// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise)
// Maximises the window to fill the screen.
export function WindowMaximise(): void;
// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise)
// Toggles between Maximised and UnMaximised.
export function WindowToggleMaximise(): void;
// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise)
// Restores the window to the dimensions and position prior to maximising.
export function WindowUnmaximise(): void;
// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised)
// Returns the state of the window, i.e. whether the window is maximised or not.
export function WindowIsMaximised(): Promise<boolean>;
// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise)
// Minimises the window.
export function WindowMinimise(): void;
// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise)
// Restores the window to the dimensions and position prior to minimising.
export function WindowUnminimise(): void;
// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised)
// Returns the state of the window, i.e. whether the window is minimised or not.
export function WindowIsMinimised(): Promise<boolean>;
// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal)
// Returns the state of the window, i.e. whether the window is normal or not.
export function WindowIsNormal(): Promise<boolean>;
// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour)
// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels.
export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void;
// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall)
// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system.
export function ScreenGetAll(): Promise<Screen[]>;
// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl)
// Opens the given URL in the system browser.
export function BrowserOpenURL(url: string): void;
// [Environment](https://wails.io/docs/reference/runtime/intro#environment)
// Returns information about the environment
export function Environment(): Promise<EnvironmentInfo>;
// [Quit](https://wails.io/docs/reference/runtime/intro#quit)
// Quits the application.
export function Quit(): void;
// [Hide](https://wails.io/docs/reference/runtime/intro#hide)
// Hides the application.
export function Hide(): void;
// [Show](https://wails.io/docs/reference/runtime/intro#show)
// Shows the application.
export function Show(): void;
// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext)
// Returns the current text stored on clipboard
export function ClipboardGetText(): Promise<string>;
// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext)
// Sets a text on the clipboard
export function ClipboardSetText(text: string): Promise<boolean>;
// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop)
// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void
// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff)
// OnFileDropOff removes the drag and drop listeners and handlers.
export function OnFileDropOff() :void
// Check if the file path resolver is available
export function CanResolveFilePaths(): boolean;
// Resolves file paths for an array of files
export function ResolveFilePaths(files: File[]): void
+242
View File
@@ -0,0 +1,242 @@
/*
_ __ _ __
| | / /___ _(_) /____
| | /| / / __ `/ / / ___/
| |/ |/ / /_/ / / (__ )
|__/|__/\__,_/_/_/____/
The electron alternative for Go
(c) Lea Anthony 2019-present
*/
export function LogPrint(message) {
window.runtime.LogPrint(message);
}
export function LogTrace(message) {
window.runtime.LogTrace(message);
}
export function LogDebug(message) {
window.runtime.LogDebug(message);
}
export function LogInfo(message) {
window.runtime.LogInfo(message);
}
export function LogWarning(message) {
window.runtime.LogWarning(message);
}
export function LogError(message) {
window.runtime.LogError(message);
}
export function LogFatal(message) {
window.runtime.LogFatal(message);
}
export function EventsOnMultiple(eventName, callback, maxCallbacks) {
return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks);
}
export function EventsOn(eventName, callback) {
return EventsOnMultiple(eventName, callback, -1);
}
export function EventsOff(eventName, ...additionalEventNames) {
return window.runtime.EventsOff(eventName, ...additionalEventNames);
}
export function EventsOffAll() {
return window.runtime.EventsOffAll();
}
export function EventsOnce(eventName, callback) {
return EventsOnMultiple(eventName, callback, 1);
}
export function EventsEmit(eventName) {
let args = [eventName].slice.call(arguments);
return window.runtime.EventsEmit.apply(null, args);
}
export function WindowReload() {
window.runtime.WindowReload();
}
export function WindowReloadApp() {
window.runtime.WindowReloadApp();
}
export function WindowSetAlwaysOnTop(b) {
window.runtime.WindowSetAlwaysOnTop(b);
}
export function WindowSetSystemDefaultTheme() {
window.runtime.WindowSetSystemDefaultTheme();
}
export function WindowSetLightTheme() {
window.runtime.WindowSetLightTheme();
}
export function WindowSetDarkTheme() {
window.runtime.WindowSetDarkTheme();
}
export function WindowCenter() {
window.runtime.WindowCenter();
}
export function WindowSetTitle(title) {
window.runtime.WindowSetTitle(title);
}
export function WindowFullscreen() {
window.runtime.WindowFullscreen();
}
export function WindowUnfullscreen() {
window.runtime.WindowUnfullscreen();
}
export function WindowIsFullscreen() {
return window.runtime.WindowIsFullscreen();
}
export function WindowGetSize() {
return window.runtime.WindowGetSize();
}
export function WindowSetSize(width, height) {
window.runtime.WindowSetSize(width, height);
}
export function WindowSetMaxSize(width, height) {
window.runtime.WindowSetMaxSize(width, height);
}
export function WindowSetMinSize(width, height) {
window.runtime.WindowSetMinSize(width, height);
}
export function WindowSetPosition(x, y) {
window.runtime.WindowSetPosition(x, y);
}
export function WindowGetPosition() {
return window.runtime.WindowGetPosition();
}
export function WindowHide() {
window.runtime.WindowHide();
}
export function WindowShow() {
window.runtime.WindowShow();
}
export function WindowMaximise() {
window.runtime.WindowMaximise();
}
export function WindowToggleMaximise() {
window.runtime.WindowToggleMaximise();
}
export function WindowUnmaximise() {
window.runtime.WindowUnmaximise();
}
export function WindowIsMaximised() {
return window.runtime.WindowIsMaximised();
}
export function WindowMinimise() {
window.runtime.WindowMinimise();
}
export function WindowUnminimise() {
window.runtime.WindowUnminimise();
}
export function WindowSetBackgroundColour(R, G, B, A) {
window.runtime.WindowSetBackgroundColour(R, G, B, A);
}
export function ScreenGetAll() {
return window.runtime.ScreenGetAll();
}
export function WindowIsMinimised() {
return window.runtime.WindowIsMinimised();
}
export function WindowIsNormal() {
return window.runtime.WindowIsNormal();
}
export function BrowserOpenURL(url) {
window.runtime.BrowserOpenURL(url);
}
export function Environment() {
return window.runtime.Environment();
}
export function Quit() {
window.runtime.Quit();
}
export function Hide() {
window.runtime.Hide();
}
export function Show() {
window.runtime.Show();
}
export function ClipboardGetText() {
return window.runtime.ClipboardGetText();
}
export function ClipboardSetText(text) {
return window.runtime.ClipboardSetText(text);
}
/**
* Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
*
* @export
* @callback OnFileDropCallback
* @param {number} x - x coordinate of the drop
* @param {number} y - y coordinate of the drop
* @param {string[]} paths - A list of file paths.
*/
/**
* OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
*
* @export
* @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
* @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target)
*/
export function OnFileDrop(callback, useDropTarget) {
return window.runtime.OnFileDrop(callback, useDropTarget);
}
/**
* OnFileDropOff removes the drag and drop listeners and handlers.
*/
export function OnFileDropOff() {
return window.runtime.OnFileDropOff();
}
export function CanResolveFilePaths() {
return window.runtime.CanResolveFilePaths();
}
export function ResolveFilePaths(files) {
return window.runtime.ResolveFilePaths(files);
}
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@wails/*": ["./src/wailsjs/*"],
"@fn_library": ["./src/declarations.d.ts"],
"@graph": ["./src/declarations.d.ts"]
}
},
"include": ["src"]
}
+1
View File
@@ -0,0 +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"}
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { resolve } from 'path'
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': resolve(__dirname, './src'),
'@wails': resolve(__dirname, './src/wailsjs'),
'@fn_library': resolve(__dirname, '../../../frontend/functions/ui'),
'@graph': resolve(__dirname, '../../../frontend/functions/ui/graph'),
},
dedupe: ['react', 'react-dom'],
},
server: {
port: 5174,
},
})
+46
View File
@@ -0,0 +1,46 @@
module fuzzygraph
go 1.25.0
require (
fn-registry v0.0.0
github.com/wailsapp/wails/v2 v2.11.0
)
require (
github.com/bep/debounce v1.2.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/gosod v1.0.4 // indirect
github.com/leaanthony/slicer v1.6.0 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.37 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/samber/lo v1.49.1 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace fn-registry => /home/lucas/fn_registry
+98
View File
@@ -0,0 +1,98 @@
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+183
View File
@@ -0,0 +1,183 @@
package main
import (
"fmt"
"math"
"math/rand/v2"
ops "fn-registry/fn_operations"
)
// buildGraphData converts entities and relations from operations.db into sigma.js-compatible GraphData.
func buildGraphData(db *ops.DB) (GraphData, error) {
entities, err := db.ListEntities("", "")
if err != nil {
return GraphData{}, fmt.Errorf("listing entities: %w", err)
}
relations, err := db.ListRelations("")
if err != nil {
return GraphData{}, fmt.Errorf("listing relations: %w", err)
}
// Compute degree per node
degree := map[string]int{}
for _, r := range relations {
degree[r.FromEntity]++
degree[r.ToEntity]++
}
// Build nodes
nodes := make([]GraphNode, 0, len(entities))
for _, e := range entities {
color, ok := entityTypeColors[e.TypeRef]
if !ok {
color = "#95a5a6" // default grey
}
// Size: base + degree factor + risk_score factor
size := 8.0
size += math.Min(float64(degree[e.ID])*2.0, 20.0)
if rs, ok := e.Metadata["risk_score"]; ok {
if v, ok := toFloat64(rs); ok {
size += v * 0.1 // risk_score contributes up to ~10
}
}
nodes = append(nodes, GraphNode{
ID: e.ID,
Label: e.Name,
Type: e.TypeRef,
Color: color,
Size: size,
X: rand.Float64()*100 - 50,
Y: rand.Float64()*100 - 50,
Extra: e.Metadata,
})
}
// Build edges
edges := make([]GraphEdge, 0, len(relations))
for _, r := range relations {
w := 1.0
if r.Weight != nil {
w = *r.Weight
}
edges = append(edges, GraphEdge{
ID: r.ID,
Source: r.FromEntity,
Target: r.ToEntity,
Label: r.Name,
Color: "#ffffff30",
Size: math.Max(w*3, 0.5),
Type: "arrow",
})
}
return GraphData{Nodes: nodes, Edges: edges}, nil
}
// buildEgoGraph returns a subgraph centered on entityID up to depth hops.
func buildEgoGraph(db *ops.DB, entityID string, depth int) (GraphData, error) {
if depth < 1 {
depth = 1
}
if depth > 5 {
depth = 5
}
relations, err := db.ListRelations("")
if err != nil {
return GraphData{}, err
}
// BFS to collect entity IDs within depth
visited := map[string]bool{entityID: true}
frontier := []string{entityID}
for d := 0; d < depth; d++ {
var next []string
for _, id := range frontier {
for _, r := range relations {
if r.FromEntity == id && !visited[r.ToEntity] {
visited[r.ToEntity] = true
next = append(next, r.ToEntity)
}
if r.ToEntity == id && !visited[r.FromEntity] {
visited[r.FromEntity] = true
next = append(next, r.FromEntity)
}
}
}
frontier = next
}
// Filter entities and relations to subgraph
entities, err := db.ListEntities("", "")
if err != nil {
return GraphData{}, err
}
degree := map[string]int{}
var subRels []ops.Relation
for _, r := range relations {
if visited[r.FromEntity] && visited[r.ToEntity] {
subRels = append(subRels, r)
degree[r.FromEntity]++
degree[r.ToEntity]++
}
}
nodes := make([]GraphNode, 0)
for _, e := range entities {
if !visited[e.ID] {
continue
}
color, ok := entityTypeColors[e.TypeRef]
if !ok {
color = "#95a5a6"
}
size := 8.0 + math.Min(float64(degree[e.ID])*2.0, 20.0)
if rs, ok := e.Metadata["risk_score"]; ok {
if v, ok := toFloat64(rs); ok {
size += v * 0.1
}
}
nodes = append(nodes, GraphNode{
ID: e.ID, Label: e.Name, Type: e.TypeRef,
Color: color, Size: size,
X: rand.Float64()*100 - 50, Y: rand.Float64()*100 - 50,
Extra: e.Metadata,
})
}
edges := make([]GraphEdge, 0)
for _, r := range subRels {
w := 1.0
if r.Weight != nil {
w = *r.Weight
}
edges = append(edges, GraphEdge{
ID: r.ID, Source: r.FromEntity, Target: r.ToEntity,
Label: r.Name, Color: "#ffffff30",
Size: math.Max(w*3, 0.5), Type: "arrow",
})
}
return GraphData{Nodes: nodes, Edges: edges}, nil
}
func toFloat64(v any) (float64, bool) {
switch n := v.(type) {
case float64:
return n, true
case float32:
return float64(n), true
case int:
return float64(n), true
case int64:
return float64(n), true
default:
return 0, false
}
}
+83
View File
@@ -0,0 +1,83 @@
package main
import (
"embed"
"fmt"
"log"
"os"
"path/filepath"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
)
//go:embed all:frontend/dist
var assets embed.FS
func main() {
// File logger
logFile, err := os.OpenFile("fuzzygraph.log", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err == nil {
log.SetOutput(logFile)
defer logFile.Close()
}
log.SetFlags(log.Ltime | log.Lmicroseconds | log.Lshortfile)
log.Println("=== fuzzygraph starting ===")
log.Printf("cwd: %s", func() string { d, _ := os.Getwd(); return d }())
log.Printf("os.Args: %v", os.Args)
// Resolve projects directory relative to cwd
projectsDir := "projects"
if cwd, err := os.Getwd(); err == nil {
absProjects := filepath.Join(cwd, "projects")
if _, err := os.Stat(absProjects); err == nil {
projectsDir = absProjects
log.Printf("projectsDir (exists): %s", projectsDir)
} else {
// Create it
os.MkdirAll(absProjects, 0o755)
projectsDir = absProjects
log.Printf("projectsDir (created): %s", projectsDir)
}
}
// Resolve registry root
registryRoot := os.Getenv("FN_REGISTRY_ROOT")
if registryRoot == "" {
if cwd, err := os.Getwd(); err == nil {
registryRoot = filepath.Join(cwd, "..", "..")
}
}
log.Printf("registryRoot: %s", registryRoot)
log.Printf("registry.db exists: %v", func() bool {
_, err := os.Stat(filepath.Join(registryRoot, "registry.db"))
return err == nil
}())
app := NewApp(projectsDir, registryRoot)
log.Println("App created, starting Wails...")
runErr := wails.Run(&options.App{
Title: "FuzzyGraph — OSINT Intelligence",
Width: 1400,
Height: 900,
AssetServer: &assetserver.Options{
Assets: assets,
},
BackgroundColour: &options.RGBA{R: 10, G: 10, B: 15, A: 1},
OnStartup: app.startup,
OnShutdown: app.shutdown,
Bind: []interface{}{
app,
},
})
if runErr != nil {
log.Printf("ERROR wails.Run: %v", runErr)
fmt.Fprintf(os.Stderr, "error: %v\n", runErr)
os.Exit(1)
}
log.Println("=== fuzzygraph exited cleanly ===")
}
+101
View File
@@ -0,0 +1,101 @@
package main
import (
"fmt"
"log"
"os"
"path/filepath"
ops "fn-registry/fn_operations"
)
// listProjectDirs returns subdirectories under projectsDir.
func listProjectDirs(projectsDir string) ([]ProjectInfo, error) {
log.Printf("[listProjectDirs] scanning %s", projectsDir)
entries, err := os.ReadDir(projectsDir)
if err != nil {
if os.IsNotExist(err) {
log.Printf("[listProjectDirs] dir does not exist, returning nil")
return nil, nil
}
log.Printf("[listProjectDirs] ERROR reading dir: %v", err)
return nil, err
}
log.Printf("[listProjectDirs] found %d entries", len(entries))
var projects []ProjectInfo
for _, e := range entries {
log.Printf("[listProjectDirs] entry: %s isDir=%v", e.Name(), e.IsDir())
if !e.IsDir() {
continue
}
dbPath := filepath.Join(projectsDir, e.Name(), "operations.db")
if _, err := os.Stat(dbPath); err != nil {
log.Printf("[listProjectDirs] %s: no operations.db, skipping", e.Name())
continue
}
info := ProjectInfo{Name: e.Name()}
// Quick counts from the DB
db, err := ops.Open(dbPath)
if err == nil {
entities, _ := db.ListEntities("", "")
relations, _ := db.ListRelations("")
info.EntityCount = len(entities)
info.RelCount = len(relations)
db.Close()
log.Printf("[listProjectDirs] %s: OK (entities=%d relations=%d)", e.Name(), info.EntityCount, info.RelCount)
} else {
log.Printf("[listProjectDirs] %s: ERROR opening db: %v", e.Name(), err)
}
projects = append(projects, info)
}
log.Printf("[listProjectDirs] returning %d projects", len(projects))
return projects, nil
}
// createProject creates a new project directory with an operations.db.
func createProject(projectsDir, name string) error {
dir := filepath.Join(projectsDir, name)
log.Printf("[createProject] creating dir: %s", dir)
if err := os.MkdirAll(dir, 0o755); err != nil {
log.Printf("[createProject] ERROR mkdir: %v", err)
return fmt.Errorf("creating project directory: %w", err)
}
dbPath := filepath.Join(dir, "operations.db")
log.Printf("[createProject] creating db: %s", dbPath)
db, err := ops.Open(dbPath)
if err != nil {
log.Printf("[createProject] ERROR opening db: %v", err)
return fmt.Errorf("creating operations.db: %w", err)
}
if err := db.Close(); err != nil {
log.Printf("[createProject] ERROR closing db: %v", err)
return err
}
// Verify
if fi, err := os.Stat(dbPath); err != nil {
log.Printf("[createProject] WARNING: db not found after creation: %v", err)
} else {
log.Printf("[createProject] OK: db created, size=%d bytes", fi.Size())
}
return nil
}
// deleteProject removes a project directory and its operations.db.
func deleteProject(projectsDir, name string) error {
dir := filepath.Join(projectsDir, name)
log.Printf("[deleteProject] removing: %s", dir)
return os.RemoveAll(dir)
}
+106
View File
@@ -0,0 +1,106 @@
package main
import (
"fmt"
"math"
"math/rand/v2"
ops "fn-registry/fn_operations"
)
// searchEntitiesFTS searches entities using FTS and returns matching entities.
func searchEntitiesFTS(db *ops.DB, query string) ([]ops.Entity, error) {
if query == "" {
return db.ListEntities("", "")
}
return db.SearchEntities(query, "")
}
// searchGraph returns a GraphData subgraph containing matching entities and their direct relations.
func searchGraph(db *ops.DB, query string) (GraphData, error) {
matches, err := searchEntitiesFTS(db, query)
if err != nil {
return GraphData{}, fmt.Errorf("searching entities: %w", err)
}
if len(matches) == 0 {
return GraphData{}, nil
}
matchIDs := map[string]bool{}
for _, e := range matches {
matchIDs[e.ID] = true
}
// Get all relations and filter to those touching matched entities
allRelations, err := db.ListRelations("")
if err != nil {
return GraphData{}, err
}
// Collect neighbor IDs
neighborIDs := map[string]bool{}
var subRels []ops.Relation
for _, r := range allRelations {
if matchIDs[r.FromEntity] || matchIDs[r.ToEntity] {
subRels = append(subRels, r)
neighborIDs[r.FromEntity] = true
neighborIDs[r.ToEntity] = true
}
}
// Merge match IDs and neighbor IDs
for id := range matchIDs {
neighborIDs[id] = true
}
// Get all entities in the subgraph
allEntities, err := db.ListEntities("", "")
if err != nil {
return GraphData{}, err
}
degree := map[string]int{}
for _, r := range subRels {
degree[r.FromEntity]++
degree[r.ToEntity]++
}
nodes := make([]GraphNode, 0)
for _, e := range allEntities {
if !neighborIDs[e.ID] {
continue
}
color, ok := entityTypeColors[e.TypeRef]
if !ok {
color = "#95a5a6"
}
size := 8.0 + math.Min(float64(degree[e.ID])*2.0, 20.0)
if rs, ok := e.Metadata["risk_score"]; ok {
if v, ok := toFloat64(rs); ok {
size += v * 0.1
}
}
nodes = append(nodes, GraphNode{
ID: e.ID, Label: e.Name, Type: e.TypeRef,
Color: color, Size: size,
X: rand.Float64()*100 - 50, Y: rand.Float64()*100 - 50,
Extra: e.Metadata,
})
}
edges := make([]GraphEdge, 0)
for _, r := range subRels {
w := 1.0
if r.Weight != nil {
w = *r.Weight
}
edges = append(edges, GraphEdge{
ID: r.ID, Source: r.FromEntity, Target: r.ToEntity,
Label: r.Name, Color: "#ffffff30",
Size: math.Max(w*3, 0.5), Type: "arrow",
})
}
return GraphData{Nodes: nodes, Edges: edges}, nil
}
+117
View File
@@ -0,0 +1,117 @@
package main
// EntityTypePreset defines an OSINT entity type with its visual properties and suggested metadata fields.
type EntityTypePreset struct {
TypeRef string `json:"type_ref"` // registry type ID
Label string `json:"label"` // human-readable
Color string `json:"color"` // hex color for graph nodes
MetadataFields []string `json:"metadata_fields"` // suggested metadata keys
}
// GraphData matches the frontend-lib GraphData interface for sigma.js rendering.
type GraphData struct {
Nodes []GraphNode `json:"nodes"`
Edges []GraphEdge `json:"edges"`
}
// GraphNode matches the frontend-lib GraphNode interface.
type GraphNode struct {
ID string `json:"id"`
Label string `json:"label"`
Type string `json:"type"`
Color string `json:"color"`
Size float64 `json:"size"`
X float64 `json:"x"`
Y float64 `json:"y"`
// Extra fields from entity metadata (flattened)
Extra map[string]any `json:"extra,omitempty"`
}
// GraphEdge matches the frontend-lib GraphEdge interface.
type GraphEdge struct {
ID string `json:"id"`
Source string `json:"source"`
Target string `json:"target"`
Label string `json:"label"`
Color string `json:"color"`
Size float64 `json:"size"`
Type string `json:"type"` // "arrow"
}
// ProjectInfo describes a project directory.
type ProjectInfo struct {
Name string `json:"name"`
Description string `json:"description"`
EntityCount int `json:"entity_count"`
RelCount int `json:"relation_count"`
}
// EntityInput is the DTO for creating/updating entities from the frontend.
type EntityInput struct {
Name string `json:"name"`
TypeRef string `json:"type_ref"`
Description string `json:"description"`
Tags []string `json:"tags"`
Metadata map[string]any `json:"metadata"`
Notes string `json:"notes"`
}
// RelationInput is the DTO for creating/updating relations from the frontend.
type RelationInputDTO struct {
Name string `json:"name"`
FromEntity string `json:"from_entity"`
ToEntity string `json:"to_entity"`
Description string `json:"description"`
Weight *float64 `json:"weight"`
Tags []string `json:"tags"`
Notes string `json:"notes"`
}
// AssertionInput is the DTO for creating assertions from the frontend.
type AssertionInput struct {
EntityID string `json:"entity_id"`
Name string `json:"name"`
Kind string `json:"kind"`
Rule string `json:"rule"`
Severity string `json:"severity"`
Description string `json:"description"`
}
// Color map for OSINT entity types
var entityTypeColors = map[string]string{
"osint_person_go_cybersecurity": "#e74c3c",
"osint_organization_go_cybersecurity": "#3498db",
"osint_crypto_wallet_go_cybersecurity": "#f39c12",
"osint_ip_address_go_cybersecurity": "#2ecc71",
"osint_domain_go_cybersecurity": "#9b59b6",
"osint_email_go_cybersecurity": "#1abc9c",
"osint_phone_go_cybersecurity": "#e67e22",
"osint_malware_go_cybersecurity": "#c0392b",
"osint_vulnerability_go_cybersecurity": "#8e44ad",
"osint_social_media_go_cybersecurity": "#2980b9",
"osint_document_go_cybersecurity": "#7f8c8d",
"osint_event_go_cybersecurity": "#d35400",
"osint_location_go_cybersecurity": "#27ae60",
}
var entityTypePresets = []EntityTypePreset{
{"osint_person_go_cybersecurity", "Person", "#e74c3c", []string{"full_name", "alias", "nationality", "dob", "gender", "risk_score"}},
{"osint_organization_go_cybersecurity", "Organization", "#3498db", []string{"legal_name", "country", "sector", "founded", "risk_score"}},
{"osint_crypto_wallet_go_cybersecurity", "Crypto Wallet", "#f39c12", []string{"address", "blockchain", "balance", "first_seen", "last_seen"}},
{"osint_ip_address_go_cybersecurity", "IP Address", "#2ecc71", []string{"ip", "asn", "country", "isp", "geolocation", "last_seen"}},
{"osint_domain_go_cybersecurity", "Domain", "#9b59b6", []string{"fqdn", "registrar", "created_date", "expires_date", "name_servers"}},
{"osint_email_go_cybersecurity", "Email", "#1abc9c", []string{"address", "provider", "verified", "breached"}},
{"osint_phone_go_cybersecurity", "Phone", "#e67e22", []string{"number", "country_code", "carrier", "phone_type"}},
{"osint_malware_go_cybersecurity", "Malware", "#c0392b", []string{"family", "hash_sha256", "first_seen", "last_seen", "threat_level"}},
{"osint_vulnerability_go_cybersecurity", "Vulnerability", "#8e44ad", []string{"cve_id", "cvss", "affected_product", "published", "exploited"}},
{"osint_social_media_go_cybersecurity", "Social Media", "#2980b9", []string{"platform", "username", "url", "followers", "verified"}},
{"osint_document_go_cybersecurity", "Document", "#7f8c8d", []string{"title", "format", "classification", "hash_sha256", "source"}},
{"osint_event_go_cybersecurity", "Event", "#d35400", []string{"event_type", "date", "location", "description", "severity"}},
{"osint_location_go_cybersecurity", "Location", "#27ae60", []string{"lat", "lon", "address", "country", "city"}},
}
var relationPresets = []string{
"funds", "employs", "communicates_with", "owns", "operates",
"controls", "affiliated_with", "located_at", "resolves_to",
"registered_by", "hosts", "exploits", "attributed_to", "related_to",
}
+14
View File
@@ -0,0 +1,14 @@
{
"$schema": "https://wails.io/schemas/config.v2.json",
"name": "fuzzygraph",
"outputfilename": "fuzzygraph",
"frontend:dir": "./frontend",
"frontend:install": "pnpm install",
"frontend:build": "pnpm run build",
"frontend:dev:watcher": "pnpm run dev",
"frontend:dev:serverUrl": "auto",
"wailsjsdir": "./frontend/src",
"author": {
"name": "Egutierrez"
}
}