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