feat(app): enrichers from templates

This commit is contained in:
dextmorgn
2026-02-02 11:42:13 +01:00
parent 53a03575cd
commit ac397f6714
17 changed files with 1680 additions and 84 deletions

View File

@@ -1,4 +1,12 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
ecmaFeatures: {
jsx: true
}
},
extends: [
'eslint:recommended',
'plugin:react/recommended',

View File

@@ -18,6 +18,7 @@
"dependencies": {
"@dagrejs/dagre": "^1.1.4",
"@hookform/resolvers": "^5.0.1",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-aspect-ratio": "^1.1.7",
@@ -91,7 +92,6 @@
"lucide-react": "^0.511.0",
"maplibre-gl": "^5.1.1",
"marked": "^15.0.12",
"next-themes": "^0.4.6",
"pixi.js": "^8.14.1",
"react-day-picker": "8.10.1",
"react-force-graph-2d": "^1.27.1",
@@ -111,6 +111,7 @@
"usehooks-ts": "^3.1.1",
"uuid": "^13.0.0",
"vaul": "^1.1.2",
"yaml": "^2.8.2",
"zod": "^3.25.42",
"zustand": "^5.0.3"
},
@@ -129,6 +130,7 @@
"eslint-plugin-react": "^7.34.3",
"graphology-types": "^0.24.8",
"husky": "^9.1.7",
"monaco-editor": "^0.55.1",
"postcss": "^8.4.35",
"prettier": "^3.3.2",
"react": "^19.2.0",

View File

@@ -7,6 +7,18 @@ export const enricherService = {
method: 'GET'
})
},
getTemplates: async (): Promise<any> => {
const url = '/api/enrichers/templates'
return fetchWithAuth(url, {
method: 'GET'
})
},
getTemplateById: async (templateId: string): Promise<any> => {
const url = `/api/enrichers/templates/${templateId}`
return fetchWithAuth(url, {
method: 'GET'
})
},
launch: async (enricherName: string, body: BodyInit): Promise<any> => {
return fetchWithAuth(`/api/enrichers/${enricherName}/launch`, {
method: 'POST',

View File

@@ -0,0 +1,88 @@
import { fetchWithAuth } from './api'
import type { TemplateData } from '@/components/templates/template-schema'
export interface Template {
id: string
name: string
category: string
version: number
content: TemplateData
is_public: boolean
owner_id: string
created_at: string
updated_at: string
description: string
}
export interface CreateTemplatePayload {
name: string
category: string
version?: number
content: TemplateData
is_public?: boolean
}
export interface UpdateTemplatePayload {
name?: string
category?: string
version?: number
content?: TemplateData
is_public?: boolean
}
export interface TestTemplateResponse {
success: boolean
data?: Record<string, unknown>
error?: string
duration_ms: number
status_code?: number
url: string
}
export const templateService = {
getAll: async (): Promise<Template[]> => {
return fetchWithAuth('/api/enrichers/templates', {
method: 'GET'
})
},
getById: async (templateId: string): Promise<Template> => {
return fetchWithAuth(`/api/enrichers/templates/${templateId}`, {
method: 'GET'
})
},
create: async (payload: CreateTemplatePayload): Promise<Template> => {
return fetchWithAuth('/api/enrichers/templates', {
method: 'POST',
body: JSON.stringify(payload)
})
},
update: async (templateId: string, payload: UpdateTemplatePayload): Promise<Template> => {
return fetchWithAuth(`/api/enrichers/templates/${templateId}`, {
method: 'PUT',
body: JSON.stringify(payload)
})
},
delete: async (templateId: string): Promise<void> => {
return fetchWithAuth(`/api/enrichers/templates/${templateId}`, {
method: 'DELETE'
})
},
test: async (templateId: string, inputValue: string): Promise<TestTemplateResponse> => {
return fetchWithAuth(`/api/enrichers/templates/${templateId}/test`, {
method: 'POST',
body: JSON.stringify({ input_value: inputValue })
})
},
testContent: async (inputValue: string, content: TemplateData): Promise<TestTemplateResponse> => {
return fetchWithAuth('/api/enrichers/templates/test', {
method: 'POST',
body: JSON.stringify({ input_value: inputValue, content })
})
}
}

View File

@@ -254,7 +254,7 @@ const FlowEditor = memo(({ initialEdges, initialNodes, theme, flow }: FlowEditor
})
const newNode: FlowNode = {
id: `${enricherData.name}-${Date.now()}`,
type: enricherData.type === 'type' ? 'type' : 'enricher',
type: enricherData.type === 'type' ? 'type' : 'request',
position,
data: {
id: enricherData.id,

View File

@@ -61,7 +61,7 @@ const FlowSheet = ({ onLayout }: { onLayout: () => void }) => {
const position = { x: selectedNode.position.x + 350, y: selectedNode.position.y }
const newNode: FlowNode = {
id: `${enricher.name}-${Date.now()}`,
type: enricher.type === 'type' ? 'type' : 'enricher',
type: enricher.type === 'type' ? 'type' : 'request',
position,
data: {
id: enricher.id,
@@ -169,80 +169,73 @@ function areEqual(prevProps: { enricher: Enricher }, nextProps: { enricher: Enri
}
// Memoized enricher item component for the sidebar
const EnricherItem = memo(
({ enricher, onClick }: { enricher: Enricher; onClick: () => void }) => {
const colors = useNodesDisplaySettings((s) => s.colors)
const borderInputColor = colors[enricher.inputs.type.toLowerCase()]
const borderOutputColor = colors[enricher.outputs.type.toLowerCase()]
const Icon =
enricher.type === 'type'
? useIcon(enricher.outputs.type.toLowerCase() as string)
: enricher.icon
? useIcon(enricher.icon)
: null
const EnricherItem = memo(({ enricher, onClick }: { enricher: Enricher; onClick: () => void }) => {
const colors = useNodesDisplaySettings((s) => s.colors)
const borderInputColor = colors[enricher.inputs.type.toLowerCase()]
const borderOutputColor = colors[enricher.outputs.type.toLowerCase()]
const Icon =
enricher.type === 'type'
? useIcon(enricher.outputs.type.toLowerCase() as string)
: enricher.icon
? useIcon(enricher.icon)
: null
return (
<TooltipProvider>
<button
onClick={onClick}
className="p-3 rounded-md relative w-full overflow-hidden cursor-grab bg-card border ring-2 ring-transparent hover:ring-primary transition-all group"
style={{
borderLeftWidth: '5px',
borderRightWidth: '5px',
borderLeftColor: borderInputColor ?? borderOutputColor,
borderRightColor: borderOutputColor,
cursor: 'grab'
}}
>
<div className="flex justify-between grow items-start">
<div className="flex items-start gap-2 grow truncate text-ellipsis">
<div className="space-y-1 truncate text-left">
<div className="flex items-center gap-2 truncate text-ellipsis">
{Icon && <Icon size={24} />}
<h3 className="text-sm font-medium truncate text-ellipsis">
{enricher.class_name}
</h3>
return (
<TooltipProvider>
<button
onClick={onClick}
className="p-3 rounded-md relative w-full overflow-hidden cursor-grab bg-card border ring-2 ring-transparent hover:ring-primary transition-all group"
style={{
borderLeftWidth: '5px',
borderRightWidth: '5px',
borderLeftColor: borderInputColor ?? borderOutputColor,
borderRightColor: borderOutputColor,
cursor: 'grab'
}}
>
<div className="flex justify-between grow items-start">
<div className="flex items-start gap-2 grow truncate text-ellipsis">
<div className="space-y-1 truncate text-left">
<div className="flex items-center gap-2 truncate text-ellipsis">
{Icon && <Icon size={24} />}
<h3 className="text-sm font-medium truncate text-ellipsis">
{enricher.class_name}
</h3>
</div>
<p className="text-sm font-normal opacity-60 truncate text-ellipsis">
{enricher.description}
</p>
<div className="mt-2 flex items-center gap-2 text-xs">
<div className="flex items-center gap-1">
<span className="text-muted-foreground">Takes</span>
<span className="font-bold truncate text-ellipsis">{enricher.inputs.type}</span>
</div>
<p className="text-sm font-normal opacity-60 truncate text-ellipsis">
{enricher.description}
</p>
<div className="mt-2 flex items-center gap-2 text-xs">
<div className="flex items-center gap-1">
<span className="text-muted-foreground">Takes</span>
<span className="font-bold truncate text-ellipsis">
{enricher.inputs.type}
</span>
</div>
<span>
<ArrowRight className="h-3 w-3" />
</span>
<div className="flex items-center gap-1">
<span className="text-muted-foreground">Returns</span>
<span className="font-bold truncate text-ellipsis">
{enricher.outputs.type}
</span>
</div>
<span>
<ArrowRight className="h-3 w-3" />
</span>
<div className="flex items-center gap-1">
<span className="text-muted-foreground">Returns</span>
<span className="font-bold truncate text-ellipsis">{enricher.outputs.type}</span>
</div>
</div>
</div>
</div>
{enricher.required_params && (
<div className="absolute bottom-3 right-3">
<Tooltip>
<TooltipTrigger asChild>
<TriangleAlert className="h-4 w-4 text-yellow-500" />
</TooltipTrigger>
<TooltipContent>
<p>API key required</p>
</TooltipContent>
</Tooltip>
</div>
)}
</button>
</TooltipProvider>
)
},
areEqual
)
</div>
{enricher.required_params && (
<div className="absolute bottom-3 right-3">
<Tooltip>
<TooltipTrigger asChild>
<TriangleAlert className="h-4 w-4 text-yellow-500" />
</TooltipTrigger>
<TooltipContent>
<p>API key required</p>
</TooltipContent>
</Tooltip>
</div>
)}
</button>
</TooltipProvider>
)
}, areEqual)
EnricherItem.displayName = 'EnricherItem'

View File

@@ -1,4 +1,4 @@
import { Home, Lock, type LucideIcon, PanelLeft, Workflow, Shapes } from 'lucide-react'
import { Home, Lock, type LucideIcon, PanelLeft, Workflow, Shapes, Puzzle } from 'lucide-react'
import { Link } from '@tanstack/react-router'
import { useLayoutStore } from '@/stores/layout-store'
import { Button } from '../ui/button'
@@ -18,6 +18,7 @@ export const Sidebar = memo(() => {
const navItems: NavItem[] = [
{ icon: Home, label: 'Dashboard', href: '/dashboard/' },
{ icon: Puzzle, label: 'Enrichers', href: '/dashboard/enrichers' },
{ icon: Workflow, label: 'Flows', href: '/dashboard/flows' },
{ icon: Shapes, label: 'Custom types', href: '/dashboard/custom-types' },
{ icon: Lock, label: 'Vault', href: '/dashboard/vault' }

View File

@@ -0,0 +1,5 @@
export { YamlEditor } from './yaml-editor'
export { JsonViewer } from './json-viewer'
export { TemplateEditor } from './template-editor'
export { templateSchema, defaultTemplate } from './template-schema'
export type { TemplateData, TemplateInput, TemplateHttpRequest, TemplateHttpResponse, TemplateOutput } from './template-schema'

View File

@@ -0,0 +1,75 @@
import { useEffect, useState } from 'react'
import MonacoEditor from '@monaco-editor/react'
import { useTheme } from '@/components/theme-provider'
interface JsonViewerProps {
data: unknown
height?: string | number
className?: string
}
function useResolvedTheme() {
const { theme } = useTheme()
const [resolvedTheme, setResolvedTheme] = useState<'dark' | 'light'>('dark')
useEffect(() => {
if (theme === 'system') {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
setResolvedTheme(isDark ? 'dark' : 'light')
} else {
setResolvedTheme(theme as 'dark' | 'light')
}
}, [theme])
return resolvedTheme
}
export function JsonViewer({ data, height = '100%', className }: JsonViewerProps) {
const resolvedTheme = useResolvedTheme()
const jsonString = JSON.stringify(data, null, 2)
return (
<div className={className} style={{ height }}>
<MonacoEditor
width="100%"
height="100%"
language="json"
theme={resolvedTheme === 'dark' ? 'vs-dark' : 'light'}
value={jsonString}
options={{
readOnly: true,
minimap: { enabled: false },
fontSize: 12,
lineNumbers: 'on',
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: 2,
wordWrap: 'on',
folding: true,
renderLineHighlight: 'none',
selectOnLineNumbers: false,
roundedSelection: true,
cursorStyle: 'line',
smoothScrolling: true,
contextmenu: true,
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
padding: { top: 12, bottom: 12 },
scrollbar: {
vertical: 'auto',
horizontal: 'auto',
verticalScrollbarSize: 8,
horizontalScrollbarSize: 8
},
domReadOnly: true,
lineDecorationsWidth: 8,
lineNumbersMinChars: 3
}}
loading={
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
Loading...
</div>
}
/>
</div>
)
}

View File

@@ -0,0 +1,718 @@
import { useState, useCallback, useMemo, useEffect, useRef } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
import { toast } from 'sonner'
import {
Save,
Copy,
Check,
AlertCircle,
Trash2,
ChevronRight,
Play,
FileCode2,
FlaskConical,
Loader2,
CheckCircle2,
XCircle,
Info,
Keyboard
} from 'lucide-react'
import type { editor } from 'monaco-editor'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { useConfirm } from '@/components/use-confirm-dialog'
import { YamlEditor } from './yaml-editor'
import { JsonViewer } from './json-viewer'
import { defaultTemplate, templateSchema, type TemplateData } from './template-schema'
import { templateService } from '@/api/template-service'
interface TemplateEditorProps {
/** Template ID for edit mode. If not provided, component is in create mode. */
templateId?: string
/** Initial content for edit mode. Uses defaultTemplate for create mode. */
initialContent?: TemplateData
/** Raw YAML string to use as initial content (e.g., from imported file). Takes precedence over initialContent. */
importedYaml?: string
}
interface TestResult {
success: boolean
data?: unknown
error?: string
duration?: number
url?: string
}
function validateTemplate(content: string): {
valid: boolean
errors: string[]
data?: TemplateData
} {
const errors: string[] = []
let parsed: TemplateData
try {
parsed = parseYaml(content)
} catch (e) {
return { valid: false, errors: [`YAML syntax error: ${(e as Error).message}`] }
}
if (!parsed || typeof parsed !== 'object') {
return { valid: false, errors: ['Template must be a valid YAML object'] }
}
const required = templateSchema.required as string[]
for (const field of required) {
if (!(field in parsed)) {
errors.push(`Missing required field: ${field}`)
}
}
if (parsed.input && !parsed.input.type) {
errors.push('input.type is required')
}
if (parsed.request) {
if (!parsed.request.method) {
errors.push('request.method is required')
} else if (!['GET', 'POST'].includes(parsed.request.method)) {
errors.push('request.method must be GET or POST')
}
if (!parsed.request.url) {
errors.push('request.url is required')
}
}
if (parsed.output && !parsed.output.type) {
errors.push('output.type is required')
}
if (parsed.response) {
if (!parsed.response.expect) {
errors.push('response.expect is required')
} else if (!['json', 'xml', 'text'].includes(parsed.response.expect)) {
errors.push('response.expect must be json, xml, or text')
}
}
return { valid: errors.length === 0, errors, data: parsed }
}
export function TemplateEditor({ templateId, initialContent, importedYaml }: TemplateEditorProps) {
const navigate = useNavigate()
const queryClient = useQueryClient()
const { confirm } = useConfirm()
const isEditMode = !!templateId
const initialYaml = useMemo(
() => importedYaml ?? (initialContent ? stringifyYaml(initialContent) : defaultTemplate),
[initialContent, importedYaml]
)
const [content, setContent] = useState(initialYaml)
const [savedContent, setSavedContent] = useState(initialYaml)
const [copied, setCopied] = useState(false)
const [editorErrors, setEditorErrors] = useState<editor.IMarkerData[]>([])
const [validationResult, setValidationResult] = useState(() => validateTemplate(initialYaml))
const [activeTab, setActiveTab] = useState<'editor' | 'test'>('editor')
// Test state
const [testInput, setTestInput] = useState('')
const [testParams, setTestParams] = useState<Record<string, string>>({})
const [testResult, setTestResult] = useState<TestResult | null>(null)
const [isTesting, setIsTesting] = useState(false)
// Get params from template, replacing {{key}} placeholders with empty string for display
const templateParams = validationResult.data?.request?.params || {}
const paramKeys = Object.keys(templateParams)
// Build the full URL with query params for preview
const buildPreviewUrl = () => {
if (!validationResult.data?.request?.url) return ''
const inputKey = validationResult.data.input?.key || 'value'
let url = validationResult.data.request.url.replace(
new RegExp(`\\{\\{${inputKey}\\}\\}`, 'g'),
testInput || `{${inputKey}}`
)
if (paramKeys.length > 0) {
const paramsObj: Record<string, string> = {}
for (const key of paramKeys) {
const templateValue = templateParams[key] ?? ''
// Replace {{key}} in param value with test input
const resolvedValue = templateValue.replace(
new RegExp(`\\{\\{${inputKey}\\}\\}`, 'g'),
testInput || `{${inputKey}}`
)
// Use custom test value if provided, otherwise use resolved template value
paramsObj[key] = testParams[key] || resolvedValue
}
const searchParams = new URLSearchParams(paramsObj)
url += (url.includes('?') ? '&' : '?') + searchParams.toString()
}
return url
}
const hasChanges = content !== savedContent
const hasErrors = !validationResult.valid || editorErrors.some((e) => e.severity >= 8)
// Derive template name from content
const templateName = validationResult.data?.name || 'Untitled'
// Ref to always have latest state for keyboard shortcut
const stateRef = useRef({ hasErrors, hasChanges, data: validationResult.data, content })
stateRef.current = { hasErrors, hasChanges, data: validationResult.data, content }
// Ref to track content being saved
const contentBeingSavedRef = useRef<string | null>(null)
// Create mutation (for new templates)
const createMutation = useMutation({
mutationFn: (data: TemplateData) =>
templateService.create({
name: data.name,
category: data.category,
version: data.version,
content: data
}),
onSuccess: (data) => {
toast.success('Template created successfully')
queryClient.invalidateQueries({ queryKey: ['template', 'enrichers'] })
navigate({ to: `/dashboard/enrichers/${data.id}` as string })
},
onError: (error) => {
toast.error(`Failed to create: ${error.message}`)
}
})
// Update mutation (for existing templates)
const updateMutation = useMutation({
mutationFn: (data: TemplateData) => templateService.update(templateId!, { content: data }),
onSuccess: () => {
toast.success('Template saved successfully')
if (contentBeingSavedRef.current) {
setSavedContent(contentBeingSavedRef.current)
contentBeingSavedRef.current = null
}
queryClient.invalidateQueries({ queryKey: ['template', 'enrichers'] })
queryClient.invalidateQueries({ queryKey: ['template', templateId] })
},
onError: (error) => {
contentBeingSavedRef.current = null
toast.error(`Failed to save: ${error.message}`)
}
})
// Delete mutation (for existing templates)
const deleteMutation = useMutation({
mutationFn: () => templateService.delete(templateId!),
onSuccess: () => {
toast.success('Template deleted')
queryClient.invalidateQueries({ queryKey: ['template', 'enrichers'] })
navigate({ to: '/dashboard/enrichers' })
},
onError: (error) => {
toast.error(`Failed to delete: ${error.message}`)
}
})
const handleChange = useCallback((value: string) => {
setContent(value)
setValidationResult(validateTemplate(value))
}, [])
const handleEditorValidate = useCallback((errors: editor.IMarkerData[]) => {
setEditorErrors(errors)
}, [])
const handleSave = useCallback(() => {
const { hasErrors, hasChanges, data, content } = stateRef.current
if (hasErrors || !data) {
toast.error('Please fix validation errors before saving')
return
}
if (isEditMode) {
if (!hasChanges) return
contentBeingSavedRef.current = content
updateMutation.mutate(data)
} else {
createMutation.mutate(data)
}
}, [isEditMode, updateMutation, createMutation])
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault()
handleSave()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleSave])
const handleDelete = useCallback(async () => {
const confirmed = await confirm({
title: 'Delete template?',
message: 'This action cannot be undone. This will permanently delete the template.'
})
if (confirmed) {
deleteMutation.mutate()
}
}, [confirm, deleteMutation])
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(content)
setCopied(true)
toast.success('Copied to clipboard')
setTimeout(() => setCopied(false), 2000)
} catch {
toast.error('Failed to copy')
}
}, [content])
const handleTest = useCallback(async () => {
if (!testInput.trim()) {
toast.error('Please enter a test value')
return
}
if (!validationResult.data) {
toast.error('Please fix validation errors before testing')
return
}
setIsTesting(true)
setTestResult(null)
try {
// Use different API based on mode
const response = isEditMode
? await templateService.test(templateId!, testInput.trim())
: await templateService.testContent(testInput.trim(), validationResult.data)
setTestResult({
success: response.success,
data: response.data,
error: response.error,
duration: response.duration_ms,
url: response.url
})
} catch (error) {
setTestResult({
success: false,
error: (error as Error).message,
duration: 0
})
} finally {
setIsTesting(false)
}
}, [isEditMode, templateId, testInput, validationResult.data])
const isSaving = createMutation.isPending || updateMutation.isPending
return (
<TooltipProvider>
<div className="h-full flex flex-col bg-background">
{/* Header */}
<header className="flex items-center justify-between px-4 py-3 border-b bg-card/50">
<div className="flex items-center gap-2 text-sm">
<button
onClick={() => navigate({ to: '/dashboard/enrichers' })}
className="text-muted-foreground hover:text-foreground transition-colors"
>
Templates
</button>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{isEditMode ? templateName : 'New Template'}</span>
{/* Status Badge */}
{isEditMode ? (
hasChanges ? (
<Badge
variant="outline"
className="ml-2 text-xs bg-amber-500/10 text-amber-600 border-amber-500/20"
>
Unsaved
</Badge>
) : (
!hasErrors && (
<Badge
variant="outline"
className="ml-2 text-xs bg-emerald-500/10 text-emerald-600 border-emerald-500/20"
>
<CheckCircle2 className="h-3 w-3 mr-1" />
Saved
</Badge>
)
)
) : (
<Badge
variant="outline"
className="ml-2 text-xs bg-blue-500/10 text-blue-600 border-blue-500/20"
>
Draft
</Badge>
)}
</div>
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="sm" onClick={handleCopy} className="h-8 px-2">
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>Copy YAML</TooltipContent>
</Tooltip>
<div className="w-px h-6 bg-border" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="default"
size="sm"
onClick={handleSave}
disabled={(isEditMode && !hasChanges) || hasErrors || isSaving}
className="h-8 gap-1.5"
>
{isSaving && <Loader2 className="h-4 w-4 animate-spin" />}
{isEditMode ? 'Save' : 'Create template'}
</Button>
</TooltipTrigger>
<TooltipContent>
<div className="flex items-center gap-2">
<Keyboard className="h-3 w-3" />
<span>S</span>
</div>
</TooltipContent>
</Tooltip>
{isEditMode && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={handleDelete}
disabled={deleteMutation.isPending}
className="h-8 px-2 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete template</TooltipContent>
</Tooltip>
)}
</div>
</header>
<div className="border-b bg-card/30">
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as 'editor' | 'test')}
className="px-4"
>
<TabsList className="h-10 bg-transparent p-0 gap-4">
<TabsTrigger value="editor">
<FileCode2 className="h-4 w-4 opacity-60" strokeWidth={1.5} />
Editor
</TabsTrigger>
<TabsTrigger value="test">
<FlaskConical className="h-4 w-4 opacity-60" strokeWidth={1.5} />
Test
</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div className="flex-1 flex overflow-hidden">
<Tabs value={activeTab} className="flex-1 flex">
<TabsContent value="editor" className="flex-1 flex m-0 data-[state=inactive]:hidden">
<div className="flex-1 min-w-0 min-h-0 overflow-hidden">
<YamlEditor
value={content}
onChange={handleChange}
onValidate={handleEditorValidate}
/>
</div>
<div className="w-72 border-l bg-card/30 flex flex-col">
<div className="p-4 border-b">
<div className="flex items-center gap-2">
{hasErrors ? (
<>
<div className="p-1.5 rounded-full bg-destructive/10">
<XCircle className="h-4 w-4 text-destructive" />
</div>
<div>
<p className="text-sm font-medium text-destructive">Validation failed</p>
<p className="text-xs text-muted-foreground">
{validationResult.errors.length +
editorErrors.filter((e) => e.severity >= 8).length}{' '}
error(s)
</p>
</div>
</>
) : (
<>
<div className="p-1.5 rounded-full bg-emerald-500/10">
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
</div>
<div>
<p className="text-sm font-medium text-emerald-600">Valid template</p>
<p className="text-xs text-muted-foreground">
{isEditMode ? 'Ready to save' : 'Ready to create'}
</p>
</div>
</>
)}
</div>
</div>
<ScrollArea className="flex-1">
<div className="p-4 space-y-3">
{validationResult.errors.map((error, i) => (
<div key={i} className="flex gap-2 text-sm">
<AlertCircle className="h-4 w-4 text-destructive shrink-0 mt-0.5" />
<span className="text-destructive">{error}</span>
</div>
))}
{editorErrors
.filter((e) => e.severity >= 8)
.map((error, i) => (
<div key={`editor-${i}`} className="flex gap-2 text-sm">
<AlertCircle className="h-4 w-4 text-destructive shrink-0 mt-0.5" />
<span className="text-destructive">
Line {error.startLineNumber}: {error.message}
</span>
</div>
))}
</div>
<div className="p-4">
<div className="flex items-center gap-2 mb-3">
<Info className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Schema reference</span>
</div>
<div className="text-xs text-muted-foreground space-y-2">
<div className="space-y-1">
<p className="font-medium text-foreground">Required:</p>
<ul className="list-disc list-inside space-y-0.5 ml-1">
<li>name, category, version</li>
<li>input.type</li>
<li>request.method, request.url</li>
<li>output.type</li>
<li>response.expect</li>
</ul>
</div>
<div className="space-y-1">
<p className="font-medium text-foreground">Optional:</p>
<ul className="list-disc list-inside space-y-0.5 ml-1">
<li>description (root level)</li>
<li>input.key (default: nodeLabel)</li>
<li>request.headers, request.params, request.body</li>
<li>response.map</li>
</ul>
</div>
<div className="pt-2">
<p className="font-medium text-foreground">URL Variables:</p>
<code className="text-xs bg-muted px-1 py-0.5 rounded">{'{{key}}'}</code>
</div>
</div>
</div>
</ScrollArea>
</div>
</TabsContent>
{/* Test Tab */}
<TabsContent value="test" className="flex-1 m-0 data-[state=inactive]:hidden">
<div className="h-full flex">
{/* Test Input */}
<div className="w-80 border-r p-6 flex flex-col gap-6">
<div>
<h3 className="text-lg font-semibold mb-1">Test your template</h3>
<p className="text-sm text-muted-foreground">
Enter a test value to simulate the enricher request.
</p>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="test-input">
Input Value
{validationResult.data?.input?.key && (
<span className="text-muted-foreground ml-1">
({validationResult.data.input.key})
</span>
)}
</Label>
<Input
id="test-input"
placeholder={`Enter ${validationResult.data?.input?.type || 'value'}...`}
value={testInput}
onChange={(e) => setTestInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleTest()}
/>
</div>
{paramKeys.length > 0 && (
<div className="space-y-2">
<Label className="text-muted-foreground">Query Parameters</Label>
<div className="border rounded-md overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b">
<th className="text-left px-3 py-1.5 text-xs font-medium text-muted-foreground w-1/3">
Key
</th>
<th className="text-left px-3 py-1.5 text-xs font-medium text-muted-foreground">
Value
</th>
</tr>
</thead>
<tbody>
{paramKeys.map((paramKey, idx) => {
const defaultValue = templateParams[paramKey] ?? ''
return (
<tr
key={paramKey}
className={idx < paramKeys.length - 1 ? 'border-b' : ''}
>
<td className="px-3 py-1.5 font-mono text-xs text-muted-foreground">
{paramKey}
</td>
<td className="px-1 py-1">
<Input
placeholder={defaultValue || 'Value'}
value={testParams[paramKey] ?? ''}
onChange={(e) =>
setTestParams((prev) => ({
...prev,
[paramKey]: e.target.value
}))
}
className="h-7 text-xs border-0 bg-transparent focus-visible:ring-1 focus-visible:ring-offset-0"
/>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)}
{validationResult.data?.request?.url && (
<div className="space-y-2">
<Label className="text-muted-foreground">Request URL</Label>
<code className="block text-xs bg-muted p-2 rounded break-all">
{buildPreviewUrl()}
</code>
</div>
)}
<Button
onClick={handleTest}
disabled={isTesting || hasErrors || !testInput.trim()}
className="w-full gap-2"
>
{isTesting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Testing...
</>
) : (
<>
<Play className="h-4 w-4" />
Run test
</>
)}
</Button>
</div>
{hasErrors && (
<div className="p-3 rounded-lg bg-destructive/10 border border-destructive/20">
<p className="text-sm text-destructive">
Fix validation errors before testing.
</p>
</div>
)}
</div>
{/* Test Result */}
<div className="flex-1 p-6 bg-muted/20">
<div className="h-full flex flex-col">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">Response</h3>
{testResult?.duration !== undefined && testResult.duration > 0 && (
<Badge variant="secondary" className="text-xs">
{testResult.duration}ms
</Badge>
)}
</div>
{!testResult ? (
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-muted-foreground">
<FlaskConical className="h-12 w-12 mx-auto mb-4 opacity-20" />
<p className="text-sm">Run a test to see the response here.</p>
</div>
</div>
) : (
<div className="flex-1 flex flex-col gap-4">
<div
className={`p-3 rounded-lg border ${
testResult.success
? 'bg-emerald-500/10 border-emerald-500/20'
: 'bg-destructive/10 border-destructive/20'
}`}
>
<div className="flex items-center gap-2">
{testResult.success ? (
<>
<CheckCircle2 className="h-5 w-5 text-emerald-500" />
<span className="font-medium text-emerald-600">
Request Successful
</span>
</>
) : (
<>
<XCircle className="h-5 w-5 text-destructive" />
<span className="font-medium text-destructive">Request Failed</span>
</>
)}
</div>
{testResult.error && (
<p className="text-sm text-destructive mt-1">{testResult.error}</p>
)}
</div>
{testResult.data && (
<div className="flex-1 min-h-0 flex flex-col">
<Label className="mb-2 block">Response Data</Label>
<div className="flex-1 min-h-[300px] rounded-md border overflow-hidden">
<JsonViewer data={testResult.data} />
</div>
</div>
)}
</div>
)}
</div>
</div>
</div>
</TabsContent>
</Tabs>
</div>
</div>
</TooltipProvider>
)
}

View File

@@ -0,0 +1,242 @@
// JSON Schema for template YAML validation - matches flowsint_core/templates/types.py
export const templateSchema = {
type: 'object',
required: ['name', 'category', 'version', 'input', 'request', 'output', 'response'],
additionalProperties: false,
properties: {
name: {
type: 'string',
minLength: 1,
description: 'Name of the template'
},
description: {
type: 'string',
description: 'Description of the template'
},
category: {
type: 'string',
minLength: 1,
description: 'Category of the template'
},
version: {
type: 'number',
description: 'Version of the template'
},
input: {
type: 'object',
required: ['type'],
additionalProperties: false,
properties: {
type: {
type: 'string',
description: 'Flowsint Type the template takes as input'
},
key: {
type: 'string',
default: 'nodeLabel',
description: 'Key to use for input mapping'
}
}
},
secrets: {
type: 'array',
items: {
type: 'object',
required: ['name'],
additionalProperties: false,
properties: {
name: {
type: 'string',
minLength: 1,
maxLength: 128,
description: 'Name of the secret (used as {{secrets.NAME}} in template)'
},
required: {
type: 'boolean',
default: true,
description: 'Whether this secret is required for the template'
},
description: {
type: 'string',
description: 'Description of what this secret is used for'
}
}
},
default: [],
description: 'List of secrets required by this template (fetched from vault)'
},
request: {
type: 'object',
required: ['method', 'url'],
additionalProperties: false,
properties: {
method: {
type: 'string',
enum: ['GET', 'POST'],
description: 'HTTP method'
},
url: {
type: 'string',
description: 'URL template with {{key}} placeholders'
},
headers: {
type: 'object',
additionalProperties: { type: 'string' },
default: {},
description: 'HTTP headers'
},
params: {
type: 'object',
additionalProperties: { type: 'string' },
default: {},
description: 'Query parameters'
},
body: {
type: ['string', 'null'],
description: 'Request body (for POST requests)'
},
timeout: {
type: 'number',
minimum: 1,
maximum: 300,
default: 30,
description: 'Request timeout in seconds'
}
}
},
output: {
type: 'object',
required: ['type'],
additionalProperties: false,
properties: {
type: {
type: 'string',
description: 'Flowsint Type that the template returns'
},
is_array: {
type: 'boolean',
default: false,
description: 'Whether the response is an array that should produce multiple outputs'
},
array_path: {
type: ['string', 'null'],
description: "Dot-notation path to array in response (e.g., 'data.results')"
}
}
},
response: {
type: 'object',
required: ['expect'],
additionalProperties: false,
properties: {
expect: {
type: 'string',
enum: ['json', 'xml', 'text'],
description: 'Expected response format'
},
map: {
type: 'object',
additionalProperties: { type: 'string' },
default: {},
description: 'Mapping from output type attributes to response keys'
}
}
},
retry: {
type: 'object',
additionalProperties: false,
properties: {
max_retries: {
type: 'integer',
minimum: 0,
maximum: 10,
default: 3,
description: 'Maximum number of retry attempts'
},
backoff_factor: {
type: 'number',
minimum: 0.1,
maximum: 10,
default: 0.5,
description: 'Multiplier for exponential backoff (seconds)'
},
retry_on_status: {
type: 'array',
items: { type: 'integer' },
default: [429, 500, 502, 503, 504],
description: 'HTTP status codes that should trigger a retry'
}
},
description: 'Retry configuration for failed requests'
}
}
}
export const defaultTemplate = `name: my-enricher
description: My custom enricher template
category: Domain
type: request
version: 1.0
input:
type: Domain
key: domain
request:
method: GET
url: https://api.example.com/lookup/{{domain}}
output:
type: Domain
response:
expect: json
map:
domain: domain
`
export interface TemplateInput {
type: string
key?: string
}
export interface TemplateSecret {
name: string
required?: boolean
description?: string
}
export interface TemplateHttpRequest {
method: 'GET' | 'POST'
url: string
headers?: Record<string, string>
params?: Record<string, string>
body?: string | null
timeout?: number
}
export interface TemplateHttpResponse {
expect: 'json' | 'xml' | 'text'
map?: Record<string, string>
}
export interface TemplateOutput {
type: string
is_array?: boolean
array_path?: string | null
}
export interface TemplateRetryConfig {
max_retries?: number
backoff_factor?: number
retry_on_status?: number[]
}
export interface TemplateData {
name: string
description?: string
category: string
version: number
input: TemplateInput
secrets?: TemplateSecret[]
request: TemplateHttpRequest
output: TemplateOutput
response: TemplateHttpResponse
retry?: TemplateRetryConfig
}

View File

@@ -0,0 +1,113 @@
import { useRef, useCallback, useEffect, useState } from 'react'
import MonacoEditor, { OnMount, OnChange } from '@monaco-editor/react'
import type { editor, Uri } from 'monaco-editor'
import { useTheme } from '@/components/theme-provider'
interface YamlEditorProps {
value: string
onChange: (value: string) => void
onValidate?: (errors: editor.IMarkerData[]) => void
readOnly?: boolean
}
function useResolvedTheme() {
const { theme } = useTheme()
const [resolvedTheme, setResolvedTheme] = useState<'dark' | 'light'>('dark')
useEffect(() => {
if (theme === 'system') {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
setResolvedTheme(isDark ? 'dark' : 'light')
} else {
setResolvedTheme(theme as 'dark' | 'light')
}
}, [theme])
return resolvedTheme
}
export function YamlEditor({ value, onChange, onValidate, readOnly = false }: YamlEditorProps) {
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null)
const resolvedTheme = useResolvedTheme()
const handleEditorMount: OnMount = useCallback(
(editor, monaco) => {
editorRef.current = editor
// Configure YAML diagnostics
monaco.languages.yaml?.yamlDefaults?.setDiagnosticsOptions({
validate: true,
enableSchemaRequest: false,
format: true
})
// Listen for marker changes (validation errors)
monaco.editor.onDidChangeMarkers((uris: readonly Uri[]) => {
const resource = uris[0]
if (resource && editor.getModel()?.uri.toString() === resource.toString()) {
const markers = monaco.editor.getModelMarkers({ resource })
onValidate?.(markers)
}
})
// Set initial markers check
setTimeout(() => {
const model = editor.getModel()
if (model) {
const markers = monaco.editor.getModelMarkers({ resource: model.uri })
onValidate?.(markers)
}
}, 500)
},
[onValidate]
)
const handleChange: OnChange = useCallback(
(value) => {
onChange(value || '')
},
[onChange]
)
return (
<div className="relative h-full w-full">
<div className="absolute inset-0">
<MonacoEditor
width="100%"
height="100%"
language="yaml"
theme={resolvedTheme === 'dark' ? 'vs-dark' : 'light'}
value={value}
onChange={handleChange}
onMount={handleEditorMount}
options={{
readOnly,
minimap: { enabled: true },
fontSize: 14,
lineNumbers: 'on',
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: 2,
insertSpaces: true,
wordWrap: 'on',
folding: true,
renderLineHighlight: 'line',
selectOnLineNumbers: true,
roundedSelection: true,
cursorStyle: 'line',
cursorBlinking: 'smooth',
smoothScrolling: true,
contextmenu: true,
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
padding: { top: 16, bottom: 16 }
}}
loading={
<div className="flex items-center justify-center h-full text-muted-foreground">
Loading editor...
</div>
}
/>
</div>
</div>
)
}

View File

@@ -18,7 +18,7 @@ function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimi
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
'bg-muted text-muted-foreground inline-flex h-10 w-fit items-center justify-center rounded-lg !p-1',
'bg-muted text-muted-foreground inline-flex h-10 w-fit items-center justify-center rounded-lg p-1!',
className
)}
{...props}

View File

@@ -19,8 +19,11 @@ import { Route as AuthDashboardIndexRouteImport } from './routes/_auth.dashboard
import { Route as AuthDashboardVaultRouteImport } from './routes/_auth.dashboard.vault'
import { Route as AuthDashboardToolsRouteImport } from './routes/_auth.dashboard.tools'
import { Route as AuthDashboardFlowsIndexRouteImport } from './routes/_auth.dashboard.flows.index'
import { Route as AuthDashboardEnrichersIndexRouteImport } from './routes/_auth.dashboard.enrichers.index'
import { Route as AuthDashboardCustomTypesIndexRouteImport } from './routes/_auth.dashboard.custom-types.index'
import { Route as AuthDashboardFlowsFlowIdRouteImport } from './routes/_auth.dashboard.flows.$flowId'
import { Route as AuthDashboardEnrichersNewRouteImport } from './routes/_auth.dashboard.enrichers.new'
import { Route as AuthDashboardEnrichersEnricherIdRouteImport } from './routes/_auth.dashboard.enrichers.$enricherId'
import { Route as AuthDashboardCustomTypesIdRouteImport } from './routes/_auth.dashboard.custom-types.$id'
import { Route as AuthDashboardInvestigationsInvestigationIdIndexRouteImport } from './routes/_auth.dashboard.investigations.$investigationId.index'
import { Route as AuthDashboardInvestigationsInvestigationIdTypeIdRouteImport } from './routes/_auth.dashboard.investigations.$investigationId.$type.$id'
@@ -74,6 +77,12 @@ const AuthDashboardFlowsIndexRoute = AuthDashboardFlowsIndexRouteImport.update({
path: '/flows/',
getParentRoute: () => AuthDashboardRoute,
} as any)
const AuthDashboardEnrichersIndexRoute =
AuthDashboardEnrichersIndexRouteImport.update({
id: '/enrichers/',
path: '/enrichers/',
getParentRoute: () => AuthDashboardRoute,
} as any)
const AuthDashboardCustomTypesIndexRoute =
AuthDashboardCustomTypesIndexRouteImport.update({
id: '/custom-types/',
@@ -86,6 +95,18 @@ const AuthDashboardFlowsFlowIdRoute =
path: '/flows/$flowId',
getParentRoute: () => AuthDashboardRoute,
} as any)
const AuthDashboardEnrichersNewRoute =
AuthDashboardEnrichersNewRouteImport.update({
id: '/enrichers/new',
path: '/enrichers/new',
getParentRoute: () => AuthDashboardRoute,
} as any)
const AuthDashboardEnrichersEnricherIdRoute =
AuthDashboardEnrichersEnricherIdRouteImport.update({
id: '/enrichers/$enricherId',
path: '/enrichers/$enricherId',
getParentRoute: () => AuthDashboardRoute,
} as any)
const AuthDashboardCustomTypesIdRoute =
AuthDashboardCustomTypesIdRouteImport.update({
id: '/custom-types/$id',
@@ -115,10 +136,13 @@ export interface FileRoutesByFullPath {
'/dashboard/vault': typeof AuthDashboardVaultRoute
'/dashboard/': typeof AuthDashboardIndexRoute
'/dashboard/custom-types/$id': typeof AuthDashboardCustomTypesIdRoute
'/dashboard/enrichers/$enricherId': typeof AuthDashboardEnrichersEnricherIdRoute
'/dashboard/enrichers/new': typeof AuthDashboardEnrichersNewRoute
'/dashboard/flows/$flowId': typeof AuthDashboardFlowsFlowIdRoute
'/dashboard/custom-types': typeof AuthDashboardCustomTypesIndexRoute
'/dashboard/flows': typeof AuthDashboardFlowsIndexRoute
'/dashboard/investigations/$investigationId': typeof AuthDashboardInvestigationsInvestigationIdIndexRoute
'/dashboard/custom-types/': typeof AuthDashboardCustomTypesIndexRoute
'/dashboard/enrichers/': typeof AuthDashboardEnrichersIndexRoute
'/dashboard/flows/': typeof AuthDashboardFlowsIndexRoute
'/dashboard/investigations/$investigationId/': typeof AuthDashboardInvestigationsInvestigationIdIndexRoute
'/dashboard/investigations/$investigationId/$type/$id': typeof AuthDashboardInvestigationsInvestigationIdTypeIdRoute
}
export interface FileRoutesByTo {
@@ -130,8 +154,11 @@ export interface FileRoutesByTo {
'/dashboard/vault': typeof AuthDashboardVaultRoute
'/dashboard': typeof AuthDashboardIndexRoute
'/dashboard/custom-types/$id': typeof AuthDashboardCustomTypesIdRoute
'/dashboard/enrichers/$enricherId': typeof AuthDashboardEnrichersEnricherIdRoute
'/dashboard/enrichers/new': typeof AuthDashboardEnrichersNewRoute
'/dashboard/flows/$flowId': typeof AuthDashboardFlowsFlowIdRoute
'/dashboard/custom-types': typeof AuthDashboardCustomTypesIndexRoute
'/dashboard/enrichers': typeof AuthDashboardEnrichersIndexRoute
'/dashboard/flows': typeof AuthDashboardFlowsIndexRoute
'/dashboard/investigations/$investigationId': typeof AuthDashboardInvestigationsInvestigationIdIndexRoute
'/dashboard/investigations/$investigationId/$type/$id': typeof AuthDashboardInvestigationsInvestigationIdTypeIdRoute
@@ -148,8 +175,11 @@ export interface FileRoutesById {
'/_auth/dashboard/vault': typeof AuthDashboardVaultRoute
'/_auth/dashboard/': typeof AuthDashboardIndexRoute
'/_auth/dashboard/custom-types/$id': typeof AuthDashboardCustomTypesIdRoute
'/_auth/dashboard/enrichers/$enricherId': typeof AuthDashboardEnrichersEnricherIdRoute
'/_auth/dashboard/enrichers/new': typeof AuthDashboardEnrichersNewRoute
'/_auth/dashboard/flows/$flowId': typeof AuthDashboardFlowsFlowIdRoute
'/_auth/dashboard/custom-types/': typeof AuthDashboardCustomTypesIndexRoute
'/_auth/dashboard/enrichers/': typeof AuthDashboardEnrichersIndexRoute
'/_auth/dashboard/flows/': typeof AuthDashboardFlowsIndexRoute
'/_auth/dashboard/investigations/$investigationId/': typeof AuthDashboardInvestigationsInvestigationIdIndexRoute
'/_auth/dashboard/investigations/$investigationId/$type/$id': typeof AuthDashboardInvestigationsInvestigationIdTypeIdRoute
@@ -166,10 +196,13 @@ export interface FileRouteTypes {
| '/dashboard/vault'
| '/dashboard/'
| '/dashboard/custom-types/$id'
| '/dashboard/enrichers/$enricherId'
| '/dashboard/enrichers/new'
| '/dashboard/flows/$flowId'
| '/dashboard/custom-types'
| '/dashboard/flows'
| '/dashboard/investigations/$investigationId'
| '/dashboard/custom-types/'
| '/dashboard/enrichers/'
| '/dashboard/flows/'
| '/dashboard/investigations/$investigationId/'
| '/dashboard/investigations/$investigationId/$type/$id'
fileRoutesByTo: FileRoutesByTo
to:
@@ -181,8 +214,11 @@ export interface FileRouteTypes {
| '/dashboard/vault'
| '/dashboard'
| '/dashboard/custom-types/$id'
| '/dashboard/enrichers/$enricherId'
| '/dashboard/enrichers/new'
| '/dashboard/flows/$flowId'
| '/dashboard/custom-types'
| '/dashboard/enrichers'
| '/dashboard/flows'
| '/dashboard/investigations/$investigationId'
| '/dashboard/investigations/$investigationId/$type/$id'
@@ -198,8 +234,11 @@ export interface FileRouteTypes {
| '/_auth/dashboard/vault'
| '/_auth/dashboard/'
| '/_auth/dashboard/custom-types/$id'
| '/_auth/dashboard/enrichers/$enricherId'
| '/_auth/dashboard/enrichers/new'
| '/_auth/dashboard/flows/$flowId'
| '/_auth/dashboard/custom-types/'
| '/_auth/dashboard/enrichers/'
| '/_auth/dashboard/flows/'
| '/_auth/dashboard/investigations/$investigationId/'
| '/_auth/dashboard/investigations/$investigationId/$type/$id'
@@ -239,7 +278,7 @@ declare module '@tanstack/react-router' {
'/_auth': {
id: '/_auth'
path: ''
fullPath: ''
fullPath: '/'
preLoaderRoute: typeof AuthRouteImport
parentRoute: typeof rootRouteImport
}
@@ -281,14 +320,21 @@ declare module '@tanstack/react-router' {
'/_auth/dashboard/flows/': {
id: '/_auth/dashboard/flows/'
path: '/flows'
fullPath: '/dashboard/flows'
fullPath: '/dashboard/flows/'
preLoaderRoute: typeof AuthDashboardFlowsIndexRouteImport
parentRoute: typeof AuthDashboardRoute
}
'/_auth/dashboard/enrichers/': {
id: '/_auth/dashboard/enrichers/'
path: '/enrichers'
fullPath: '/dashboard/enrichers/'
preLoaderRoute: typeof AuthDashboardEnrichersIndexRouteImport
parentRoute: typeof AuthDashboardRoute
}
'/_auth/dashboard/custom-types/': {
id: '/_auth/dashboard/custom-types/'
path: '/custom-types'
fullPath: '/dashboard/custom-types'
fullPath: '/dashboard/custom-types/'
preLoaderRoute: typeof AuthDashboardCustomTypesIndexRouteImport
parentRoute: typeof AuthDashboardRoute
}
@@ -299,6 +345,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthDashboardFlowsFlowIdRouteImport
parentRoute: typeof AuthDashboardRoute
}
'/_auth/dashboard/enrichers/new': {
id: '/_auth/dashboard/enrichers/new'
path: '/enrichers/new'
fullPath: '/dashboard/enrichers/new'
preLoaderRoute: typeof AuthDashboardEnrichersNewRouteImport
parentRoute: typeof AuthDashboardRoute
}
'/_auth/dashboard/enrichers/$enricherId': {
id: '/_auth/dashboard/enrichers/$enricherId'
path: '/enrichers/$enricherId'
fullPath: '/dashboard/enrichers/$enricherId'
preLoaderRoute: typeof AuthDashboardEnrichersEnricherIdRouteImport
parentRoute: typeof AuthDashboardRoute
}
'/_auth/dashboard/custom-types/$id': {
id: '/_auth/dashboard/custom-types/$id'
path: '/custom-types/$id'
@@ -309,7 +369,7 @@ declare module '@tanstack/react-router' {
'/_auth/dashboard/investigations/$investigationId/': {
id: '/_auth/dashboard/investigations/$investigationId/'
path: '/investigations/$investigationId'
fullPath: '/dashboard/investigations/$investigationId'
fullPath: '/dashboard/investigations/$investigationId/'
preLoaderRoute: typeof AuthDashboardInvestigationsInvestigationIdIndexRouteImport
parentRoute: typeof AuthDashboardRoute
}
@@ -328,8 +388,11 @@ interface AuthDashboardRouteChildren {
AuthDashboardVaultRoute: typeof AuthDashboardVaultRoute
AuthDashboardIndexRoute: typeof AuthDashboardIndexRoute
AuthDashboardCustomTypesIdRoute: typeof AuthDashboardCustomTypesIdRoute
AuthDashboardEnrichersEnricherIdRoute: typeof AuthDashboardEnrichersEnricherIdRoute
AuthDashboardEnrichersNewRoute: typeof AuthDashboardEnrichersNewRoute
AuthDashboardFlowsFlowIdRoute: typeof AuthDashboardFlowsFlowIdRoute
AuthDashboardCustomTypesIndexRoute: typeof AuthDashboardCustomTypesIndexRoute
AuthDashboardEnrichersIndexRoute: typeof AuthDashboardEnrichersIndexRoute
AuthDashboardFlowsIndexRoute: typeof AuthDashboardFlowsIndexRoute
AuthDashboardInvestigationsInvestigationIdIndexRoute: typeof AuthDashboardInvestigationsInvestigationIdIndexRoute
AuthDashboardInvestigationsInvestigationIdTypeIdRoute: typeof AuthDashboardInvestigationsInvestigationIdTypeIdRoute
@@ -340,8 +403,11 @@ const AuthDashboardRouteChildren: AuthDashboardRouteChildren = {
AuthDashboardVaultRoute: AuthDashboardVaultRoute,
AuthDashboardIndexRoute: AuthDashboardIndexRoute,
AuthDashboardCustomTypesIdRoute: AuthDashboardCustomTypesIdRoute,
AuthDashboardEnrichersEnricherIdRoute: AuthDashboardEnrichersEnricherIdRoute,
AuthDashboardEnrichersNewRoute: AuthDashboardEnrichersNewRoute,
AuthDashboardFlowsFlowIdRoute: AuthDashboardFlowsFlowIdRoute,
AuthDashboardCustomTypesIndexRoute: AuthDashboardCustomTypesIndexRoute,
AuthDashboardEnrichersIndexRoute: AuthDashboardEnrichersIndexRoute,
AuthDashboardFlowsIndexRoute: AuthDashboardFlowsIndexRoute,
AuthDashboardInvestigationsInvestigationIdIndexRoute:
AuthDashboardInvestigationsInvestigationIdIndexRoute,

View File

@@ -0,0 +1,39 @@
import { createFileRoute } from '@tanstack/react-router'
import Loader from '@/components/loader'
import { templateService } from '@/api/template-service'
import { TemplateEditor } from '@/components/templates/template-editor'
export const Route = createFileRoute('/_auth/dashboard/enrichers/$enricherId')({
loader: async ({ params: { enricherId } }) => {
const template = await templateService.getById(enricherId)
return { template }
},
component: TemplatePage,
pendingComponent: () => (
<div className="h-full w-full flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<Loader />
<p className="text-muted-foreground">Loading template...</p>
</div>
</div>
),
errorComponent: ({ error }) => (
<div className="h-full w-full flex items-center justify-center">
<div className="text-center">
<h2 className="text-lg font-semibold text-destructive mb-2">Error loading template</h2>
<p className="text-muted-foreground">{error.message}</p>
</div>
</div>
)
})
function TemplatePage() {
const { template } = Route.useLoaderData()
return (
<TemplateEditor
key={template.id}
templateId={template.id}
initialContent={template.content}
/>
)
}

View File

@@ -0,0 +1,221 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { PlusIcon, FileCode2, Clock, FileX, Upload, FlaskConical, X } from 'lucide-react'
import { useNavigate } from '@tanstack/react-router'
import { toast } from 'sonner'
import { SkeletonList } from '@/components/shared/skeleton-list'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { formatDistanceToNow } from 'date-fns'
import { Badge } from '@/components/ui/badge'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import ErrorState from '@/components/shared/error-state'
import { PageLayout } from '@/components/layout/page-layout'
import { templateService, type Template } from '@/api/template-service'
export const Route = createFileRoute('/_auth/dashboard/enrichers/')({
component: TemplatesPage
})
function TemplatesPage() {
const navigate = useNavigate()
const fileInputRef = useRef<HTMLInputElement>(null)
const [isBannerDismissed, setIsBannerDismissed] = useState(false)
const dismissBanner = () => {
setIsBannerDismissed(true)
}
const {
data: templates,
isLoading,
error,
refetch
} = useQuery<Template[]>({
queryKey: ['template', 'enrichers'],
queryFn: () => templateService.getAll()
})
const handleImportFile = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
if (!file.name.endsWith('.yaml') && !file.name.endsWith('.yml')) {
toast.error('Only YAML files (.yaml, .yml) are supported')
if (fileInputRef.current) fileInputRef.current.value = ''
return
}
const reader = new FileReader()
reader.onload = (e) => {
const content = e.target?.result as string
navigate({
to: '/dashboard/enrichers/new' as string,
state: { importedContent: content }
})
}
reader.onerror = () => {
toast.error('Failed to read file')
}
reader.readAsText(file)
if (fileInputRef.current) fileInputRef.current.value = ''
}
// Get all unique categories
const categories =
templates?.reduce((acc: string[], template) => {
if (template.category && !acc.includes(template.category)) {
acc.push(template.category)
}
return acc
}, []) || []
// Add "All" to categories
const allCategories = ['All', ...categories]
return (
<PageLayout
title="Enricher Templates"
description="Create and manage your enricher templates."
isLoading={isLoading}
loadingComponent={
<div className="p-2">
<SkeletonList rowCount={6} mode="card" />
</div>
}
error={error}
errorComponent={
<ErrorState
title="Couldn't load templates"
description="Something went wrong while fetching data. Please try again."
error={error}
onRetry={() => refetch()}
/>
}
actions={
<div className="flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
accept=".yaml,.yml"
onChange={handleImportFile}
className="hidden"
/>
<Button size="sm" variant="outline" onClick={() => fileInputRef.current?.click()}>
<Upload className="w-4 h-4 mr-2" />
Import
</Button>
<Button size="sm" onClick={() => navigate({ to: '/dashboard/enrichers/new' as string })}>
<PlusIcon className="w-4 h-4 mr-2" />
New template
</Button>
</div>
}
>
{!isBannerDismissed && (
<div className="mb-6 flex items-center gap-3 rounded-lg border border-primary/30 bg-primary/10 px-4 py-3">
<FlaskConical className="h-4 w-4 shrink-0 text-primary" />
<p className="flex-1 text-sm text-primary">
Template enrichers are currently in <strong>beta</strong>. Feel free to{' '}
<a
href="https://github.com/reconurge/flowsint/issues"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-2"
>
raise an issue
</a>{' '}
if you encounter any problems.
</p>
<button
onClick={dismissBanner}
className="shrink-0 rounded p-1 text-primary hover:bg-primary/20 transition-colors"
aria-label="Dismiss"
>
<X className="h-4 w-4" />
</button>
</div>
)}
<div style={{ containerType: 'inline-size' }}>
{!templates?.length ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="rounded-full bg-muted/50 p-4 mb-4">
<FileX className="w-8 h-8 text-muted-foreground" />
</div>
<h3 className="text-xl font-semibold mb-2">No templates yet</h3>
<p className="text-muted-foreground mb-6 max-w-md">
Get started by creating your first enricher template. Templates allow you to define
custom enrichers using YAML configuration.
</p>
<Button onClick={() => navigate({ to: '/dashboard/enrichers/new' as string })}>
<PlusIcon className="w-4 h-4 mr-2" />
Create your first template
</Button>
</div>
) : (
<Tabs defaultValue="All" className="space-y-6">
<TabsList className="w-full justify-start h-auto p-1 bg-muted/50 overflow-x-auto hide-scrollbar">
{allCategories.map((category) => (
<TabsTrigger
key={category}
value={category}
className="data-[state=active]:bg-background"
>
{category}
</TabsTrigger>
))}
</TabsList>
{allCategories.map((category) => (
<TabsContent key={category} value={category} className="mt-0">
<div className="grid grid-cols-1 cq-sm:grid-cols-2 cq-md:grid-cols-3 cq-lg:grid-cols-4 cq-xl:grid-cols-5 gap-6">
{templates
?.filter((template) =>
category === 'All' ? true : template.category === category
)
.map((template) => (
<Card
key={template.id}
className="group hover:border-primary/50 hover:shadow-md transition-all cursor-pointer"
onClick={() => navigate({ to: `/dashboard/enrichers/${template.id}` })}
>
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<CardTitle className="text-lg font-medium group-hover:text-primary transition-colors">
{template.name || '(Unnamed template)'}
</CardTitle>
<Badge variant="outline">
<FileCode2 className="w-4 h-4 text-muted-foreground" />v
{template.version}
</Badge>
</div>
<CardDescription className="line-clamp-2 mt-1">
{template.description}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="flex items-center text-sm text-muted-foreground">
<Clock className="w-4 h-4 mr-1" />
{formatDistanceToNow(
new Date(template.updated_at || template.created_at),
{ addSuffix: true }
)}
</div>
<Badge variant="secondary">{template.category}</Badge>
</div>
</CardContent>
</Card>
))}
</div>
</TabsContent>
))}
</Tabs>
)}
</div>
</PageLayout>
)
}

View File

@@ -0,0 +1,13 @@
import { createFileRoute, useRouterState } from '@tanstack/react-router'
import { TemplateEditor } from '@/components/templates/template-editor'
export const Route = createFileRoute('/_auth/dashboard/enrichers/new')({
component: NewTemplatePage
})
function NewTemplatePage() {
const routerState = useRouterState()
const importedContent = (routerState.location.state as { importedContent?: string })?.importedContent
return <TemplateEditor importedYaml={importedContent} />
}