feat: enrichers, panel de ingest y menu contextual en el grafo

- Añade enricher.go + directorio enrichers/ para enriquecer entidades con fuentes externas.
- Nuevos componentes frontend: IngestPanel (panel de ingesta de datos) y NodeContextMenu (menu contextual sobre nodos del grafo).
- Retira SearchBar y lib/utils.ts; la busqueda se integra dentro de los paneles existentes.
- Ajusta tipos (types.go, types.ts, wailsjs/go) y theming (postcss + app.css + Mantine).
- Actualiza app.go y wails.json para exponer las nuevas capacidades.
- Añade directorio projects/ con estado inicial.
- Rebuild del frontend (dist actualizado).
This commit is contained in:
2026-04-13 23:32:55 +02:00
parent 23198eee0c
commit c9fd4aa84c
42 changed files with 2615 additions and 1543 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FuzzyGraph</title>
<script type="module" crossorigin src="/assets/index-CYqMr7xa.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cjyz0t73.css">
<script type="module" crossorigin src="/assets/index-4p79H44C.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-vp4DQNbX.css">
</head>
<body>
<div id="root"></div>
+8 -11
View File
@@ -9,24 +9,21 @@
"preview": "vite preview --host"
},
"dependencies": {
"@base-ui/react": "^1.3.0",
"@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-slider": "^1.3.6",
"@mantine/core": "^9.0.0",
"@mantine/hooks": "^9.0.0",
"@mantine/notifications": "^9.0.0",
"@tabler/icons-react": "^3.41.1",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.577.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0"
"react-dom": "^19.2.4"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.0",
"tailwindcss": "^4.2.2",
"postcss": "^8.5.8",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"typescript": "~5.9.3",
"vite": "^8.0.0"
}
+1 -1
View File
@@ -1 +1 @@
925e378ac695fa8339fd9576604d1d9f
994f04234280ec7657894460ce41d791
+360 -604
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
module.exports = {
plugins: {
'postcss-preset-mantine': {},
'postcss-simple-vars': {
variables: {
'mantine-breakpoint-xs': '36em',
'mantine-breakpoint-sm': '48em',
'mantine-breakpoint-md': '62em',
'mantine-breakpoint-lg': '75em',
'mantine-breakpoint-xl': '88em',
},
},
},
};
+162 -91
View File
@@ -1,13 +1,16 @@
import { useEffect, useState, useCallback } from 'react'
import type { ProjectInfo, Entity, Relation, GraphData, EntityTypePreset } from './types'
import { AppShell, Group, Text, Center, Box } from '@mantine/core'
import { notifications } from '@mantine/notifications'
import type { ProjectInfo, Entity, Relation, GraphData, EntityTypePreset, EnricherDef } from './types'
import { ProjectSidebar } from './components/ProjectSidebar'
import { SearchBar } from '@fn_library'
import { SearchBar, Tabs, TabsList, TabsTrigger, TabsContent } from '@fn_library'
import { GraphView } from './components/GraphView'
import { EntityTable } from './components/EntityTable'
import { RelationTable } from './components/RelationTable'
import { EntityDetail } from './components/EntityDetail'
import { AssertionPanel } from './components/AssertionPanel'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@fn_library'
import { NodeContextMenu } from './components/NodeContextMenu'
import { IngestPanel } from './components/IngestPanel'
import * as WailsApp from './wailsjs/go/main/App'
export default function App() {
@@ -21,46 +24,46 @@ export default function App() {
const [activeTab, setActiveTab] = useState('graph')
const [selectedEntityId, setSelectedEntityId] = useState<string | null>(null)
// Enricher state
const [enrichers, setEnrichers] = useState<EnricherDef[]>([])
const [ctxMenu, setCtxMenu] = useState<{ position: { x: number; y: number }; nodeId: string; nodeType: string } | null>(null)
const [ctxEnrichers, setCtxEnrichers] = useState<EnricherDef[]>([])
const [runningEnricher, setRunningEnricher] = useState<string | null>(null)
useEffect(() => {
console.log('[App] mount — loading presets and projects')
refreshProjects()
WailsApp.GetEntityPresets()
.then(p => { console.log('[App] GetEntityPresets OK:', p?.length, 'presets'); setPresets(p as unknown as EntityTypePreset[]) })
.then(p => setPresets(p as unknown as EntityTypePreset[]))
.catch(e => console.error('[App] GetEntityPresets ERROR:', e))
WailsApp.GetRelationPresets()
.then(p => { console.log('[App] GetRelationPresets OK:', p?.length); setRelationPresets(p) })
.then(p => setRelationPresets(p))
.catch(e => console.error('[App] GetRelationPresets ERROR:', e))
WailsApp.GetEnrichers()
.then(e => setEnrichers((e || []) as unknown as EnricherDef[]))
.catch(e => console.error('[App] GetEnrichers ERROR:', e))
}, [])
const refreshProjects = useCallback(() => {
console.log('[App] refreshProjects called')
WailsApp.ListProjects()
.then(p => {
const list = (p || []) as unknown as ProjectInfo[]
console.log('[App] ListProjects OK:', list.length, 'projects', JSON.stringify(list))
setProjects(list)
})
.then(p => setProjects((p || []) as unknown as ProjectInfo[]))
.catch(e => console.error('[App] ListProjects ERROR:', e))
}, [])
const refreshData = useCallback(() => {
console.log('[App] refreshData called')
WailsApp.ListEntities()
.then(e => { const list = (e || []) as unknown as Entity[]; console.log('[App] ListEntities OK:', list.length); setEntities(list) })
.then(e => setEntities((e || []) as unknown as Entity[]))
.catch(e => console.error('[App] ListEntities ERROR:', e))
WailsApp.ListRelations()
.then(r => { const list = (r || []) as unknown as Relation[]; console.log('[App] ListRelations OK:', list.length); setRelations(list) })
.then(r => setRelations((r || []) as unknown as Relation[]))
.catch(e => console.error('[App] ListRelations ERROR:', e))
WailsApp.GetGraphData()
.then(g => { const data = (g || { nodes: [], edges: [] }) as unknown as GraphData; console.log('[App] GetGraphData OK: nodes=', data.nodes?.length, 'edges=', data.edges?.length); setGraphData(data) })
.then(g => setGraphData((g || { nodes: [], edges: [] }) as unknown as GraphData))
.catch(e => console.error('[App] GetGraphData ERROR:', e))
}, [])
const handleSwitchProject = useCallback(async (name: string) => {
console.log('[App] handleSwitchProject:', name)
try {
await WailsApp.SwitchProject(name)
console.log('[App] SwitchProject OK')
setCurrentProject(name)
refreshData()
} catch (e) {
@@ -69,24 +72,18 @@ export default function App() {
}, [refreshData])
const handleCreateProject = useCallback(async (name: string) => {
console.log('[App] handleCreateProject:', name)
try {
const result = await WailsApp.CreateProject(name)
console.log('[App] CreateProject OK:', JSON.stringify(result))
await WailsApp.CreateProject(name)
refreshProjects()
console.log('[App] switching to new project...')
await handleSwitchProject(name)
console.log('[App] switched OK')
} catch (e) {
console.error('[App] CreateProject ERROR:', e)
}
}, [refreshProjects, handleSwitchProject])
const handleDeleteProject = useCallback(async (name: string) => {
console.log('[App] handleDeleteProject:', name)
try {
await WailsApp.DeleteProject(name)
console.log('[App] DeleteProject OK')
if (currentProject === name) {
setCurrentProject('')
setEntities([])
@@ -100,7 +97,6 @@ export default function App() {
}, [currentProject, refreshProjects])
const handleSearch = useCallback(async (query: string) => {
console.log('[App] handleSearch:', query)
if (!query.trim()) {
refreshData()
return
@@ -110,7 +106,6 @@ export default function App() {
WailsApp.SearchEntities(query),
WailsApp.SearchGraph(query),
])
console.log('[App] Search OK: entities=', (ents as unknown[])?.length, 'graph nodes=', (graph as unknown as GraphData)?.nodes?.length)
setEntities((ents || []) as unknown as Entity[])
setGraphData((graph || { nodes: [], edges: [] }) as unknown as GraphData)
} catch (e) {
@@ -119,12 +114,10 @@ export default function App() {
}, [refreshData])
const handleNodeClick = useCallback((nodeId: string) => {
console.log('[App] handleNodeClick:', nodeId)
setSelectedEntityId(nodeId)
}, [])
const handleNodeDoubleClick = useCallback(async (nodeId: string) => {
console.log('[App] handleNodeDoubleClick:', nodeId)
try {
const ego = await WailsApp.GetEntityNeighbors(nodeId, 2)
setGraphData((ego || { nodes: [], edges: [] }) as unknown as GraphData)
@@ -133,83 +126,161 @@ export default function App() {
}
}, [])
// Context menu handler
const handleContextMenu = useCallback((event: MouseEvent, target: { type: string; id?: string; data?: Record<string, unknown> }) => {
if (target.type === 'node' && target.id && target.data) {
const nodeType = String(target.data?.entityType ?? target.data?.type ?? '')
const applicable = enrichers.filter(e => e.applies_to.includes(nodeType))
if (applicable.length > 0) {
setCtxMenu({
position: { x: event.clientX, y: event.clientY },
nodeId: target.id,
nodeType,
})
setCtxEnrichers(applicable)
}
} else {
setCtxMenu(null)
}
}, [enrichers])
// Run enricher
const handleRunEnricher = useCallback(async (enricherId: string) => {
if (!ctxMenu) return
const nodeId = ctxMenu.nodeId
setCtxMenu(null)
setRunningEnricher(enricherId)
try {
const result = await WailsApp.RunEnricher(enricherId, nodeId)
setGraphData((result || { nodes: [], edges: [] }) as unknown as GraphData)
refreshData()
notifications.show({ title: 'Enricher complete', message: `${enricherId} finished`, color: 'green' })
} catch (e: any) {
notifications.show({ title: 'Enricher failed', message: String(e), color: 'red' })
console.error('[App] RunEnricher ERROR:', e)
} finally {
setRunningEnricher(null)
}
}, [ctxMenu, refreshData])
// Ingest handlers
const handleIngestURL = useCallback(async (url: string) => {
try {
await WailsApp.IngestURL(url)
refreshData()
notifications.show({ title: 'URL ingested', message: url, color: 'blue' })
} catch (e: any) {
notifications.show({ title: 'Ingest failed', message: String(e), color: 'red' })
}
}, [refreshData])
const handleIngestFile = useCallback(async (path: string) => {
try {
await WailsApp.IngestFile(path)
refreshData()
notifications.show({ title: 'File ingested', message: path, color: 'blue' })
} catch (e: any) {
notifications.show({ title: 'Ingest failed', message: String(e), color: 'red' })
}
}, [refreshData])
const selectedEntity = entities.find(e => e.id === selectedEntityId) ?? null
console.log('[App] render: projects=', projects.length, 'currentProject=', currentProject, 'entities=', entities.length, 'relations=', relations.length)
return (
<div className="flex h-screen overflow-hidden">
<ProjectSidebar
projects={projects}
current={currentProject}
onSwitch={handleSwitchProject}
onCreate={handleCreateProject}
onDelete={handleDeleteProject}
/>
<AppShell
navbar={{ width: 224, breakpoint: 0 }}
padding={0}
>
<AppShell.Navbar>
<ProjectSidebar
projects={projects}
current={currentProject}
onSwitch={handleSwitchProject}
onCreate={handleCreateProject}
onDelete={handleDeleteProject}
/>
</AppShell.Navbar>
<main className="flex-1 flex flex-col overflow-hidden">
<AppShell.Main style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
{currentProject ? (
<>
<div className="px-4 pt-3 pb-2 flex items-center gap-3 border-b" style={{ borderColor: 'var(--border)' }}>
<h2 className="text-sm font-semibold" style={{ color: 'var(--foreground)' }}>{currentProject}</h2>
<SearchBar onSearch={handleSearch} />
</div>
<Group px="md" py="xs" gap="sm" style={{ borderBottom: '1px solid var(--mantine-color-dark-4)' }}>
<Text size="sm" fw={600}>{currentProject}</Text>
<Box flex={1}>
<SearchBar onSearch={handleSearch} />
</Box>
</Group>
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col overflow-hidden">
<TabsList className="mx-4 mt-2">
<TabsTrigger value="graph">Graph</TabsTrigger>
<TabsTrigger value="entities">Entities ({entities.length})</TabsTrigger>
<TabsTrigger value="relations">Relations ({relations.length})</TabsTrigger>
<TabsTrigger value="assertions">Assertions</TabsTrigger>
</TabsList>
<Box flex={1} style={{ display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<Tabs value={activeTab} onTabChange={(v) => v && setActiveTab(v)}>
<TabsList style={{ margin: '8px 16px 0' }}>
<TabsTrigger value="graph">Graph</TabsTrigger>
<TabsTrigger value="entities">Entities ({entities.length})</TabsTrigger>
<TabsTrigger value="relations">Relations ({relations.length})</TabsTrigger>
<TabsTrigger value="assertions">Assertions</TabsTrigger>
</TabsList>
<TabsContent value="graph" className="flex-1 flex overflow-hidden m-0 p-0">
<div className="flex-1 relative">
<GraphView
data={graphData}
<TabsContent value="graph" style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<IngestPanel onIngestURL={handleIngestURL} onIngestFile={handleIngestFile} />
<Box flex={1} pos="relative" style={{ display: 'flex', overflow: 'hidden', minHeight: 0 }}>
<Box flex={1} style={{ minHeight: 0, height: '100%' }}>
<GraphView
data={graphData}
presets={presets}
onNodeClick={handleNodeClick}
onNodeDoubleClick={handleNodeDoubleClick}
onContextMenu={handleContextMenu}
/>
</Box>
{selectedEntity && (
<EntityDetail
entity={selectedEntity}
relations={relations}
onClose={() => setSelectedEntityId(null)}
onUpdate={refreshData}
/>
)}
</Box>
<NodeContextMenu
position={ctxMenu?.position ?? null}
nodeId={ctxMenu?.nodeId ?? null}
enrichers={ctxEnrichers}
running={runningEnricher}
onRun={handleRunEnricher}
onClose={() => setCtxMenu(null)}
/>
</TabsContent>
<TabsContent value="entities" style={{ flex: 1, overflow: 'auto', padding: '0 16px 16px' }}>
<EntityTable
entities={entities}
presets={presets}
onNodeClick={handleNodeClick}
onNodeDoubleClick={handleNodeDoubleClick}
onRefresh={refreshData}
/>
</div>
{selectedEntity && (
<EntityDetail
entity={selectedEntity}
</TabsContent>
<TabsContent value="relations" style={{ flex: 1, overflow: 'auto', padding: '0 16px 16px' }}>
<RelationTable
relations={relations}
onClose={() => setSelectedEntityId(null)}
onUpdate={refreshData}
entities={entities}
relationPresets={relationPresets}
onRefresh={refreshData}
/>
)}
</TabsContent>
</TabsContent>
<TabsContent value="entities" className="flex-1 overflow-auto px-4 pb-4 m-0">
<EntityTable
entities={entities}
presets={presets}
onRefresh={refreshData}
/>
</TabsContent>
<TabsContent value="relations" className="flex-1 overflow-auto px-4 pb-4 m-0">
<RelationTable
relations={relations}
entities={entities}
relationPresets={relationPresets}
onRefresh={refreshData}
/>
</TabsContent>
<TabsContent value="assertions" className="flex-1 overflow-auto px-4 pb-4 m-0">
<AssertionPanel entities={entities} />
</TabsContent>
</Tabs>
<TabsContent value="assertions" style={{ flex: 1, overflow: 'auto', padding: '0 16px 16px' }}>
<AssertionPanel entities={entities} />
</TabsContent>
</Tabs>
</Box>
</>
) : (
<div className="flex-1 flex items-center justify-center">
<p style={{ color: 'var(--muted-foreground)' }}>Select or create a project to begin</p>
</div>
<Center flex={1}>
<Text c="dimmed">Select or create a project to begin</Text>
</Center>
)}
</main>
</div>
</AppShell.Main>
</AppShell>
)
}
-36
View File
@@ -1,39 +1,3 @@
@import "tailwindcss";
:root {
--background: oklch(8% 0.015 260);
--foreground: oklch(95% 0.01 260);
--muted: oklch(18% 0.02 260);
--muted-foreground: oklch(60% 0.02 260);
--border: oklch(15% 0.01 260);
--primary: oklch(65% 0.22 260);
--primary-foreground: oklch(98% 0.01 260);
--secondary: oklch(20% 0.02 260);
--secondary-foreground: oklch(95% 0.01 260);
--accent: oklch(18% 0.03 260);
--accent-foreground: oklch(95% 0.01 260);
--destructive: oklch(55% 0.22 25);
--destructive-foreground: oklch(98% 0.01 260);
--card: oklch(11% 0.015 260);
--card-foreground: oklch(95% 0.01 260);
--popover: oklch(12% 0.015 260);
--popover-foreground: oklch(95% 0.01 260);
--ring: oklch(65% 0.22 260);
--input: oklch(22% 0.02 260);
--radius: 0.5rem;
--success: oklch(65% 0.2 145);
--success-foreground: oklch(98% 0.01 145);
}
body {
background-color: var(--background);
color: var(--foreground);
font-family: 'Geist Variable', system-ui, -apple-system, sans-serif;
-webkit-font-smoothing: antialiased;
}
[data-slot="card"] {
border: none;
box-shadow: none;
}
+93 -76
View File
@@ -1,10 +1,9 @@
import { useState } from 'react'
import type { Entity, Assertion, AssertionResult, AssertionInput } from '../types'
import { Plus, Play, Trash2 } from 'lucide-react'
import { SimpleSelect } from '@fn_library'
import { Card, CardHeader, CardTitle, CardContent } from '@fn_library'
import { Button } from '@fn_library'
import { Table, Group, Text, TextInput } from '@mantine/core'
import { IconPlus, IconPlayerPlay, IconTrash } from '@tabler/icons-react'
import { Card, CardHeader, CardTitle, CardContent, SimpleSelect, Button, FnActionIcon } from '@fn_library'
import * as WailsApp from '../wailsjs/go/main/App'
import type { Entity, Assertion, AssertionResult, AssertionInput } from '../types'
interface Props {
entities: Entity[]
@@ -54,37 +53,39 @@ export function AssertionPanel({ entities }: Props) {
loadAssertions(selectedEntity)
}
const inputStyle = { background: 'var(--input)', color: 'var(--foreground)', border: '1px solid var(--border)' }
return (
<Card className="mt-3">
<CardHeader className="flex flex-row items-center justify-between py-3">
<CardTitle className="text-base">Assertions</CardTitle>
<div className="flex gap-2">
<SimpleSelect
value={selectedEntity}
onValueChange={loadAssertions}
placeholder="Select entity..."
className="w-48"
options={entities.map(e => ({ value: e.id, label: e.name }))}
/>
<Button size="sm" onClick={handleEval} disabled={!selectedEntity}>
<Play size={14} className="mr-1" /> Eval
</Button>
<Button size="sm" variant="outline" onClick={() => setShowAdd(!showAdd)} disabled={!selectedEntity}>
<Plus size={14} />
</Button>
</div>
<Card variant="default" style={{ marginTop: 12 }}>
<CardHeader>
<Group justify="space-between" align="center" py="xs">
<CardTitle>Assertions</CardTitle>
<Group gap="sm">
<SimpleSelect
value={selectedEntity}
onValueChange={loadAssertions}
placeholder="Select entity..."
options={entities.map(e => ({ value: e.id, label: e.name }))}
/>
<Button size="sm" onClick={handleEval} disabled={!selectedEntity}>
<IconPlayerPlay size={14} style={{ marginRight: 4 }} /> Eval
</Button>
<Button size="sm" variant="outline" onClick={() => setShowAdd(!showAdd)} disabled={!selectedEntity}>
<IconPlus size={14} />
</Button>
</Group>
</Group>
</CardHeader>
{showAdd && (
<div className="px-4 pb-3 flex gap-2 items-end">
<div className="flex-1">
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Name</label>
<input value={newName} onChange={e => setNewName(e.target.value)} className="w-full px-2 py-1 rounded text-sm" style={inputStyle} />
</div>
<div className="w-24">
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Kind</label>
<Group px="md" pb="sm" gap="sm" align="flex-end">
<TextInput
label="Name"
value={newName}
onChange={e => setNewName(e.currentTarget.value)}
size="xs"
flex={1}
/>
<div style={{ width: 96 }}>
<Text size="xs" fw={500} mb={4}>Kind</Text>
<SimpleSelect
value={newKind}
onValueChange={setNewKind}
@@ -97,8 +98,8 @@ export function AssertionPanel({ entities }: Props) {
]}
/>
</div>
<div className="w-24">
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Severity</label>
<div style={{ width: 96 }}>
<Text size="xs" fw={500} mb={4}>Severity</Text>
<SimpleSelect
value={newSeverity}
onValueChange={setNewSeverity}
@@ -109,61 +110,77 @@ export function AssertionPanel({ entities }: Props) {
]}
/>
</div>
<div className="flex-1">
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Rule (SQL expr)</label>
<input value={newRule} onChange={e => setNewRule(e.target.value)} className="w-full px-2 py-1 rounded text-sm" style={inputStyle} placeholder="risk_score > 70" />
</div>
<button onClick={handleAdd} className="px-3 py-1 rounded text-sm" style={{ background: 'var(--primary)', color: 'var(--primary-foreground)' }}>Add</button>
</div>
<TextInput
label="Rule (SQL expr)"
value={newRule}
onChange={e => setNewRule(e.currentTarget.value)}
placeholder="risk_score > 70"
size="xs"
flex={1}
/>
<Button size="sm" onClick={handleAdd}>Add</Button>
</Group>
)}
<CardContent className="p-0">
<table className="w-full text-sm">
<thead>
<tr className="border-b" style={{ borderColor: 'var(--border)' }}>
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Name</th>
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Kind</th>
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Rule</th>
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Severity</th>
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Result</th>
<th className="text-right px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Actions</th>
</tr>
</thead>
<tbody>
<CardContent style={{ padding: 0 }}>
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Kind</Table.Th>
<Table.Th>Rule</Table.Th>
<Table.Th>Severity</Table.Th>
<Table.Th>Result</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{assertions.map(a => {
const result = results.find(r => r.assertion_id === a.id)
return (
<tr key={a.id} className="border-b" style={{ borderColor: 'var(--border)' }}>
<td className="px-4 py-2">{a.name}</td>
<td className="px-4 py-2" style={{ color: 'var(--muted-foreground)' }}>{a.kind}</td>
<td className="px-4 py-2 font-mono text-xs">{a.rule}</td>
<td className="px-4 py-2">
<span style={{ color: a.severity === 'critical' ? 'var(--destructive)' : a.severity === 'warning' ? 'var(--chart-3, #f59e0b)' : 'var(--muted-foreground)' }}>
<Table.Tr key={a.id}>
<Table.Td>{a.name}</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">{a.kind}</Text>
</Table.Td>
<Table.Td>
<Text size="xs" ff="monospace">{a.rule}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c={a.severity === 'critical' ? 'red' : a.severity === 'warning' ? 'yellow' : 'dimmed'}>
{a.severity}
</span>
</td>
<td className="px-4 py-2">
</Text>
</Table.Td>
<Table.Td>
{result ? (
<span style={{ color: result.status === 'pass' ? 'var(--success)' : 'var(--destructive)' }}>
<Text size="sm" c={result.status === 'pass' ? 'teal' : 'red'}>
{result.status}
</span>
</Text>
) : '—'}
</td>
<td className="px-4 py-2 text-right">
<button onClick={() => handleDelete(a.id)} className="p-1 rounded" style={{ color: 'var(--destructive)' }}>
<Trash2 size={14} />
</button>
</td>
</tr>
</Table.Td>
<Table.Td ta="right">
<FnActionIcon
icon={<IconTrash size={14} />}
variant="subtle"
size="sm"
color="red"
onClick={() => handleDelete(a.id)}
/>
</Table.Td>
</Table.Tr>
)
})}
{assertions.length === 0 && (
<tr><td colSpan={6} className="px-4 py-8 text-center" style={{ color: 'var(--muted-foreground)' }}>
{selectedEntity ? 'No assertions for this entity' : 'Select an entity to view assertions'}
</td></tr>
<Table.Tr>
<Table.Td colSpan={6}>
<Text ta="center" c="dimmed" py="xl">
{selectedEntity ? 'No assertions for this entity' : 'Select an entity to view assertions'}
</Text>
</Table.Td>
</Table.Tr>
)}
</tbody>
</table>
</Table.Tbody>
</Table>
</CardContent>
</Card>
)
+41 -35
View File
@@ -1,5 +1,8 @@
import { Box, Stack, Group, Text } from '@mantine/core'
import { IconExternalLink } from '@tabler/icons-react'
import { Badge, FnActionIcon } from '@fn_library'
import { IconX } from '@tabler/icons-react'
import type { Entity, Relation } from '../types'
import { X, ExternalLink } from 'lucide-react'
interface Props {
entity: Entity
@@ -12,80 +15,83 @@ export function EntityDetail({ entity, relations, onClose }: Props) {
const directRelations = relations.filter(r => r.from_entity === entity.id || r.to_entity === entity.id)
return (
<aside className="w-72 border-l overflow-y-auto" style={{ borderColor: 'var(--border)', background: 'var(--card)' }}>
<div className="p-3 flex items-center justify-between border-b" style={{ borderColor: 'var(--border)' }}>
<h3 className="text-sm font-semibold truncate">{entity.name}</h3>
<button onClick={onClose} className="p-1">
<X size={14} style={{ color: 'var(--muted-foreground)' }} />
</button>
</div>
<Box w={288} style={{ borderLeft: '1px solid var(--mantine-color-dark-4)', overflowY: 'auto' }}>
<Group px="sm" py="sm" justify="space-between" style={{ borderBottom: '1px solid var(--mantine-color-dark-4)' }}>
<Text size="sm" fw={600} truncate flex={1}>{entity.name}</Text>
<FnActionIcon
icon={<IconX size={14} />}
variant="subtle"
size="xs"
onClick={onClose}
/>
</Group>
<div className="p-3 space-y-3 text-sm">
<Stack gap="sm" p="sm">
<Section label="Type">{entity.type_ref.replace(/_go_cybersecurity$/, '').replace(/^osint_/, '')}</Section>
<Section label="Status">{entity.status}</Section>
{entity.description && <Section label="Description">{entity.description}</Section>}
{entity.metadata && Object.keys(entity.metadata).length > 0 && (
<div>
<label className="block text-xs font-semibold mb-1" style={{ color: 'var(--muted-foreground)' }}>Metadata</label>
<div className="space-y-1">
<Text size="xs" fw={600} c="dimmed" mb={4}>Metadata</Text>
<Stack gap={4}>
{Object.entries(entity.metadata).map(([k, v]) => (
<div key={k} className="flex justify-between">
<span style={{ color: 'var(--muted-foreground)' }}>{k}</span>
<span className="font-mono text-xs">{String(v)}</span>
</div>
<Group key={k} justify="space-between">
<Text size="sm" c="dimmed">{k}</Text>
<Text size="xs" ff="monospace">{String(v)}</Text>
</Group>
))}
</div>
</Stack>
</div>
)}
{entity.notes && (
<div>
<label className="block text-xs font-semibold mb-1" style={{ color: 'var(--muted-foreground)' }}>Notes</label>
<p className="text-xs whitespace-pre-wrap" style={{ color: 'var(--foreground)' }}>{entity.notes}</p>
<Text size="xs" fw={600} c="dimmed" mb={4}>Notes</Text>
<Text size="xs" style={{ whiteSpace: 'pre-wrap' }}>{entity.notes}</Text>
</div>
)}
{entity.tags && entity.tags.length > 0 && (
<div>
<label className="block text-xs font-semibold mb-1" style={{ color: 'var(--muted-foreground)' }}>Tags</label>
<div className="flex flex-wrap gap-1">
<Text size="xs" fw={600} c="dimmed" mb={4}>Tags</Text>
<Group gap={4}>
{entity.tags.map(t => (
<span key={t} className="px-1.5 py-0.5 rounded text-xs" style={{ background: 'var(--secondary)', color: 'var(--secondary-foreground)' }}>{t}</span>
<Badge key={t} variant="secondary" size="sm">{t}</Badge>
))}
</div>
</Group>
</div>
)}
{directRelations.length > 0 && (
<div>
<label className="block text-xs font-semibold mb-1" style={{ color: 'var(--muted-foreground)' }}>
<Text size="xs" fw={600} c="dimmed" mb={4}>
Relations ({directRelations.length})
</label>
<div className="space-y-1">
</Text>
<Stack gap={4}>
{directRelations.map(r => {
const isFrom = r.from_entity === entity.id
return (
<div key={r.id} className="flex items-center gap-1 text-xs">
<ExternalLink size={10} style={{ color: 'var(--muted-foreground)' }} />
<span>{isFrom ? '' : '<-'} {r.name} {isFrom ? '->' : ''}</span>
<span className="font-medium">{isFrom ? r.to_entity : r.from_entity}</span>
</div>
<Group key={r.id} gap={4}>
<IconExternalLink size={10} style={{ color: 'var(--mantine-color-dimmed)' }} />
<Text size="xs">{isFrom ? '' : '<-'} {r.name} {isFrom ? '->' : ''}</Text>
<Text size="xs" fw={500}>{isFrom ? r.to_entity : r.from_entity}</Text>
</Group>
)
})}
</div>
</Stack>
</div>
)}
</div>
</aside>
</Stack>
</Box>
)
}
function Section({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<label className="block text-xs font-semibold" style={{ color: 'var(--muted-foreground)' }}>{label}</label>
<span>{children}</span>
<Text size="xs" fw={600} c="dimmed">{label}</Text>
<Text size="sm">{children}</Text>
</div>
)
}
+59 -69
View File
@@ -1,11 +1,11 @@
import { useState, useEffect } from 'react'
import { Stack, Group, TextInput, Textarea, Text } from '@mantine/core'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, SimpleSelect, Button } from '@fn_library'
import type { Entity, EntityTypePreset, EntityInput } from '../types'
import { SimpleSelect } from '@fn_library'
import { X } from 'lucide-react'
interface Props {
presets: EntityTypePreset[]
entity: Entity | null // null = create, non-null = edit
entity: Entity | null
onSubmit: (input: EntityInput) => void
onClose: () => void
}
@@ -29,7 +29,6 @@ export function EntityDialog({ presets, entity, onSubmit, onClose }: Props) {
const currentPreset = presets.find(p => p.type_ref === typeRef)
const metadataFields = currentPreset?.metadata_fields ?? []
// When type changes, reset metadata fields to match new type
useEffect(() => {
if (!entity) {
const m: Record<string, string> = {}
@@ -44,7 +43,6 @@ export function EntityDialog({ presets, entity, onSubmit, onClose }: Props) {
const cleanMeta: Record<string, unknown> = {}
for (const [k, v] of Object.entries(metadata)) {
if (v.trim()) {
// Try parsing as number
const num = Number(v)
if (!isNaN(num) && v.trim() !== '') {
cleanMeta[k] = num
@@ -68,28 +66,23 @@ export function EntityDialog({ presets, entity, onSubmit, onClose }: Props) {
})
}
const inputStyle = {
background: 'var(--input)',
color: 'var(--foreground)',
border: '1px solid var(--border)',
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.6)' }}>
<div className="w-[520px] max-h-[85vh] overflow-y-auto rounded-lg p-5" style={{ background: 'var(--card)', border: '1px solid var(--border)' }}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-base font-semibold">{entity ? 'Edit Entity' : 'New Entity'}</h3>
<button onClick={onClose} className="p-1"><X size={16} style={{ color: 'var(--muted-foreground)' }} /></button>
</div>
<Dialog open onOpenChange={(open: boolean) => { if (!open) onClose() }}>
<DialogContent>
<DialogHeader>
<DialogTitle>{entity ? 'Edit Entity' : 'New Entity'}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div>
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Name</label>
<input value={name} onChange={e => setName(e.target.value)} className="w-full px-3 py-1.5 rounded text-sm" style={inputStyle} />
</div>
<Stack gap="sm">
<TextInput
label="Name"
value={name}
onChange={e => setName(e.currentTarget.value)}
size="sm"
/>
<div>
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Type</label>
<Text size="sm" fw={500} mb={4}>Type</Text>
<SimpleSelect
value={typeRef}
onValueChange={setTypeRef}
@@ -97,64 +90,61 @@ export function EntityDialog({ presets, entity, onSubmit, onClose }: Props) {
/>
</div>
<div>
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Description</label>
<input value={description} onChange={e => setDescription(e.target.value)} className="w-full px-3 py-1.5 rounded text-sm" style={inputStyle} />
</div>
<TextInput
label="Description"
value={description}
onChange={e => setDescription(e.currentTarget.value)}
size="sm"
/>
<div>
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Tags (comma separated)</label>
<input value={tagsStr} onChange={e => setTagsStr(e.target.value)} className="w-full px-3 py-1.5 rounded text-sm" style={inputStyle} placeholder="osint, high-risk" />
</div>
<TextInput
label="Tags (comma separated)"
value={tagsStr}
onChange={e => setTagsStr(e.currentTarget.value)}
placeholder="osint, high-risk"
size="sm"
/>
{metadataFields.length > 0 && (
<div>
<label className="block text-xs mb-1 font-semibold" style={{ color: 'var(--muted-foreground)' }}>
<Text size="xs" fw={600} c="dimmed" mb="xs">
Metadata ({currentPreset?.label})
</label>
<div className="space-y-2">
</Text>
<Stack gap="xs">
{metadataFields.map(field => (
<div key={field} className="flex items-center gap-2">
<span className="text-xs w-28 text-right" style={{ color: 'var(--muted-foreground)' }}>{field}</span>
<input
<Group key={field} gap="sm" align="center">
<Text size="xs" c="dimmed" w={112} ta="right">{field}</Text>
<TextInput
value={metadata[field] ?? ''}
onChange={e => setMetadata(prev => ({ ...prev, [field]: e.target.value }))}
className="flex-1 px-2 py-1 rounded text-sm"
style={inputStyle}
onChange={e => setMetadata(prev => ({ ...prev, [field]: e.currentTarget.value }))}
size="xs"
flex={1}
/>
</div>
</Group>
))}
</div>
</Stack>
</div>
)}
<div>
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Notes</label>
<textarea
value={notes}
onChange={e => setNotes(e.target.value)}
rows={3}
className="w-full px-3 py-1.5 rounded text-sm resize-none"
style={inputStyle}
placeholder="Operational notes..."
/>
</div>
</div>
<Textarea
label="Notes"
value={notes}
onChange={e => setNotes(e.currentTarget.value)}
rows={3}
placeholder="Operational notes..."
size="sm"
/>
</Stack>
<div className="flex justify-end gap-2 mt-4">
<button onClick={onClose} className="px-3 py-1.5 rounded text-sm" style={{ background: 'var(--secondary)', color: 'var(--secondary-foreground)' }}>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={!name.trim()}
className="px-3 py-1.5 rounded text-sm font-medium disabled:opacity-40"
style={{ background: 'var(--primary)', color: 'var(--primary-foreground)' }}
>
{entity ? 'Update' : 'Create'}
</button>
</div>
</div>
</div>
<DialogFooter>
<Group justify="flex-end" gap="sm" mt="md">
<Button variant="secondary" onClick={onClose}>Cancel</Button>
<Button onClick={handleSubmit} disabled={!name.trim()}>
{entity ? 'Update' : 'Create'}
</Button>
</Group>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+61 -47
View File
@@ -1,11 +1,10 @@
import { useState } from 'react'
import type { Entity, EntityTypePreset, EntityInput } from '../types'
import { Table, Group, Text } from '@mantine/core'
import { IconPlus, IconPencil, IconTrash } from '@tabler/icons-react'
import { Card, CardHeader, CardTitle, CardContent, Badge, Button, FnActionIcon } from '@fn_library'
import { EntityDialog } from './EntityDialog'
import { Plus, Pencil, Trash2 } from 'lucide-react'
import { Card, CardHeader, CardTitle, CardContent } from '@fn_library'
import { Badge } from '@fn_library'
import { Button } from '@fn_library'
import { AddEntity, UpdateEntity, DeleteEntity } from '../wailsjs/go/main/App'
import type { Entity, EntityTypePreset, EntityInput } from '../types'
interface Props {
entities: Entity[]
@@ -39,59 +38,74 @@ export function EntityTable({ entities, presets, onRefresh }: Props) {
}
return (
<Card className="mt-3">
<CardHeader className="flex flex-row items-center justify-between py-3">
<CardTitle className="text-base">Entities</CardTitle>
<Button size="sm" onClick={() => setDialogOpen(true)}>
<Plus size={14} className="mr-1" /> Add Entity
</Button>
<Card variant="default" style={{ marginTop: 12 }}>
<CardHeader>
<Group justify="space-between" align="center" py="xs">
<CardTitle>Entities</CardTitle>
<Button size="sm" onClick={() => setDialogOpen(true)}>
<IconPlus size={14} style={{ marginRight: 4 }} /> Add Entity
</Button>
</Group>
</CardHeader>
<CardContent className="p-0">
<table className="w-full text-sm">
<thead>
<tr className="border-b" style={{ borderColor: 'var(--border)' }}>
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Name</th>
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Type</th>
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Status</th>
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Notes</th>
<th className="text-right px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Actions</th>
</tr>
</thead>
<tbody>
<CardContent style={{ padding: 0 }}>
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Notes</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{entities.map(e => {
const preset = presetMap[e.type_ref]
return (
<tr key={e.id} className="border-b hover:opacity-90" style={{ borderColor: 'var(--border)' }}>
<td className="px-4 py-2 font-medium">{e.name}</td>
<td className="px-4 py-2">
<Badge style={{ backgroundColor: preset?.color ?? 'var(--muted-foreground)', color: 'var(--primary-foreground)' }}>
<Table.Tr key={e.id}>
<Table.Td fw={500}>{e.name}</Table.Td>
<Table.Td>
<Badge style={{ backgroundColor: preset?.color ?? undefined }}>
{preset?.label ?? e.type_ref}
</Badge>
</td>
<td className="px-4 py-2">
<span className="text-xs" style={{ color: e.status === 'active' ? 'var(--success)' : 'var(--muted-foreground)' }}>
</Table.Td>
<Table.Td>
<Text size="xs" c={e.status === 'active' ? 'teal' : 'dimmed'}>
{e.status}
</span>
</td>
<td className="px-4 py-2 max-w-48 truncate" style={{ color: 'var(--muted-foreground)' }}>
{e.notes || '—'}
</td>
<td className="px-4 py-2 text-right">
<button onClick={() => setEditEntity(e)} className="p-1 mr-1 rounded hover:opacity-80" style={{ color: 'var(--primary)' }}>
<Pencil size={14} />
</button>
<button onClick={() => handleDelete(e.id)} className="p-1 rounded hover:opacity-80" style={{ color: 'var(--destructive)' }}>
<Trash2 size={14} />
</button>
</td>
</tr>
</Text>
</Table.Td>
<Table.Td maw={192}>
<Text size="sm" c="dimmed" truncate>{e.notes || '—'}</Text>
</Table.Td>
<Table.Td ta="right">
<Group gap={4} justify="flex-end">
<FnActionIcon
icon={<IconPencil size={14} />}
variant="subtle"
size="sm"
onClick={() => setEditEntity(e)}
/>
<FnActionIcon
icon={<IconTrash size={14} />}
variant="subtle"
size="sm"
color="red"
onClick={() => handleDelete(e.id)}
/>
</Group>
</Table.Td>
</Table.Tr>
)
})}
{entities.length === 0 && (
<tr><td colSpan={5} className="px-4 py-8 text-center" style={{ color: 'var(--muted-foreground)' }}>No entities yet</td></tr>
<Table.Tr>
<Table.Td colSpan={5}>
<Text ta="center" c="dimmed" py="xl">No entities yet</Text>
</Table.Td>
</Table.Tr>
)}
</tbody>
</table>
</Table.Tbody>
</Table>
</CardContent>
{(dialogOpen || editEntity) && (
+8 -6
View File
@@ -1,3 +1,4 @@
import { Center, Text } from '@mantine/core'
import { GraphContainer } from '@graph'
import type { GraphData as LibGraphData } from '@graph'
import type { GraphData, EntityTypePreset } from '../types'
@@ -7,10 +8,10 @@ interface Props {
presets: EntityTypePreset[]
onNodeClick: (nodeId: string) => void
onNodeDoubleClick: (nodeId: string) => void
onContextMenu?: (event: MouseEvent, target: { type: 'node' | 'edge' | 'canvas'; id?: string; data?: Record<string, unknown> }) => void
}
export function GraphView({ data, presets, onNodeClick, onNodeDoubleClick }: Props) {
// Map our GraphData to the library's format (they're compatible but need the cast)
export function GraphView({ data, presets, onNodeClick, onNodeDoubleClick, onContextMenu }: Props) {
const libData: LibGraphData = {
nodes: data.nodes.map(n => ({
id: n.id,
@@ -40,11 +41,11 @@ export function GraphView({ data, presets, onNodeClick, onNodeDoubleClick }: Pro
if (data.nodes.length === 0) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-sm" style={{ color: 'var(--muted-foreground)' }}>
<Center h="100%">
<Text size="sm" c="dimmed">
No data to display. Add entities and relations to build the graph.
</p>
</div>
</Text>
</Center>
)
}
@@ -58,6 +59,7 @@ export function GraphView({ data, presets, onNodeClick, onNodeDoubleClick }: Pro
nodeTypes={nodeTypes}
onNodeClick={n => onNodeClick(n.id)}
onNodeDoubleClick={n => onNodeDoubleClick(n.id)}
onContextMenu={onContextMenu}
enableSelection
selectionMode="multiple"
theme={{ nodeSize: 8, edgeSize: 1 }}
+62
View File
@@ -0,0 +1,62 @@
import { useState } from 'react'
import { Group, TextInput, ActionIcon, Text } from '@mantine/core'
import { IconPlus, IconLink, IconFile } from '@tabler/icons-react'
interface Props {
onIngestURL: (url: string) => void
onIngestFile: (path: string) => void
}
export function IngestPanel({ onIngestURL, onIngestFile }: Props) {
const [url, setUrl] = useState('')
const handleSubmit = () => {
const trimmed = url.trim()
if (!trimmed) return
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
onIngestURL(trimmed)
} else if (trimmed.startsWith('/') || trimmed.includes('.')) {
onIngestFile(trimmed)
} else {
onIngestURL('https://' + trimmed)
}
setUrl('')
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
const files = e.dataTransfer.files
if (files.length > 0) {
// Wails provides the full path via dataTransfer
const path = (files[0] as any).path || files[0].name
if (path) onIngestFile(path)
}
}
return (
<Group
px="sm"
py={4}
gap="xs"
style={{ borderBottom: '1px solid var(--mantine-color-dark-5)' }}
onDrop={handleDrop}
onDragOver={e => e.preventDefault()}
>
<IconLink size={14} style={{ opacity: 0.5 }} />
<TextInput
size="xs"
placeholder="Paste URL or file path..."
value={url}
onChange={e => setUrl(e.currentTarget.value)}
onKeyDown={e => e.key === 'Enter' && handleSubmit()}
flex={1}
variant="unstyled"
styles={{ input: { fontSize: 12 } }}
/>
<ActionIcon size="xs" variant="subtle" onClick={handleSubmit} disabled={!url.trim()}>
<IconPlus size={14} />
</ActionIcon>
<Text size="xs" c="dimmed">or drop file</Text>
</Group>
)
}
@@ -0,0 +1,64 @@
import { Menu, Text, Loader } from '@mantine/core'
import {
IconWorldDownload,
IconFileText,
IconBrain,
IconLink,
IconServer,
IconWand,
} from '@tabler/icons-react'
import type { EnricherDef } from '../types'
const ICON_MAP: Record<string, React.ComponentType<{ size?: number }>> = {
IconWorldDownload,
IconFileText,
IconBrain,
IconLink,
IconServer,
}
interface Props {
position: { x: number; y: number } | null
nodeId: string | null
enrichers: EnricherDef[]
running: string | null
onRun: (enricherId: string) => void
onClose: () => void
}
export function NodeContextMenu({ position, nodeId, enrichers, running, onRun, onClose }: Props) {
if (!position || !nodeId || enrichers.length === 0) return null
return (
<Menu
opened
onChange={(opened) => { if (!opened) onClose() }}
position="bottom-start"
offset={0}
>
<Menu.Target>
<div style={{ position: 'fixed', left: position.x, top: position.y, width: 1, height: 1 }} />
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Enrichers</Menu.Label>
{enrichers.map(e => {
const Icon = ICON_MAP[e.icon] || IconWand
const isRunning = running === e.id
return (
<Menu.Item
key={e.id}
leftSection={isRunning ? <Loader size={16} /> : <Icon size={16} />}
disabled={running !== null}
onClick={() => onRun(e.id)}
>
<div>
<Text size="sm">{e.label}</Text>
<Text size="xs" c="dimmed">{e.description}</Text>
</div>
</Menu.Item>
)
})}
</Menu.Dropdown>
</Menu>
)
}
+46 -60
View File
@@ -1,6 +1,8 @@
import { useState } from 'react'
import { Box, Stack, Group, Text, TextInput, UnstyledButton } from '@mantine/core'
import { IconPlus, IconTrash, IconFolderOpen } from '@tabler/icons-react'
import { FnActionIcon } from '@fn_library'
import type { ProjectInfo } from '../types'
import { Plus, Trash2, FolderOpen } from 'lucide-react'
interface Props {
projects: ProjectInfo[]
@@ -14,90 +16,74 @@ export function ProjectSidebar({ projects, current, onSwitch, onCreate, onDelete
const [newName, setNewName] = useState('')
const [showInput, setShowInput] = useState(false)
console.log('[ProjectSidebar] render: projects=', projects.length, 'current=', current, 'projects data:', JSON.stringify(projects))
const handleCreate = () => {
const name = newName.trim()
console.log('[ProjectSidebar] handleCreate: name=', JSON.stringify(name))
if (name) {
onCreate(name)
setNewName('')
setShowInput(false)
} else {
console.log('[ProjectSidebar] handleCreate: empty name, skipping')
}
}
return (
<aside className="w-56 flex flex-col border-r" style={{ borderColor: 'var(--border)', background: 'var(--card)' }}>
<div className="p-3 flex items-center justify-between border-b" style={{ borderColor: 'var(--border)' }}>
<span className="text-xs font-bold uppercase tracking-wider" style={{ color: 'var(--muted-foreground)' }}>
<Stack gap={0} h="100%">
<Group px="sm" py="sm" justify="space-between" style={{ borderBottom: '1px solid var(--mantine-color-dark-4)' }}>
<Text size="xs" fw={700} tt="uppercase" c="dimmed" style={{ letterSpacing: '0.05em' }}>
Projects
</span>
<button
onClick={() => { console.log('[ProjectSidebar] toggling input'); setShowInput(!showInput) }}
className="p-1 rounded hover:opacity-80"
style={{ color: 'var(--primary)' }}
>
<Plus size={16} />
</button>
</div>
</Text>
<FnActionIcon
icon={<IconPlus size={16} />}
variant="subtle"
size="sm"
onClick={() => setShowInput(!showInput)}
/>
</Group>
{showInput && (
<div className="p-2 border-b" style={{ borderColor: 'var(--border)' }}>
<input
<Box p="xs" style={{ borderBottom: '1px solid var(--mantine-color-dark-4)' }}>
<TextInput
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => {
console.log('[ProjectSidebar] keyDown:', e.key)
if (e.key === 'Enter') handleCreate()
}}
onChange={e => setNewName(e.currentTarget.value)}
onKeyDown={e => { if (e.key === 'Enter') handleCreate() }}
placeholder="Project name..."
size="xs"
autoFocus
className="w-full px-2 py-1 rounded text-sm"
style={{
background: 'var(--input)',
color: 'var(--foreground)',
border: '1px solid var(--border)',
}}
/>
</div>
</Box>
)}
<div className="flex-1 overflow-y-auto">
<Box flex={1} style={{ overflowY: 'auto' }}>
{projects.map(p => (
<button
<UnstyledButton
key={p.name}
onClick={() => { console.log('[ProjectSidebar] switching to:', p.name); onSwitch(p.name) }}
className="flex w-full items-center gap-2 px-3 py-2 cursor-pointer group text-left"
style={{
background: p.name === current ? 'var(--accent)' : 'transparent',
color: p.name === current ? 'var(--accent-foreground)' : 'var(--foreground)',
}}
onClick={() => onSwitch(p.name)}
w="100%"
px="sm"
py="xs"
bg={p.name === current ? 'dark.6' : undefined}
style={{ display: 'block' }}
>
<FolderOpen size={14} style={{ color: 'var(--muted-foreground)' }} />
<div className="flex-1 min-w-0">
<div className="text-sm truncate">{p.name}</div>
<div className="text-xs" style={{ color: 'var(--muted-foreground)' }}>
{p.entity_count}E / {p.relation_count}R
</div>
</div>
<button
onClick={e => { e.stopPropagation(); console.log('[ProjectSidebar] deleting:', p.name); onDelete(p.name) }}
className="opacity-0 group-hover:opacity-100 p-1 rounded"
style={{ color: 'var(--destructive)' }}
>
<Trash2 size={12} />
</button>
</button>
<Group gap="xs" wrap="nowrap">
<IconFolderOpen size={14} style={{ color: 'var(--mantine-color-dimmed)', flexShrink: 0 }} />
<Box flex={1} miw={0}>
<Text size="sm" truncate>{p.name}</Text>
<Text size="xs" c="dimmed">{p.entity_count}E / {p.relation_count}R</Text>
</Box>
<FnActionIcon
icon={<IconTrash size={12} />}
variant="subtle"
size="xs"
color="red"
onClick={(e: React.MouseEvent) => { e.stopPropagation(); onDelete(p.name) }}
/>
</Group>
</UnstyledButton>
))}
{projects.length === 0 && (
<p className="p-3 text-xs" style={{ color: 'var(--muted-foreground)' }}>
No projects yet
</p>
<Text size="xs" c="dimmed" p="sm">No projects yet</Text>
)}
</div>
</aside>
</Box>
</Stack>
)
}
+51 -50
View File
@@ -1,7 +1,7 @@
import { useState } from 'react'
import { Stack, Group, TextInput, Textarea, Text, NumberInput } from '@mantine/core'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, SimpleSelect, Button } from '@fn_library'
import type { Entity, RelationInputDTO } from '../types'
import { SimpleSelect } from '@fn_library'
import { X } from 'lucide-react'
interface Props {
entities: Entity[]
@@ -15,11 +15,11 @@ export function RelationDialog({ entities, relationPresets, onSubmit, onClose }:
const [fromEntity, setFromEntity] = useState(entities[0]?.id ?? '')
const [toEntity, setToEntity] = useState(entities[1]?.id ?? entities[0]?.id ?? '')
const [description, setDescription] = useState('')
const [weight, setWeight] = useState('1.0')
const [weight, setWeight] = useState<number | string>(1.0)
const [notes, setNotes] = useState('')
const handleSubmit = () => {
const w = parseFloat(weight)
const w = typeof weight === 'number' ? weight : parseFloat(String(weight))
onSubmit({
name,
from_entity: fromEntity,
@@ -31,23 +31,16 @@ export function RelationDialog({ entities, relationPresets, onSubmit, onClose }:
})
}
const inputStyle = {
background: 'var(--input)',
color: 'var(--foreground)',
border: '1px solid var(--border)',
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.6)' }}>
<div className="w-[480px] rounded-lg p-5" style={{ background: 'var(--card)', border: '1px solid var(--border)' }}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-base font-semibold">New Relation</h3>
<button onClick={onClose} className="p-1"><X size={16} style={{ color: 'var(--muted-foreground)' }} /></button>
</div>
<Dialog open onOpenChange={(open: boolean) => { if (!open) onClose() }}>
<DialogContent>
<DialogHeader>
<DialogTitle>New Relation</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<Stack gap="sm">
<div>
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Relation Type</label>
<Text size="sm" fw={500} mb={4}>Relation Type</Text>
<SimpleSelect
value={name}
onValueChange={setName}
@@ -55,53 +48,61 @@ export function RelationDialog({ entities, relationPresets, onSubmit, onClose }:
/>
</div>
<div className="flex gap-3">
<div className="flex-1">
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>From</label>
<Group gap="sm" grow>
<div>
<Text size="sm" fw={500} mb={4}>From</Text>
<SimpleSelect
value={fromEntity}
onValueChange={setFromEntity}
options={entities.map(e => ({ value: e.id, label: e.name }))}
/>
</div>
<div className="flex-1">
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>To</label>
<div>
<Text size="sm" fw={500} mb={4}>To</Text>
<SimpleSelect
value={toEntity}
onValueChange={setToEntity}
options={entities.map(e => ({ value: e.id, label: e.name }))}
/>
</div>
</div>
</Group>
<div>
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Description</label>
<input value={description} onChange={e => setDescription(e.target.value)} className="w-full px-3 py-1.5 rounded text-sm" style={inputStyle} />
</div>
<TextInput
label="Description"
value={description}
onChange={e => setDescription(e.currentTarget.value)}
size="sm"
/>
<div>
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Weight (0.0 - 1.0)</label>
<input value={weight} onChange={e => setWeight(e.target.value)} type="number" step="0.1" min="0" max="1" className="w-full px-3 py-1.5 rounded text-sm" style={inputStyle} />
</div>
<NumberInput
label="Weight (0.0 - 1.0)"
value={weight}
onChange={setWeight}
step={0.1}
min={0}
max={1}
decimalScale={2}
size="sm"
/>
<div>
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Notes</label>
<textarea value={notes} onChange={e => setNotes(e.target.value)} rows={2} className="w-full px-3 py-1.5 rounded text-sm resize-none" style={inputStyle} />
</div>
</div>
<Textarea
label="Notes"
value={notes}
onChange={e => setNotes(e.currentTarget.value)}
rows={2}
size="sm"
/>
</Stack>
<div className="flex justify-end gap-2 mt-4">
<button onClick={onClose} className="px-3 py-1.5 rounded text-sm" style={{ background: 'var(--secondary)', color: 'var(--secondary-foreground)' }}>Cancel</button>
<button
onClick={handleSubmit}
disabled={!name || fromEntity === toEntity}
className="px-3 py-1.5 rounded text-sm font-medium disabled:opacity-40"
style={{ background: 'var(--primary)', color: 'var(--primary-foreground)' }}
>
Create
</button>
</div>
</div>
</div>
<DialogFooter>
<Group justify="flex-end" gap="sm" mt="md">
<Button variant="secondary" onClick={onClose}>Cancel</Button>
<Button onClick={handleSubmit} disabled={!name || fromEntity === toEntity}>
Create
</Button>
</Group>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+48 -36
View File
@@ -1,10 +1,10 @@
import { useState } from 'react'
import type { Relation, Entity, RelationInputDTO } from '../types'
import { Table, Group, Text } from '@mantine/core'
import { IconPlus, IconTrash } from '@tabler/icons-react'
import { Card, CardHeader, CardTitle, CardContent, Button, FnActionIcon } from '@fn_library'
import { RelationDialog } from './RelationDialog'
import { Plus, Trash2 } from 'lucide-react'
import { Card, CardHeader, CardTitle, CardContent } from '@fn_library'
import { Button } from '@fn_library'
import { AddRelation, DeleteRelation } from '../wailsjs/go/main/App'
import type { Relation, Entity, RelationInputDTO } from '../types'
interface Props {
relations: Relation[]
@@ -30,43 +30,55 @@ export function RelationTable({ relations, entities, relationPresets, onRefresh
}
return (
<Card className="mt-3">
<CardHeader className="flex flex-row items-center justify-between py-3">
<CardTitle className="text-base">Relations</CardTitle>
<Button size="sm" onClick={() => setDialogOpen(true)} disabled={entities.length < 2}>
<Plus size={14} className="mr-1" /> Add Relation
</Button>
<Card variant="default" style={{ marginTop: 12 }}>
<CardHeader>
<Group justify="space-between" align="center" py="xs">
<CardTitle>Relations</CardTitle>
<Button size="sm" onClick={() => setDialogOpen(true)} disabled={entities.length < 2}>
<IconPlus size={14} style={{ marginRight: 4 }} /> Add Relation
</Button>
</Group>
</CardHeader>
<CardContent className="p-0">
<table className="w-full text-sm">
<thead>
<tr className="border-b" style={{ borderColor: 'var(--border)' }}>
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>From</th>
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Relation</th>
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>To</th>
<th className="text-left px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Weight</th>
<th className="text-right px-4 py-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>Actions</th>
</tr>
</thead>
<tbody>
<CardContent style={{ padding: 0 }}>
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>From</Table.Th>
<Table.Th>Relation</Table.Th>
<Table.Th>To</Table.Th>
<Table.Th>Weight</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{relations.map(r => (
<tr key={r.id} className="border-b" style={{ borderColor: 'var(--border)' }}>
<td className="px-4 py-2">{entityMap[r.from_entity] ?? r.from_entity}</td>
<td className="px-4 py-2 font-medium">{r.name}</td>
<td className="px-4 py-2">{entityMap[r.to_entity] ?? r.to_entity}</td>
<td className="px-4 py-2" style={{ color: 'var(--muted-foreground)' }}>{r.weight?.toFixed(2) ?? '—'}</td>
<td className="px-4 py-2 text-right">
<button onClick={() => handleDelete(r.id)} className="p-1 rounded hover:opacity-80" style={{ color: 'var(--destructive)' }}>
<Trash2 size={14} />
</button>
</td>
</tr>
<Table.Tr key={r.id}>
<Table.Td>{entityMap[r.from_entity] ?? r.from_entity}</Table.Td>
<Table.Td fw={500}>{r.name}</Table.Td>
<Table.Td>{entityMap[r.to_entity] ?? r.to_entity}</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">{r.weight?.toFixed(2) ?? '—'}</Text>
</Table.Td>
<Table.Td ta="right">
<FnActionIcon
icon={<IconTrash size={14} />}
variant="subtle"
size="sm"
color="red"
onClick={() => handleDelete(r.id)}
/>
</Table.Td>
</Table.Tr>
))}
{relations.length === 0 && (
<tr><td colSpan={5} className="px-4 py-8 text-center" style={{ color: 'var(--muted-foreground)' }}>No relations yet</td></tr>
<Table.Tr>
<Table.Td colSpan={5}>
<Text ta="center" c="dimmed" py="xl">No relations yet</Text>
</Table.Td>
</Table.Tr>
)}
</tbody>
</table>
</Table.Tbody>
</Table>
</CardContent>
{dialogOpen && (
-37
View File
@@ -1,37 +0,0 @@
import { useState, useRef, useEffect } from 'react'
import { Search, X } from 'lucide-react'
interface Props {
onSearch: (query: string) => void
}
export function SearchBar({ onSearch }: Props) {
const [query, setQuery] = useState('')
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => {
onSearch(query)
}, 300)
return () => { if (timerRef.current) clearTimeout(timerRef.current) }
}, [query, onSearch])
return (
<div className="flex-1 flex items-center gap-2 px-2 py-1 rounded" style={{ background: 'var(--input)', border: '1px solid var(--border)' }}>
<Search size={14} style={{ color: 'var(--muted-foreground)' }} />
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search entities..."
className="flex-1 bg-transparent text-sm outline-none"
style={{ color: 'var(--foreground)' }}
/>
{query && (
<button onClick={() => setQuery('')} className="p-0.5">
<X size={12} style={{ color: 'var(--muted-foreground)' }} />
</button>
)}
</div>
)
}
+49 -12
View File
@@ -1,17 +1,47 @@
declare module '@fn_library' {
export const Tabs: React.FC<{ value: string; onValueChange: (v: string) => void; className?: string; children: React.ReactNode }>
export const TabsList: React.FC<{ className?: string; children: React.ReactNode }>
export const TabsTrigger: React.FC<{ value: string; children: React.ReactNode }>
export const TabsContent: React.FC<{ value: string; className?: string; children: React.ReactNode }>
export const Card: React.FC<{ className?: string; children: React.ReactNode }>
export const CardHeader: React.FC<{ className?: string; children: React.ReactNode }>
export const CardTitle: React.FC<{ className?: string; children: React.ReactNode }>
export const CardContent: React.FC<{ className?: string; children: React.ReactNode }>
export const Badge: React.FC<{ className?: string; style?: React.CSSProperties; children: React.ReactNode }>
export const Button: React.FC<{ size?: string; variant?: string; onClick?: () => void; disabled?: boolean; className?: string; children: React.ReactNode }>
import { type CSSProperties, type ReactNode, type ReactElement, type MouseEventHandler } from 'react'
// Tabs
export const Tabs: React.FC<{ defaultValue?: string | null; value?: string | null; onTabChange?: (value: string | null) => void; orientation?: 'horizontal' | 'vertical'; variant?: 'default' | 'line'; className?: string; style?: CSSProperties; children?: ReactNode }>
export const TabsList: React.FC<{ className?: string; style?: CSSProperties; children?: ReactNode }>
export const TabsTrigger: React.FC<{ value: string; disabled?: boolean; className?: string; children?: ReactNode }>
export const TabsContent: React.FC<{ value: string; className?: string; style?: CSSProperties; children?: ReactNode }>
// Card
export const Card: React.FC<{ variant?: 'default' | 'borderless' | 'ghost'; size?: 'default' | 'sm'; className?: string; style?: CSSProperties; children?: ReactNode }>
export const CardHeader: React.FC<{ className?: string; style?: CSSProperties; children?: ReactNode }>
export const CardTitle: React.FC<{ className?: string; children?: ReactNode }>
export const CardContent: React.FC<{ className?: string; style?: CSSProperties; children?: ReactNode }>
export const CardFooter: React.FC<{ className?: string; style?: CSSProperties; children?: ReactNode }>
// Badge
export const Badge: React.FC<{ variant?: 'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'success' | 'warning' | 'error' | 'info'; size?: 'default' | 'sm'; className?: string; style?: CSSProperties; children?: ReactNode }>
// Button
export const Button: React.FC<{ variant?: 'default' | 'outline' | 'secondary' | 'ghost' | 'destructive' | 'link'; size?: 'default' | 'xs' | 'sm' | 'lg' | 'icon'; onClick?: MouseEventHandler; disabled?: boolean; className?: string; style?: CSSProperties; children?: ReactNode }>
// SimpleSelect
export interface SimpleSelectOption { value: string; label: string; disabled?: boolean }
export function SimpleSelect(props: { value: string; onValueChange: (value: string) => void; options: SimpleSelectOption[]; placeholder?: string; disabled?: boolean; size?: 'sm' | 'default'; className?: string }): React.ReactElement
export function SearchBar(props: { onSearch: (query: string) => void; placeholder?: string; debounceMs?: number; className?: string }): React.ReactElement
export function SimpleSelect(props: { value: string; onValueChange: (value: string) => void; options: SimpleSelectOption[]; placeholder?: string; disabled?: boolean; size?: 'sm' | 'default'; className?: string }): ReactElement
// SearchBar
export function SearchBar(props: { onSearch: (query: string) => void; placeholder?: string; debounceMs?: number; className?: string }): ReactElement
// Dialog
export const Dialog: React.FC<{ open?: boolean; onOpenChange?: (open: boolean) => void; children?: ReactNode }>
export const DialogContent: React.FC<{ className?: string; children?: ReactNode }>
export const DialogHeader: React.FC<{ className?: string; children?: ReactNode }>
export const DialogTitle: React.FC<{ className?: string; children?: ReactNode }>
export const DialogDescription: React.FC<{ className?: string; children?: ReactNode }>
export const DialogFooter: React.FC<{ className?: string; children?: ReactNode }>
export const DialogClose: React.FC<{ className?: string; children?: ReactNode }>
export const DialogTrigger: React.FC<{ className?: string; children?: ReactNode }>
// FnActionIcon
export const FnActionIcon: React.FC<{ icon: ReactNode; variant?: 'filled' | 'light' | 'outline' | 'transparent' | 'default' | 'subtle'; size?: string | number; color?: string; onClick?: MouseEventHandler; loading?: boolean; disabled?: boolean; tooltip?: string; className?: string; style?: CSSProperties }>
// Textarea (re-export)
export const Textarea: React.FC<{ label?: string; value?: string; onChange?: React.ChangeEventHandler<HTMLTextAreaElement>; rows?: number; placeholder?: string; size?: string; autoResize?: boolean; className?: string }>
}
declare module '@graph' {
@@ -42,6 +72,12 @@ declare module '@graph' {
edges: GraphEdge[]
}
export interface ContextMenuTarget {
type: 'node' | 'edge' | 'canvas'
id?: string
data?: GraphNode | GraphEdge
}
export const GraphContainer: React.FC<{
data: GraphData
layout?: string
@@ -51,6 +87,7 @@ declare module '@graph' {
nodeTypes?: Array<{ type: string; color: string; label: string }>
onNodeClick?: (node: GraphNode) => void
onNodeDoubleClick?: (node: GraphNode) => void
onContextMenu?: (event: MouseEvent, target: ContextMenuTarget) => void
enableSelection?: boolean
selectionMode?: string
theme?: Record<string, unknown>
-6
View File
@@ -1,6 +0,0 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+29 -1
View File
@@ -1,10 +1,38 @@
import '@mantine/core/styles.css'
import '@mantine/notifications/styles.css'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { MantineProvider, createTheme, type MantineColorsTuple } from '@mantine/core'
import { Notifications } from '@mantine/notifications'
import './app.css'
import App from './App'
const brand: MantineColorsTuple = [
'#e5f0ff',
'#cddeff',
'#9abbff',
'#6495ff',
'#3874fe',
'#1d60fe',
'#0953ff',
'#0046e4',
'#003dcd',
'#0034b5',
]
const theme = createTheme({
colors: { brand },
primaryColor: 'brand',
defaultRadius: 'md',
fontFamily: "'Geist Variable', system-ui, -apple-system, sans-serif",
})
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<MantineProvider theme={theme} defaultColorScheme="dark">
<Notifications position="top-right" />
<App />
</MantineProvider>
</StrictMode>,
)
+14
View File
@@ -119,3 +119,17 @@ export interface AssertionInput {
severity: string
description: string
}
export interface EnricherDef {
id: string
label: string
description: string
applies_to: string[]
icon: string
}
export interface ContextMenuTarget {
type: "node" | "edge" | "canvas"
id?: string
data?: GraphNode | GraphEdge
}
+10
View File
@@ -23,6 +23,10 @@ export function EvalAssertions(arg1:string):Promise<Array<fn_operations.Assertio
export function GetCurrentProject():Promise<string>;
export function GetEnrichers():Promise<Array<main.EnricherDef>>;
export function GetEnrichersForEntity(arg1:string):Promise<Array<main.EnricherDef>>;
export function GetEntity(arg1:string):Promise<fn_operations.Entity>;
export function GetEntityNeighbors(arg1:string,arg2:number):Promise<main.GraphData>;
@@ -35,6 +39,10 @@ export function GetGraphData():Promise<main.GraphData>;
export function GetRelationPresets():Promise<Array<string>>;
export function IngestFile(arg1:string):Promise<string>;
export function IngestURL(arg1:string):Promise<string>;
export function ListAssertions(arg1:string):Promise<Array<fn_operations.Assertion>>;
export function ListEntities():Promise<Array<fn_operations.Entity>>;
@@ -43,6 +51,8 @@ export function ListProjects():Promise<Array<main.ProjectInfo>>;
export function ListRelations():Promise<Array<fn_operations.Relation>>;
export function RunEnricher(arg1:string,arg2:string):Promise<main.GraphData>;
export function SearchEntities(arg1:string):Promise<Array<fn_operations.Entity>>;
export function SearchGraph(arg1:string):Promise<main.GraphData>;
+20
View File
@@ -42,6 +42,14 @@ export function GetCurrentProject() {
return window['go']['main']['App']['GetCurrentProject']();
}
export function GetEnrichers() {
return window['go']['main']['App']['GetEnrichers']();
}
export function GetEnrichersForEntity(arg1) {
return window['go']['main']['App']['GetEnrichersForEntity'](arg1);
}
export function GetEntity(arg1) {
return window['go']['main']['App']['GetEntity'](arg1);
}
@@ -66,6 +74,14 @@ export function GetRelationPresets() {
return window['go']['main']['App']['GetRelationPresets']();
}
export function IngestFile(arg1) {
return window['go']['main']['App']['IngestFile'](arg1);
}
export function IngestURL(arg1) {
return window['go']['main']['App']['IngestURL'](arg1);
}
export function ListAssertions(arg1) {
return window['go']['main']['App']['ListAssertions'](arg1);
}
@@ -82,6 +98,10 @@ export function ListRelations() {
return window['go']['main']['App']['ListRelations']();
}
export function RunEnricher(arg1, arg2) {
return window['go']['main']['App']['RunEnricher'](arg1, arg2);
}
export function SearchEntities(arg1) {
return window['go']['main']['App']['SearchEntities'](arg1);
}
+22
View File
@@ -237,6 +237,28 @@ export namespace main {
this.description = source["description"];
}
}
export class EnricherDef {
id: string;
label: string;
description: string;
applies_to: string[];
script: string;
icon: string;
static createFrom(source: any = {}) {
return new EnricherDef(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.label = source["label"];
this.description = source["description"];
this.applies_to = source["applies_to"];
this.script = source["script"];
this.icon = source["icon"];
}
}
export class EntityInput {
name: string;
type_ref: string;
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/declarations.d.ts","./src/main.tsx","./src/types.ts","./src/components/AssertionPanel.tsx","./src/components/EntityDetail.tsx","./src/components/EntityDialog.tsx","./src/components/EntityTable.tsx","./src/components/GraphView.tsx","./src/components/ProjectSidebar.tsx","./src/components/RelationDialog.tsx","./src/components/RelationTable.tsx","./src/components/SearchBar.tsx","./src/lib/utils.ts","./src/wailsjs/go/models.ts","./src/wailsjs/go/main/App.d.ts","./src/wailsjs/runtime/runtime.d.ts"],"version":"5.9.3"}
{"root":["./src/App.tsx","./src/declarations.d.ts","./src/main.tsx","./src/types.ts","./src/components/AssertionPanel.tsx","./src/components/EntityDetail.tsx","./src/components/EntityDialog.tsx","./src/components/EntityTable.tsx","./src/components/GraphView.tsx","./src/components/IngestPanel.tsx","./src/components/NodeContextMenu.tsx","./src/components/ProjectSidebar.tsx","./src/components/RelationDialog.tsx","./src/components/RelationTable.tsx","./src/wailsjs/go/models.ts","./src/wailsjs/go/main/App.d.ts","./src/wailsjs/runtime/runtime.d.ts"],"version":"5.9.3"}
+4 -2
View File
@@ -1,10 +1,9 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { resolve } from 'path'
export default defineConfig({
plugins: [react(), tailwindcss()],
plugins: [react()],
resolve: {
alias: {
'@': resolve(__dirname, './src'),
@@ -14,6 +13,9 @@ export default defineConfig({
},
dedupe: ['react', 'react-dom'],
},
css: {
postcss: resolve(__dirname, './postcss.config.cjs'),
},
server: {
port: 5174,
},