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
+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>
)