diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml index 0953e4d..bdeff25 100644 --- a/docker-compose.e2e.yml +++ b/docker-compose.e2e.yml @@ -6,5 +6,64 @@ services: workflow-orchestrator-service: ports: - "8005:8005" # Expose for debugging if needed + volumes: + - workflow_data:/mnt/workflow_data + environment: + - WORKFLOW_DATA_PATH=/mnt/workflow_data + alphavantage-provider-service: + volumes: + - workflow_data:/mnt/workflow_data + environment: + - WORKFLOW_DATA_PATH=/mnt/workflow_data + tushare-provider-service: + volumes: + - workflow_data:/mnt/workflow_data + environment: + - WORKFLOW_DATA_PATH=/mnt/workflow_data + + finnhub-provider-service: + volumes: + - workflow_data:/mnt/workflow_data + environment: + - WORKFLOW_DATA_PATH=/mnt/workflow_data + + yfinance-provider-service: + volumes: + - workflow_data:/mnt/workflow_data + environment: + - WORKFLOW_DATA_PATH=/mnt/workflow_data + + report-generator-service: + volumes: + - workflow_data:/mnt/workflow_data + environment: + - WORKFLOW_DATA_PATH=/mnt/workflow_data + + mock-provider-service: + build: + context: . + dockerfile: services/mock-provider-service/Dockerfile + container_name: mock-provider-service + environment: + SERVER_PORT: 8006 + NATS_ADDR: nats://nats:4222 + API_GATEWAY_URL: http://api-gateway:4000 + SERVICE_HOST: mock-provider-service + WORKFLOW_DATA_PATH: /mnt/workflow_data + RUST_LOG: info + volumes: + - workflow_data:/mnt/workflow_data + depends_on: + - nats + networks: + - app-network + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://localhost:8006/health >/dev/null || exit 1"] + interval: 5s + timeout: 5s + retries: 12 + +volumes: + workflow_data: diff --git a/docker-compose.yml b/docker-compose.yml index 451ebb6..c7fb353 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -124,11 +124,14 @@ services: context: . dockerfile: services/alphavantage-provider-service/Dockerfile container_name: alphavantage-provider-service + volumes: + - workflow_data:/mnt/workflow_data environment: SERVER_PORT: 8000 NATS_ADDR: nats://nats:4222 DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000/api/v1 API_GATEWAY_URL: http://api-gateway:4000 + WORKFLOW_DATA_PATH: /mnt/workflow_data SERVICE_HOST: alphavantage-provider-service RUST_LOG: info,axum=info RUST_BACKTRACE: "1" @@ -148,12 +151,15 @@ services: context: . dockerfile: services/tushare-provider-service/Dockerfile container_name: tushare-provider-service + volumes: + - workflow_data:/mnt/workflow_data environment: SERVER_PORT: 8001 NATS_ADDR: nats://nats:4222 DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000/api/v1 TUSHARE_API_URL: http://api.waditu.com API_GATEWAY_URL: http://api-gateway:4000 + WORKFLOW_DATA_PATH: /mnt/workflow_data SERVICE_HOST: tushare-provider-service RUST_LOG: info,axum=info RUST_BACKTRACE: "1" @@ -173,12 +179,15 @@ services: context: . dockerfile: services/finnhub-provider-service/Dockerfile container_name: finnhub-provider-service + volumes: + - workflow_data:/mnt/workflow_data environment: SERVER_PORT: 8002 NATS_ADDR: nats://nats:4222 DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000/api/v1 FINNHUB_API_URL: https://finnhub.io/api/v1 API_GATEWAY_URL: http://api-gateway:4000 + WORKFLOW_DATA_PATH: /mnt/workflow_data SERVICE_HOST: finnhub-provider-service RUST_LOG: info,axum=info RUST_BACKTRACE: "1" @@ -198,11 +207,14 @@ services: context: . dockerfile: services/yfinance-provider-service/Dockerfile container_name: yfinance-provider-service + volumes: + - workflow_data:/mnt/workflow_data environment: SERVER_PORT: 8003 NATS_ADDR: nats://nats:4222 DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000/api/v1 API_GATEWAY_URL: http://api-gateway:4000 + WORKFLOW_DATA_PATH: /mnt/workflow_data SERVICE_HOST: yfinance-provider-service RUST_LOG: info,axum=info RUST_BACKTRACE: "1" @@ -225,10 +237,13 @@ services: context: . dockerfile: services/report-generator-service/Dockerfile container_name: report-generator-service + volumes: + - workflow_data:/mnt/workflow_data environment: SERVER_PORT: 8004 NATS_ADDR: nats://nats:4222 DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000/api/v1 + WORKFLOW_DATA_PATH: /mnt/workflow_data RUST_LOG: info,axum=info RUST_BACKTRACE: "1" depends_on: @@ -247,10 +262,13 @@ services: context: . dockerfile: services/workflow-orchestrator-service/Dockerfile container_name: workflow-orchestrator-service + volumes: + - workflow_data:/mnt/workflow_data environment: SERVER_PORT: 8005 NATS_ADDR: nats://nats:4222 DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000/api/v1 + WORKFLOW_DATA_PATH: /mnt/workflow_data RUST_LOG: info RUST_BACKTRACE: "1" depends_on: @@ -269,6 +287,7 @@ services: # ================================================================= volumes: + workflow_data: pgdata: frontend_node_modules: nats_data: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6d7d502..a9816b2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-navigation-menu": "^1.2.14", "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", @@ -1746,6 +1747,68 @@ } } }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.8", + "resolved": "http://npm.repo.lan/@radix-ui/react-progress/-/react-progress-1.1.8.tgz", + "integrity": "sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.3", + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-context": { + "version": "1.1.3", + "resolved": "http://npm.repo.lan/@radix-ui/react-context/-/react-context-1.1.3.tgz", + "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "http://npm.repo.lan/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-roving-focus": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4dc5ca5..4edaba1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,13 +8,14 @@ "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", - "gen:api": "openapi-zod-client ../openapi.json -o src/api/schema.gen.ts --export-schemas --export-types && sed -i 's/^type /export type /' src/api/schema.gen.ts" + "gen:api": "openapi-zod-client ../openapi.json -o src/api/schema.gen.ts --export-schemas --export-types && sed -i 's/^type /export type /' src/api/schema.gen.ts && sed -i 's/^const /export const /' src/api/schema.gen.ts && sed -i 's/: z.ZodType<[^>]*>//g' src/api/schema.gen.ts" }, "dependencies": { "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-navigation-menu": "^1.2.14", "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", diff --git a/frontend/src/api/schema.gen.ts b/frontend/src/api/schema.gen.ts index 71ba35e..d66a43e 100644 --- a/frontend/src/api/schema.gen.ts +++ b/frontend/src/api/schema.gen.ts @@ -135,10 +135,19 @@ export type WorkflowEvent = }; type: "TaskStreamUpdate"; } + | { + payload: { + level: string; + message: string; + task_id: string; + timestamp: number; + }; + type: "TaskLog"; + } | { payload: { end_timestamp: number; - result_summary: unknown; + result_summary?: unknown | undefined; }; type: "WorkflowCompleted"; } @@ -160,61 +169,61 @@ export type WorkflowEvent = type: "WorkflowStateSnapshot"; }; -const AnalysisModuleConfig: z.ZodType = z.object({ +export const AnalysisModuleConfig = z.object({ dependencies: z.array(z.string()), model_id: z.string(), name: z.string(), prompt_template: z.string(), provider_id: z.string(), }); -const AnalysisTemplateSet: z.ZodType = z.object({ +export const AnalysisTemplateSet = z.object({ modules: z.record(AnalysisModuleConfig), name: z.string(), }); -const AnalysisTemplateSets: z.ZodType = +export const AnalysisTemplateSets = z.record(AnalysisTemplateSet); -const DataSourceProvider = z.enum([ +export const DataSourceProvider = z.enum([ "Tushare", "Finnhub", "Alphavantage", "Yfinance", ]); -const DataSourceConfig: z.ZodType = z.object({ +export const DataSourceConfig = z.object({ api_key: z.union([z.string(), z.null()]).optional(), api_url: z.union([z.string(), z.null()]).optional(), enabled: z.boolean(), provider: DataSourceProvider, }); -const DataSourcesConfig: z.ZodType = +export const DataSourcesConfig = z.record(DataSourceConfig); -const TestLlmConfigRequest = z.object({ +export const TestLlmConfigRequest = z.object({ api_base_url: z.string(), api_key: z.string(), model_id: z.string(), }); -const LlmModel: z.ZodType = z.object({ +export const LlmModel = z.object({ is_active: z.boolean(), model_id: z.string(), name: z.union([z.string(), z.null()]).optional(), }); -const LlmProvider: z.ZodType = z.object({ +export const LlmProvider = z.object({ api_base_url: z.string(), api_key: z.string(), models: z.array(LlmModel), name: z.string(), }); -const LlmProvidersConfig: z.ZodType = z.record(LlmProvider); -const TestConfigRequest = z.object({ data: z.unknown(), type: z.string() }); -const TestConnectionResponse = z.object({ +export const LlmProvidersConfig = z.record(LlmProvider); +export const TestConfigRequest = z.object({ data: z.unknown(), type: z.string() }); +export const TestConnectionResponse = z.object({ message: z.string(), success: z.boolean(), }); -const DiscoverPreviewRequest = z.object({ +export const DiscoverPreviewRequest = z.object({ api_base_url: z.string(), api_key: z.string(), }); -const FieldType = z.enum(["Text", "Password", "Url", "Boolean", "Select"]); -const ConfigKey = z.enum([ +export const FieldType = z.enum(["Text", "Password", "Url", "Boolean", "Select"]); +export const ConfigKey = z.enum([ "ApiKey", "ApiToken", "ApiUrl", @@ -225,7 +234,7 @@ const ConfigKey = z.enum([ "SandboxMode", "Region", ]); -const ConfigFieldSchema: z.ZodType = z.object({ +export const ConfigFieldSchema = z.object({ default_value: z.union([z.string(), z.null()]).optional(), description: z.union([z.string(), z.null()]).optional(), field_type: FieldType, @@ -235,7 +244,7 @@ const ConfigFieldSchema: z.ZodType = z.object({ placeholder: z.union([z.string(), z.null()]).optional(), required: z.boolean(), }); -const ProviderMetadata: z.ZodType = z.object({ +export const ProviderMetadata = z.object({ config_schema: z.array(ConfigFieldSchema), description: z.string(), icon_url: z.union([z.string(), z.null()]).optional(), @@ -244,31 +253,31 @@ const ProviderMetadata: z.ZodType = z.object({ name_en: z.string(), supports_test_connection: z.boolean(), }); -const SymbolResolveRequest = z.object({ +export const SymbolResolveRequest = z.object({ market: z.union([z.string(), z.null()]).optional(), symbol: z.string(), }); -const SymbolResolveResponse = z.object({ +export const SymbolResolveResponse = z.object({ market: z.string(), symbol: z.string(), }); -const DataRequest = z.object({ +export const DataRequest = z.object({ market: z.union([z.string(), z.null()]).optional(), symbol: z.string(), template_id: z.string(), }); -const RequestAcceptedResponse = z.object({ +export const RequestAcceptedResponse = z.object({ market: z.string(), request_id: z.string().uuid(), symbol: z.string(), }); -const ObservabilityTaskStatus = z.enum([ +export const ObservabilityTaskStatus = z.enum([ "Queued", "InProgress", "Completed", "Failed", ]); -const TaskProgress: z.ZodType = z.object({ +export const TaskProgress = z.object({ details: z.string(), progress_percent: z.number().int().gte(0), request_id: z.string().uuid(), @@ -276,25 +285,25 @@ const TaskProgress: z.ZodType = z.object({ status: ObservabilityTaskStatus, task_name: z.string(), }); -const CanonicalSymbol = z.string(); -const ServiceStatus = z.enum(["Ok", "Degraded", "Unhealthy"]); -const HealthStatus: z.ZodType = z.object({ +export const CanonicalSymbol = z.string(); +export const ServiceStatus = z.enum(["Ok", "Degraded", "Unhealthy"]); +export const HealthStatus = z.object({ details: z.record(z.string()), module_id: z.string(), status: ServiceStatus, version: z.string(), }); -const StartWorkflowCommand: z.ZodType = z.object({ +export const StartWorkflowCommand = z.object({ market: z.string(), request_id: z.string().uuid(), symbol: CanonicalSymbol, template_id: z.string(), }); -const TaskDependency: z.ZodType = z.object({ +export const TaskDependency = z.object({ from: z.string(), to: z.string(), }); -const TaskStatus = z.enum([ +export const TaskStatus = z.enum([ "Pending", "Scheduled", "Running", @@ -302,18 +311,18 @@ const TaskStatus = z.enum([ "Failed", "Skipped", ]); -const TaskType = z.enum(["DataFetch", "DataProcessing", "Analysis"]); -const TaskNode: z.ZodType = z.object({ +export const TaskType = z.enum(["DataFetch", "DataProcessing", "Analysis"]); +export const TaskNode = z.object({ id: z.string(), initial_status: TaskStatus, name: z.string(), type: TaskType, }); -const WorkflowDag: z.ZodType = z.object({ +export const WorkflowDag = z.object({ edges: z.array(TaskDependency), nodes: z.array(TaskNode), }); -const WorkflowEvent: z.ZodType = z.union([ +export const WorkflowEvent = z.union([ z .object({ payload: z @@ -349,12 +358,25 @@ const WorkflowEvent: z.ZodType = z.union([ type: z.literal("TaskStreamUpdate"), }) .passthrough(), + z + .object({ + payload: z + .object({ + level: z.string(), + message: z.string(), + task_id: z.string(), + timestamp: z.number().int(), + }) + .passthrough(), + type: z.literal("TaskLog"), + }) + .passthrough(), z .object({ payload: z .object({ end_timestamp: z.number().int(), - result_summary: z.unknown(), + result_summary: z.unknown().optional(), }) .passthrough(), type: z.literal("WorkflowCompleted"), @@ -423,7 +445,7 @@ export const schemas = { WorkflowEvent, }; -const endpoints = makeApi([ +export const endpoints = makeApi([ { method: "get", path: "/api/v1/configs/analysis_template_sets", diff --git a/frontend/src/components/RealtimeLogs.tsx b/frontend/src/components/RealtimeLogs.tsx new file mode 100644 index 0000000..58ca3d8 --- /dev/null +++ b/frontend/src/components/RealtimeLogs.tsx @@ -0,0 +1,75 @@ +import { useState } from 'react'; +import { Terminal, ChevronUp, ChevronDown } from 'lucide-react'; +import { Card } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { useAutoScroll } from '@/hooks/useAutoScroll'; +import { cn } from "@/lib/utils"; + +interface LogEntry { + taskId: string; + log: string; +} + +interface RealtimeLogsProps { + logs: LogEntry[]; + className?: string; +} + +export function RealtimeLogs({ logs, className }: RealtimeLogsProps) { + const [isExpanded, setIsExpanded] = useState(false); + const logsViewportRef = useAutoScroll(logs.length); + + const toggleExpand = () => { + setIsExpanded(!isExpanded); + }; + + return ( + +
+
+ + Real-time Logs + + {/* Preview last log when collapsed */} + {!isExpanded && logs.length > 0 && ( +
+ [{logs[logs.length - 1].taskId}] + {logs[logs.length - 1].log} +
+ )} + {!isExpanded && logs.length === 0 && ( + Waiting for logs... + )} +
+ + +
+ + {/* Expanded Content */} +
+
+
+ {logs.length === 0 && Waiting for logs...} + {logs.map((entry, i) => ( +
+ [{entry.taskId}] + {entry.log} +
+ ))} +
+
+
+
+ ); +} + diff --git a/frontend/src/components/ui/progress.tsx b/frontend/src/components/ui/progress.tsx new file mode 100644 index 0000000..b8b0c23 --- /dev/null +++ b/frontend/src/components/ui/progress.tsx @@ -0,0 +1,26 @@ +import * as React from "react" +import * as ProgressPrimitive from "@radix-ui/react-progress" + +import { cn } from "@/lib/utils" + +const Progress = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, value, ...props }, ref) => ( + + + +)) +Progress.displayName = ProgressPrimitive.Root.displayName + +export { Progress } diff --git a/frontend/src/components/ui/toaster.tsx b/frontend/src/components/ui/toaster.tsx index 61c72d9..2da80e1 100644 --- a/frontend/src/components/ui/toaster.tsx +++ b/frontend/src/components/ui/toaster.tsx @@ -5,7 +5,7 @@ export function Toaster() { const { toasts, dismiss } = useToast() return ( -
+
{toasts.map(function ({ id, title, description, type }) { return ( = { [schemas.TaskStatus.enum.Pending]: 'border-muted bg-card', [schemas.TaskStatus.enum.Scheduled]: 'border-yellow-500/50 bg-yellow-50/10', - [schemas.TaskStatus.enum.Running]: 'border-blue-500 ring-2 ring-blue-500/20 bg-blue-50/10', + [schemas.TaskStatus.enum.Running]: 'border-blue-500 ring-2 ring-blue-500/20 bg-blue-50/10 animate-pulse', [schemas.TaskStatus.enum.Completed]: 'border-green-500 bg-green-50/10', [schemas.TaskStatus.enum.Failed]: 'border-red-500 bg-red-50/10', [schemas.TaskStatus.enum.Skipped]: 'border-gray-200 bg-gray-50/5 opacity-60', @@ -182,7 +182,7 @@ export function WorkflowVisualizer() { if (!dag) return
Waiting for workflow to start...
; return ( -
+
(null); + const shouldAutoScrollRef = useRef(true); + + const handleScroll = () => { + const viewport = viewportRef.current; + if (!viewport) return; + + const { scrollTop, scrollHeight, clientHeight } = viewport; + // If user is near bottom (within 50px), enable auto-scroll + const isNearBottom = scrollHeight - scrollTop - clientHeight < 50; + shouldAutoScrollRef.current = isNearBottom; + }; + + useEffect(() => { + const viewport = viewportRef.current; + if (viewport) { + // Initial check + const { scrollTop, scrollHeight, clientHeight } = viewport; + shouldAutoScrollRef.current = scrollHeight - scrollTop - clientHeight < 50; + + viewport.addEventListener('scroll', handleScroll); + return () => viewport.removeEventListener('scroll', handleScroll); + } + }, []); + + useEffect(() => { + if (shouldAutoScrollRef.current && viewportRef.current) { + const viewport = viewportRef.current; + viewport.scrollTop = viewport.scrollHeight; + } + }, [dependency]); + + return viewportRef; +} + diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index ca22769..52682ec 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -6,12 +6,15 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" -import { BarChart3, Search, Sparkles, Loader2 } from "lucide-react" -import { useAnalysisTemplates } from "@/hooks/useConfig" +import { BarChart3, Search, Sparkles, Loader2, AlertCircle } from "lucide-react" +import { useAnalysisTemplates, useLlmProviders } from "@/hooks/useConfig" import { client } from '@/api/client'; -import { type DataRequest as DataRequestDTO } from '@/api/schema.gen'; +import { DataRequest } from '@/api/schema.gen'; +import { z } from 'zod'; import { useToast } from "@/hooks/use-toast" +type DataRequestDTO = z.infer; + export function Dashboard() { const navigate = useNavigate(); const { toast } = useToast(); @@ -20,6 +23,9 @@ export function Dashboard() { const [templateId, setTemplateId] = useState(""); const { data: templates, isLoading: isTemplatesLoading } = useAnalysisTemplates(); + const { data: llmProviders } = useLlmProviders(); + + const [validationError, setValidationError] = useState(null); // Auto-select first template when loaded useEffect(() => { @@ -28,6 +34,36 @@ export function Dashboard() { } }, [templates, templateId]); + // Validate template against providers + useEffect(() => { + if (!templateId || !templates || !templates[templateId] || !llmProviders) { + setValidationError(null); + return; + } + + const selectedTemplate = templates[templateId]; + const missingConfigs: string[] = []; + + Object.values(selectedTemplate.modules).forEach(module => { + if (!llmProviders[module.provider_id]) { + missingConfigs.push(`Module '${module.name}': Provider '${module.provider_id}' not found`); + } else { + const provider = llmProviders[module.provider_id]; + const modelExists = provider.models.some(m => m.model_id === module.model_id); + if (!modelExists) { + missingConfigs.push(`Module '${module.name}': Model '${module.model_id}' not found in provider '${provider.name}'`); + } + } + }); + + if (missingConfigs.length > 0) { + setValidationError(missingConfigs.join("; ")); + } else { + setValidationError(null); + } + + }, [templateId, templates, llmProviders]); + const startWorkflowMutation = useMutation({ mutationFn: async (payload: DataRequestDTO) => { return await client.start_workflow(payload); @@ -133,13 +169,25 @@ export function Dashboard() {
+ {validationError && ( +
+ + + Configuration Error: The selected template has invalid configurations.
+ {validationError.split('; ').map((err, i) => ( + • {err} + ))} +
+
+ )} + )} -
@@ -336,13 +339,18 @@ function ProviderCard({ id, provider, onDelete, onUpdate }: { id: string, provid
setSearchQuery(e.target.value)} - onFocus={() => setIsSearchFocused(true)} + onFocus={handleInputFocus} className="pl-8 h-9 text-sm" /> - {searchQuery && ( + {isFetchingModels && ( +
+ +
+ )} + {searchQuery && !isFetchingModels && (