mirror of
https://github.com/reconurge/flowsint.git
synced 2026-04-28 10:22:58 -05:00
feat(app): enrichers from templates
This commit is contained in:
@@ -1,4 +1,12 @@
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: {
|
||||
jsx: true
|
||||
}
|
||||
},
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:react/recommended',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
88
flowsint-app/src/api/template-service.ts
Normal file
88
flowsint-app/src/api/template-service.ts
Normal 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 })
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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' }
|
||||
|
||||
5
flowsint-app/src/components/templates/index.ts
Normal file
5
flowsint-app/src/components/templates/index.ts
Normal 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'
|
||||
75
flowsint-app/src/components/templates/json-viewer.tsx
Normal file
75
flowsint-app/src/components/templates/json-viewer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
718
flowsint-app/src/components/templates/template-editor.tsx
Normal file
718
flowsint-app/src/components/templates/template-editor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
242
flowsint-app/src/components/templates/template-schema.ts
Normal file
242
flowsint-app/src/components/templates/template-schema.ts
Normal 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
|
||||
}
|
||||
113
flowsint-app/src/components/templates/yaml-editor.tsx
Normal file
113
flowsint-app/src/components/templates/yaml-editor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
221
flowsint-app/src/routes/_auth.dashboard.enrichers.index.tsx
Normal file
221
flowsint-app/src/routes/_auth.dashboard.enrichers.index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
13
flowsint-app/src/routes/_auth.dashboard.enrichers.new.tsx
Normal file
13
flowsint-app/src/routes/_auth.dashboard.enrichers.new.tsx
Normal 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} />
|
||||
}
|
||||
Reference in New Issue
Block a user