Loading toolkit...
;
+ if (!toolkit)
+ return (
+
+
navigate('/toolkits')}
+ className="text-muted-foreground hover:text-foreground flex items-center gap-1.5 text-sm transition-colors"
+ >
+ Back to Toolkits
+
+
+
+
+
+ Toolkit
+
+
+ {toolkit.name}
+
+ {toolkit.description && (
+
{toolkit.description}
+ )}
+
+ {toolkit.simulate && simulate mode }
+
+ ID: {toolkit.id}
+
+
+
+
+
setShowRequestAccess(true)}
+ className="bg-primary/10 border-primary/30 text-primary hover:bg-primary/20 inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm font-medium transition-colors"
+ >
+ Request Access
+
+ {id !== 'default' && (
+
setShowSettings(true)}
+ className="bg-muted border-border text-foreground hover:bg-muted/60 inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm transition-colors"
+ >
+ Settings
+
+ )}
+
+
+
+ {/* Pending requests */}
+ {pending.length > 0 && (
+
+
+
+
+ {pending.length} Pending Access Request{pending.length !== 1 ? 's' : ''}
+
+
+ {pending.map((req: any) => (
+
+
+
+ {req.type === 'grant'
+ ? 'credential access'
+ : 'permission change'}
+
+ {req.reason && (
+
+ {req.reason}
+
+ )}
+
+
navigate(`/approve/${toolkit.id}/${req.id}`)}
+ className="bg-primary text-background hover:bg-primary/80 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors"
+ >
+ Review
+
+
+ ))}
+
+ )}
+
+ {/* API Keys */}
+
+
+
+
+ API Keys ({keys.length})
+
+ {toolkit.disabled && (
+
+
+ Toolkit Suspended
+
+ )}
+
+
+ {toolkit.disabled ? (
+
setKillswitchConfirming((c) => !c)}
+ className="bg-primary/10 border-primary/40 text-primary hover:bg-primary/20 inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm font-medium transition-colors disabled:opacity-50"
+ >
+ Restore Access
+
+ ) : (
+
setKillswitchConfirming((c) => !c)}
+ className={`inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm transition-colors disabled:opacity-50 ${killswitchConfirming ? 'bg-danger/10 border-danger/40 text-danger' : 'bg-muted border-border text-muted-foreground hover:text-danger hover:border-danger/40 hover:bg-danger/5'}`}
+ >
+ Kill switch
+
+ )}
+ {!toolkit.disabled && (
+
setShowKeyCreate(true)}
+ className="bg-primary text-background hover:bg-primary/80 inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors"
+ >
+ Create Key
+
+ )}
+
+
+ {/* Kill switch confirmation row */}
+ {killswitchConfirming && (
+
+
+ {toolkit.disabled
+ ? 'Restore access to this toolkit?'
+ : 'Block all API access for this toolkit immediately?'}
+
+ {
+ killswitchMutation.mutate(!toolkit.disabled);
+ setKillswitchConfirming(false);
+ }}
+ disabled={killswitchMutation.isPending}
+ className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 ${toolkit.disabled ? 'bg-primary text-background hover:bg-primary/80' : 'bg-danger text-destructive-foreground hover:bg-danger/80'}`}
+ >
+ {toolkit.disabled ? 'Restore' : 'Kill Access'}
+
+ setKillswitchConfirming(false)}
+ className="bg-muted border-border text-muted-foreground hover:bg-muted/60 rounded-lg border px-3 py-1.5 text-xs transition-colors"
+ >
+ Cancel
+
+
+ )}
+
+ {newKey && (
+
setNewKey(null)}
+ title="New API Key Created"
+ />
+ )}
+ {showKeyCreate && (
+
+
Create API Key
+
setKeyName(e.target.value)}
+ placeholder="Key name (optional)"
+ aria-label="Key name"
+ className="bg-muted border-border text-foreground focus:border-primary w-full rounded-lg border px-3 py-2 text-sm focus:outline-hidden"
+ />
+
+
+ createKeyMutation.mutate({ name: keyName || null })
+ }
+ disabled={createKeyMutation.isPending}
+ className="bg-primary text-background hover:bg-primary/80 rounded-lg px-3 py-1.5 text-sm transition-colors disabled:opacity-50"
+ >
+ {createKeyMutation.isPending ? 'Generating...' : 'Generate'}
+
+ setShowKeyCreate(false)}
+ className="bg-muted border-border text-foreground hover:bg-muted/60 rounded-lg border px-3 py-1.5 text-sm transition-colors"
+ >
+ Cancel
+
+
+
+ )}
+ {keys.length === 0 && !showKeyCreate && !newKey && (
+
+ No keys yet. Create one to allow agents to use this toolkit.
+
+ )}
+ {keys.map((key: any) => (
+
+
+
+
+
+ {key.label || 'Unnamed Key'}
+
+ {key.prefix && (
+
+ {key.prefix}...
+
+ )}
+ {key.revoked_at && revoked }
+
+ {key.created_at && (
+
+ {new Date(key.created_at * 1000).toLocaleString()}
+
+ )}
+
+ {!key.revoked_at && (
+
revokeKeyMutation.mutate(key.id)}
+ message="Revoke this key?"
+ confirmLabel="Revoke"
+ >
+
+ Revoke
+
+
+ )}
+
+ ))}
+
+
+
+ {/* Credentials */}
+
+
+
+ Bound Credentials ({credentials.length})
+
+ navigate('/credentials')}
+ className="bg-muted border-border text-foreground hover:bg-muted/60 inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm transition-colors"
+ >
+ Manage Credentials
+
+
+
+ {credentials.length === 0 ? (
+
+ No credentials bound. Bind credentials to grant this toolkit API access.
+
+ ) : (
+ credentials.map((cred: any) => (
+
+
+
+
+ {cred.label}
+
+ {cred.api_id && (
+
+ {cred.api_id}
+
+ )}
+ {cred.permissions && (
+
+ {
+ cred.permissions.filter(
+ (r: any) =>
+ !r._comment?.includes('System safety'),
+ ).length
+ }{' '}
+ agent rule(s) + system safety
+
+ )}
+
+
+
+ setEditingPermForCred(
+ editingPermForCred === cred.credential_id
+ ? null
+ : cred.credential_id,
+ )
+ }
+ className="bg-muted border-border text-muted-foreground hover:text-foreground inline-flex items-center gap-1 rounded border px-2 py-1 text-xs transition-colors"
+ >
+ Permissions
+ {editingPermForCred === cred.credential_id ? (
+
+ ) : (
+
+ )}
+
+ {id !== 'default' && (
+
+ unbindMutation.mutate(cred.credential_id)
+ }
+ message="Unbind this credential?"
+ confirmLabel="Unbind"
+ >
+
+ Unbind
+
+
+ )}
+
+
+ {editingPermForCred === cred.credential_id && (
+
setEditingPermForCred(null)}
+ />
+ )}
+
+ ))
+ )}
+
+
+
+ {/* Settings Modal */}
+ {showSettings && (
+
+
setShowSettings(false)}
+ />
+
+
+
+ Toolkit Settings
+
+ setShowSettings(false)}
+ className="text-muted-foreground hover:text-foreground"
+ >
+
+
+
+
+
+
+ Name
+
+ setEditName(e.target.value)}
+ className="bg-background border-border text-foreground focus:border-primary w-full rounded-lg border px-3 py-2 focus:outline-hidden"
+ />
+
+
+
+ Description
+
+
+
+ updateMutation.mutate()}
+ disabled={updateMutation.isPending}
+ className="bg-primary text-background hover:bg-primary/80 flex-1 rounded-lg px-4 py-2 font-medium transition-colors disabled:opacity-50"
+ >
+ {updateMutation.isPending ? 'Saving...' : 'Save Changes'}
+
+ setShowSettings(false)}
+ className="bg-muted border-border text-foreground hover:bg-muted/60 rounded-lg border px-4 py-2 transition-colors"
+ >
+ Cancel
+
+
+
+
Danger Zone
+
deleteMutation.mutate()}
+ message="Permanently delete this toolkit?"
+ confirmLabel="Delete Forever"
+ >
+
+ Delete Toolkit
+
+
+
+
+
+
+ )}
+
+ {/* Request Access Dialog */}
+ {showRequestAccess && (
+
setShowRequestAccess(false)} />
+ )}
+
+ );
}
diff --git a/ui/src/pages/ToolkitsPage.tsx b/ui/src/pages/ToolkitsPage.tsx
index cbdc9974..1cfe8e81 100644
--- a/ui/src/pages/ToolkitsPage.tsx
+++ b/ui/src/pages/ToolkitsPage.tsx
@@ -1,152 +1,261 @@
-import React, { useState } from 'react'
-import { Link, useNavigate } from 'react-router-dom'
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
-import { api } from '../api/client'
-import { usePendingRequests } from '../hooks/usePendingRequests'
-import type { ToolkitCreate } from '../api/types'
-import { Plus, Wrench, AlertTriangle, Key, X, Ban } from 'lucide-react'
+import React, { useState } from 'react';
+import { Link, useNavigate } from 'react-router-dom';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { Plus, Wrench, AlertTriangle, Key, X, Ban } from 'lucide-react';
+import { api } from '@/api/client';
+import { usePendingRequests } from '@/hooks/usePendingRequests';
+import type { ToolkitCreate } from '@/api/types';
-function CreateModal({ onClose, onCreated }: { onClose: () => void; onCreated: (id: string) => void }) {
- const queryClient = useQueryClient()
- const [name, setName] = useState('')
- const [description, setDescription] = useState('')
- const [simulate, setSimulate] = useState(false)
- const [error, setError] = useState
(null)
+function CreateModal({
+ onClose,
+ onCreated,
+}: {
+ onClose: () => void;
+ onCreated: (id: string) => void;
+}) {
+ const queryClient = useQueryClient();
+ const [name, setName] = useState('');
+ const [description, setDescription] = useState('');
+ const [simulate, setSimulate] = useState(false);
+ const [error, setError] = useState(null);
- const mutation = useMutation({
- mutationFn: (data: ToolkitCreate) => api.createToolkit(data),
- onSuccess: (t) => { queryClient.invalidateQueries({ queryKey: ['toolkits'] }); onCreated(t.id) },
- onError: (e: Error) => setError(e.message),
- })
+ const mutation = useMutation({
+ mutationFn: (data: ToolkitCreate) => api.createToolkit(data),
+ onSuccess: (t) => {
+ queryClient.invalidateQueries({ queryKey: ['toolkits'] });
+ onCreated(t.id);
+ },
+ onError: (e: Error) => setError(e.message),
+ });
- return (
-
-
-
-
-
Create Toolkit
-
-
-
-
-
- )
+ return (
+
+
+
+
+
+ Create Toolkit
+
+
+
+
+
+
{
+ e.preventDefault();
+ setError(null);
+ mutation.mutate({ name, description: description || null, simulate });
+ }}
+ className="space-y-4"
+ >
+
+
+ Name *
+
+ setName(e.target.value)}
+ required
+ autoFocus
+ className="bg-background border-border text-foreground focus:border-primary w-full rounded-lg border px-3 py-2 focus:outline-hidden"
+ />
+
+
+
+ Description
+
+ setDescription(e.target.value)}
+ rows={2}
+ className="bg-background border-border text-foreground focus:border-primary w-full resize-none rounded-lg border px-3 py-2 focus:outline-hidden"
+ />
+
+
+ setSimulate(e.target.checked)}
+ className="rounded"
+ />
+
+
Simulate mode
+
+ Returns mock responses without calling real APIs
+
+
+
+ {error && {error}
}
+
+
+ {mutation.isPending ? 'Creating...' : 'Create Toolkit'}
+
+
+ Cancel
+
+
+
+
+
+ );
}
-interface ToolkitsPageProps { createNew?: boolean }
+interface ToolkitsPageProps {
+ createNew?: boolean;
+}
export default function ToolkitsPage({ createNew = false }: ToolkitsPageProps) {
- const navigate = useNavigate()
- const [showCreate, setShowCreate] = useState(createNew)
+ const navigate = useNavigate();
+ const [showCreate, setShowCreate] = useState(createNew);
- const { data: toolkits, isLoading } = useQuery({
- queryKey: ['toolkits'],
- queryFn: api.listToolkits,
- refetchInterval: 30000,
- })
+ const { data: toolkits, isLoading } = useQuery({
+ queryKey: ['toolkits'],
+ queryFn: api.listToolkits,
+ refetchInterval: 30000,
+ });
- // Pending requests: fetched globally, grouped by toolkit_id here
- const { data: pendingRequests } = usePendingRequests()
- const pendingByToolkit = (pendingRequests ?? []).reduce>((acc, req: any) => {
- if (req.toolkit_id) acc[req.toolkit_id] = (acc[req.toolkit_id] ?? 0) + 1
- return acc
- }, {})
+ // Pending requests: fetched globally, grouped by toolkit_id here
+ const { data: pendingRequests } = usePendingRequests();
+ const pendingByToolkit = (pendingRequests ?? []).reduce>(
+ (acc, req: any) => {
+ if (req.toolkit_id) acc[req.toolkit_id] = (acc[req.toolkit_id] ?? 0) + 1;
+ return acc;
+ },
+ {},
+ );
- return (
-
-
-
-
Management
-
Toolkits
-
-
setShowCreate(true)} className="inline-flex items-center gap-2 bg-primary text-background hover:bg-primary/80 font-medium rounded-lg px-4 py-2 transition-colors text-sm">
- Create Toolkit
-
-
+ return (
+
+
+
+
+ Management
+
+
+ Toolkits
+
+
+
setShowCreate(true)}
+ className="bg-primary text-background hover:bg-primary/80 inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors"
+ >
+ Create Toolkit
+
+
- {isLoading ? (
-
Loading toolkits...
- ) : !toolkits || toolkits.length === 0 ? (
-
-
-
No toolkits yet
-
Create a toolkit to give an agent scoped access to your APIs.
-
setShowCreate(true)} className="bg-primary text-background hover:bg-primary/80 font-medium rounded-lg px-4 py-2 transition-colors text-sm">
- Create your first toolkit
-
-
- ) : (
-
- {toolkits.map(toolkit => {
- const pendingCount = pendingByToolkit[toolkit.id] ?? 0
- return (
-
-
-
-
-
{toolkit.name}
- {toolkit.disabled && (
-
- SUSPENDED
-
- )}
- {pendingCount > 0 && (
-
- {pendingCount} pending
-
- )}
- {toolkit.simulate && (
-
simulate
- )}
-
- {toolkit.description &&
{toolkit.description}
}
-
-
-
-
- {toolkit.key_count ?? '—'} keys
- {toolkit.credential_count != null ? `${toolkit.credential_count} credentials` : (toolkit.credentials?.length != null ? `${toolkit.credentials.length} credentials` : '—')}
-
-
- )
- })}
-
- )}
+ {isLoading ? (
+
Loading toolkits...
+ ) : !toolkits || toolkits.length === 0 ? (
+
+
+
No toolkits yet
+
+ Create a toolkit to give an agent scoped access to your APIs.
+
+
setShowCreate(true)}
+ className="bg-primary text-background hover:bg-primary/80 rounded-lg px-4 py-2 text-sm font-medium transition-colors"
+ >
+ Create your first toolkit
+
+
+ ) : (
+
+ {toolkits.map((toolkit) => {
+ const pendingCount = pendingByToolkit[toolkit.id] ?? 0;
+ return (
+
+
+
+
+
+ {toolkit.name}
+
+ {toolkit.disabled && (
+
+
+ SUSPENDED
+
+ )}
+ {pendingCount > 0 && (
+
+
+ {pendingCount} pending
+
+ )}
+ {toolkit.simulate && (
+
+ simulate
+
+ )}
+
+ {toolkit.description && (
+
+ {toolkit.description}
+
+ )}
+
+
+
+
+
+
+ {toolkit.key_count ?? '—'} keys
+
+
+ {toolkit.credential_count != null
+ ? `${toolkit.credential_count} credentials`
+ : toolkit.credentials?.length != null
+ ? `${toolkit.credentials.length} credentials`
+ : '—'}
+
+
+
+ );
+ })}
+
+ )}
- {showCreate && (
-
{ setShowCreate(false); if (createNew) navigate('/toolkits') }}
- onCreated={id => { setShowCreate(false); navigate(`/toolkits/${id}`) }}
- />
- )}
-
- )
+ {showCreate && (
+
{
+ setShowCreate(false);
+ if (createNew) navigate('/toolkits');
+ }}
+ onCreated={(id) => {
+ setShowCreate(false);
+ navigate(`/toolkits/${id}`);
+ }}
+ />
+ )}
+
+ );
}
diff --git a/ui/src/pages/TraceDetailPage.tsx b/ui/src/pages/TraceDetailPage.tsx
index eb73173f..b8248c66 100644
--- a/ui/src/pages/TraceDetailPage.tsx
+++ b/ui/src/pages/TraceDetailPage.tsx
@@ -1,114 +1,217 @@
-import React from 'react'
-import { useParams, useNavigate } from 'react-router-dom'
-import { useQuery } from '@tanstack/react-query'
-import { api } from '../api/client'
-import { Badge, StatusBadge } from '../components/ui/Badge'
-import { ChevronLeft, Clock, Zap } from 'lucide-react'
+import React from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import { useQuery } from '@tanstack/react-query';
+import { ChevronLeft, Clock, Zap } from 'lucide-react';
+import { api } from '@/api/client';
+import { Badge, StatusBadge } from '@/components/ui/Badge';
export default function TraceDetailPage() {
- const { id } = useParams<{ id: string }>()
- const navigate = useNavigate()
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
- const { data: trace, isLoading } = useQuery({
- queryKey: ['trace', id],
- queryFn: () => api.getTrace(id!),
- enabled: !!id,
- })
+ const { data: trace, isLoading } = useQuery({
+ queryKey: ['trace', id],
+ queryFn: () => api.getTrace(id!),
+ enabled: !!id,
+ });
- if (isLoading) return Loading trace...
- if (!trace) return (
-
-
Trace not found.
-
navigate('/traces')} className="mt-4 px-4 py-2 bg-muted border border-border rounded-lg text-sm">Back to Traces
-
- )
+ if (isLoading)
+ return Loading trace...
;
+ if (!trace)
+ return (
+
+
Trace not found.
+
navigate('/traces')}
+ className="bg-muted border-border mt-4 rounded-lg border px-4 py-2 text-sm"
+ >
+ Back to Traces
+
+
+ );
- return (
-
-
navigate('/traces')} className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors">
- Back to Traces
-
+ return (
+
+
navigate('/traces')}
+ className="text-muted-foreground hover:text-foreground flex items-center gap-1.5 text-sm transition-colors"
+ >
+ Back to Traces
+
-
-
Trace Detail
-
{trace.id}
-
+
+
+ Trace Detail
+
+
+ {trace.id}
+
+
- {/* Summary */}
-
-
Summary
-
-
Toolkit
{trace.toolkit_id ?? '—'}
-
Status
- {trace.http_status ?
:
{trace.status ?? '—'} }
-
- {trace.operation_id && (
-
Operation
{trace.operation_id}
- )}
- {trace.workflow_id && (
-
Workflow
{trace.workflow_id}
- )}
- {trace.spec_path && (
-
Spec Path
{trace.spec_path}
- )}
-
Duration
-
{trace.duration_ms != null ? `${trace.duration_ms}ms` : '—'}
-
-
Execution Time
-
{trace.created_at ? new Date(trace.created_at * 1000).toLocaleString() : '—'}
-
- {trace.completed_at && trace.completed_at !== trace.created_at && (
-
Completed
{new Date(trace.completed_at * 1000).toLocaleString()}
- )}
-
- {trace.error && (
-
-
Error
-
{trace.error}
-
- )}
-
+ {/* Summary */}
+
+
+ Summary
+
+
+
+
Toolkit
+
{trace.toolkit_id ?? '—'}
+
+
+
Status
+ {trace.http_status ? (
+
+ ) : (
+
+ {trace.status ?? '—'}
+
+ )}
+
+ {trace.operation_id && (
+
+
Operation
+
+ {trace.operation_id}
+
+
+ )}
+ {trace.workflow_id && (
+
+
Workflow
+
+ {trace.workflow_id}
+
+
+ )}
+ {trace.spec_path && (
+
+
Spec Path
+
+ {trace.spec_path}
+
+
+ )}
+
+
Duration
+
+
+
+ {trace.duration_ms != null ? `${trace.duration_ms}ms` : '—'}
+
+
+
+
+
Execution Time
+
+
+
+ {trace.created_at
+ ? new Date(trace.created_at * 1000).toLocaleString()
+ : '—'}
+
+
+
+ {trace.completed_at && trace.completed_at !== trace.created_at && (
+
+
Completed
+
+ {new Date(trace.completed_at * 1000).toLocaleString()}
+
+
+ )}
+
+ {trace.error && (
+
+
Error
+
+ {trace.error}
+
+
+ )}
+
- {/* Steps */}
- {trace.steps && trace.steps.length > 0 && (
-
-
Steps ({trace.steps.length})
-
- {trace.steps.map((step: any, i: number) => (
-
-
{i+1}
-
-
- {step.step_id && {step.step_id}}
- {step.operation && {step.operation}}
- {step.http_status && }
- {step.status && !step.http_status && {step.status} }
-
- {step.error &&
{String(step.error)} }
-
-
- ))}
-
-
- )}
+ {/* Steps */}
+ {trace.steps && trace.steps.length > 0 && (
+
+
+
+ Steps ({trace.steps.length})
+
+
+
+ {trace.steps.map((step: any, i: number) => (
+
+
+ {i + 1}
+
+
+
+ {step.step_id && (
+
+ {step.step_id}
+
+ )}
+ {step.operation && (
+
+ {step.operation}
+
+ )}
+ {step.http_status && (
+
+ )}
+ {step.status && !step.http_status && (
+
+ {step.status}
+
+ )}
+
+ {step.error && (
+
+ {String(step.error)}
+
+ )}
+
+
+ ))}
+
+
+ )}
- {/* Request / Response */}
- {trace.request && (
-
-
Request
-
-
{JSON.stringify(trace.request, null, 2)}
-
-
- )}
- {trace.response && (
-
-
Response
-
-
{JSON.stringify(trace.response, null, 2)}
-
-
- )}
-
- )
+ {/* Request / Response */}
+ {trace.request && (
+
+
+
Request
+
+
+
+ {JSON.stringify(trace.request, null, 2)}
+
+
+
+ )}
+ {trace.response && (
+
+
+
Response
+
+
+
+ {JSON.stringify(trace.response, null, 2)}
+
+
+
+ )}
+
+ );
}
diff --git a/ui/src/pages/TracesPage.tsx b/ui/src/pages/TracesPage.tsx
index 9ea0c1c1..555d432e 100644
--- a/ui/src/pages/TracesPage.tsx
+++ b/ui/src/pages/TracesPage.tsx
@@ -1,117 +1,199 @@
-import { useState } from 'react'
-import { useNavigate, useSearchParams } from 'react-router-dom'
-import { useQuery } from '@tanstack/react-query'
-import { api } from '../api/client'
-import type { TraceOut } from '../api/generated'
-import { Badge, StatusBadge } from '../components/ui/Badge'
-import { Activity, ChevronLeft, ChevronRight, Filter, X } from 'lucide-react'
+import { useState } from 'react';
+import { useNavigate, useSearchParams } from 'react-router-dom';
+import { useQuery } from '@tanstack/react-query';
+import { Activity, ChevronLeft, ChevronRight, Filter, X } from 'lucide-react';
+import { api } from '@/api/client';
+import type { TraceOut } from '@/api/generated';
+import { Badge, StatusBadge } from '@/components/ui/Badge';
function timeAgo(ts?: number | null) {
- if (!ts) return '—'
- const s = Math.floor(Date.now() / 1000 - ts)
- if (s < 60) return 'just now'
- if (s < 3600) return `${Math.floor(s/60)}m ago`
- if (s < 86400) return `${Math.floor(s/3600)}h ago`
- return `${Math.floor(s/86400)}d ago`
+ if (!ts) return '—';
+ const s = Math.floor(Date.now() / 1000 - ts);
+ if (s < 60) return 'just now';
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
+ if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
+ return `${Math.floor(s / 86400)}d ago`;
}
export default function TracesPage() {
- const navigate = useNavigate()
- const [searchParams, setSearchParams] = useSearchParams()
- const [page, setPage] = useState(parseInt(searchParams.get('page') || '1', 10))
- const toolkit = searchParams.get('toolkit') || undefined
- const workflow = searchParams.get('workflow') || undefined
+ const navigate = useNavigate();
+ const [searchParams, setSearchParams] = useSearchParams();
+ const [page, setPage] = useState(parseInt(searchParams.get('page') || '1', 10));
+ const toolkit = searchParams.get('toolkit') || undefined;
+ const workflow = searchParams.get('workflow') || undefined;
- const { data: tracesPage, isLoading, isError } = useQuery({
- queryKey: ['traces', page, toolkit, workflow],
- queryFn: () => api.listTraces({ page, limit: 20, toolkit, workflow }),
- })
+ const {
+ data: tracesPage,
+ isLoading,
+ isError,
+ } = useQuery({
+ queryKey: ['traces', page, toolkit, workflow],
+ queryFn: () => api.listTraces({ page, limit: 20, toolkit, workflow }),
+ });
- const traces = tracesPage?.traces ?? []
- const total = tracesPage?.total ?? 0
- const totalPages = Math.ceil(total / 20)
+ const traces = tracesPage?.traces ?? [];
+ const total = tracesPage?.total ?? 0;
+ const totalPages = Math.ceil(total / 20);
- return (
-
-
-
Observability
-
Execution Traces
-
+ return (
+
+
+
+ Observability
+
+
+ Execution Traces
+
+
- {(toolkit || workflow) && (
-
-
- {toolkit && (
-
- toolkit: {toolkit}
- { const p = new URLSearchParams(searchParams); p.delete('toolkit'); setSearchParams(p) }}>
-
- )}
- {workflow && (
-
- workflow: {workflow}
- { const p = new URLSearchParams(searchParams); p.delete('workflow'); setSearchParams(p) }}>
-
- )}
-
- )}
+ {(toolkit || workflow) && (
+
+
+ {toolkit && (
+
+ toolkit: {toolkit}
+ {
+ const p = new URLSearchParams(searchParams);
+ p.delete('toolkit');
+ setSearchParams(p);
+ }}
+ >
+
+
+
+ )}
+ {workflow && (
+
+ workflow: {workflow}
+ {
+ const p = new URLSearchParams(searchParams);
+ p.delete('workflow');
+ setSearchParams(p);
+ }}
+ >
+
+
+
+ )}
+
+ )}
- {isLoading ? (
-
Loading traces...
- ) : isError ? (
-
-
Failed to load traces
-
Please try refreshing the page.
-
- ) : traces.length === 0 ? (
-
-
-
No traces found
-
Traces appear here when agents call the broker.
-
- ) : (
- <>
-
-
-
-
-
- {['Time','Toolkit','Operation / Workflow','Status','Duration'].map(h => (
- {h}
- ))}
-
-
-
- {traces.map((trace: TraceOut) => (
- navigate(`/traces/${trace.id}`)}>
- {timeAgo(trace.created_at)}
- {trace.toolkit_id ?? '—'}
-
- {trace.workflow_id && workflow }
- {trace.operation_id ?? trace.workflow_id ?? '—'}
-
-
- {trace.http_status ? : (
- {trace.status ?? '—'}
- )}
-
- {trace.duration_ms != null ? `${trace.duration_ms}ms` : '—'}
-
- ))}
-
-
-
-
- {totalPages > 1 && (
-
- setPage(p => p-1)} className="px-3 py-1.5 bg-muted border border-border rounded-lg text-sm disabled:opacity-40 hover:bg-muted/60 transition-colors">
- Page {page} of {totalPages}
- =totalPages} onClick={() => setPage(p => p+1)} className="px-3 py-1.5 bg-muted border border-border rounded-lg text-sm disabled:opacity-40 hover:bg-muted/60 transition-colors">
-
- )}
- >
- )}
-
- )
+ {isLoading ? (
+
Loading traces...
+ ) : isError ? (
+
+
Failed to load traces
+
+ Please try refreshing the page.
+
+
+ ) : traces.length === 0 ? (
+
+
+
No traces found
+
Traces appear here when agents call the broker.
+
+ ) : (
+ <>
+
+
+
+
+
+ {[
+ 'Time',
+ 'Toolkit',
+ 'Operation / Workflow',
+ 'Status',
+ 'Duration',
+ ].map((h) => (
+
+ {h}
+
+ ))}
+
+
+
+ {traces.map((trace: TraceOut) => (
+ navigate(`/traces/${trace.id}`)}
+ >
+
+ {timeAgo(trace.created_at)}
+
+
+ {trace.toolkit_id ?? '—'}
+
+
+ {trace.workflow_id && (
+
+ workflow
+
+ )}
+ {trace.operation_id ?? trace.workflow_id ?? '—'}
+
+
+ {trace.http_status ? (
+
+ ) : (
+
+ {trace.status ?? '—'}
+
+ )}
+
+
+ {trace.duration_ms != null
+ ? `${trace.duration_ms}ms`
+ : '—'}
+
+
+ ))}
+
+
+
+
+ {totalPages > 1 && (
+
+ setPage((p) => p - 1)}
+ className="bg-muted border-border hover:bg-muted/60 rounded-lg border px-3 py-1.5 text-sm transition-colors disabled:opacity-40"
+ >
+
+
+
+ Page {page} of {totalPages}
+
+ = totalPages}
+ onClick={() => setPage((p) => p + 1)}
+ className="bg-muted border-border hover:bg-muted/60 rounded-lg border px-3 py-1.5 text-sm transition-colors disabled:opacity-40"
+ >
+
+
+
+ )}
+ >
+ )}
+
+ );
}
diff --git a/ui/src/pages/WorkflowDetailPage.tsx b/ui/src/pages/WorkflowDetailPage.tsx
index 224813f9..e5a80db0 100644
--- a/ui/src/pages/WorkflowDetailPage.tsx
+++ b/ui/src/pages/WorkflowDetailPage.tsx
@@ -1,243 +1,296 @@
-import { Component, useState } from 'react'
-import type { ReactNode } from 'react'
-import { useParams, useNavigate } from 'react-router-dom'
-import { useQuery, useQueryClient } from '@tanstack/react-query'
-import { api } from '../api/client'
-import { Badge } from '../components/ui/Badge'
-import { ChevronLeft, Workflow, ExternalLink, Loader2, Zap, AlertTriangle } from 'lucide-react'
-import { ArazzoUI } from '@jentic/arazzo-ui'
-import '@jentic/arazzo-ui/styles.css'
-
-class ArazzoErrorBoundary extends Component<{ slug?: string; children: ReactNode }, { error: Error | null }> {
- state = { error: null as Error | null }
- static getDerivedStateFromError(error: Error) { return { error } }
- componentDidUpdate(prevProps: { slug?: string }) {
- if (prevProps.slug !== this.props.slug && this.state.error) {
- this.setState({ error: null })
- }
- }
- render() {
- if (this.state.error) {
- return (
-
-
-
Workflow visualization failed to render
-
{this.state.error.message}
-
- )
- }
- return this.props.children
- }
+import { Component, useState } from 'react';
+import type { ReactNode } from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { ChevronLeft, Workflow, ExternalLink, Loader2, Zap, AlertTriangle } from 'lucide-react';
+import { ArazzoUI } from '@jentic/arazzo-ui';
+import { api } from '@/api/client';
+import { Badge } from '@/components/ui/Badge';
+import '@jentic/arazzo-ui/styles.css';
+
+class ArazzoErrorBoundary extends Component<
+ { slug?: string; children: ReactNode },
+ { error: Error | null }
+> {
+ state = { error: null as Error | null };
+ static getDerivedStateFromError(error: Error) {
+ return { error };
+ }
+ componentDidUpdate(prevProps: { slug?: string }) {
+ if (prevProps.slug !== this.props.slug && this.state.error) {
+ this.setState({ error: null });
+ }
+ }
+ render() {
+ if (this.state.error) {
+ return (
+
+
+
+ Workflow visualization failed to render
+
+
{this.state.error.message}
+
+ );
+ }
+ return this.props.children;
+ }
}
-function CatalogWorkflowFallback({ slug, navigate }: { slug: string; navigate: (path: string) => void }) {
- const queryClient = useQueryClient()
- const [importing, setImporting] = useState(false)
- const [error, setError] = useState(null)
-
- // Convert slug back to api_id: apideck.com~ecosystem → apideck.com/ecosystem
- const apiId = slug.replace('~', '/')
- const githubUrl = `https://github.com/jentic/jentic-public-apis/tree/main/workflows/${slug}`
- const encodedSlug = encodeURIComponent(slug)
- const rawArazzoUrl = `https://raw.githubusercontent.com/jentic/jentic-public-apis/refs/heads/main/workflows/${encodedSlug}/workflows.arazzo.json`
- const arazzoUIUrl = `https://arazzo-ui.jentic.com?document=${encodeURIComponent(rawArazzoUrl)}`
-
- const handleImport = async () => {
- setImporting(true)
- setError(null)
- try {
- const catalogRes = await fetch(`/catalog/${apiId}`, { credentials: 'include' })
- if (!catalogRes.ok) {
- const body = await catalogRes.json().catch(() => ({}))
- throw new Error(body.detail || `Catalog lookup failed (${catalogRes.status})`)
- }
- const catalogEntry = await catalogRes.json()
- if (!catalogEntry.spec_url) {
- throw new Error('No spec URL found for this API in the catalog')
- }
- const importRes = await fetch('/import', {
- method: 'POST',
- credentials: 'include',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ sources: [{ type: 'url', url: catalogEntry.spec_url, force_api_id: apiId }] }),
- })
- if (!importRes.ok) {
- const body = await importRes.json().catch(() => ({}))
- throw new Error(body.detail || `Import failed (${importRes.status})`)
- }
- const importResult = await importRes.json()
- if (importResult.failed > 0) {
- const err = importResult.results?.[0]?.error || 'Unknown error'
- throw new Error(`Import failed: ${err}`)
- }
- queryClient.invalidateQueries({ queryKey: ['workflows'] })
- navigate('/workflows')
- } catch (e: any) {
- setError(e.message)
- } finally {
- setImporting(false)
- }
- }
-
- return (
-
-
navigate('/workflows')}
- className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors">
- Back to Workflows
-
-
-
-
- This workflow is available in the Jentic public catalog. Import it to view details and execute.
-
- {error &&
{error}
}
-
-
-
- )
+function CatalogWorkflowFallback({
+ slug,
+ navigate,
+}: {
+ slug: string;
+ navigate: (path: string) => void;
+}) {
+ const queryClient = useQueryClient();
+ const [importing, setImporting] = useState(false);
+ const [error, setError] = useState(null);
+
+ // Convert slug back to api_id: apideck.com~ecosystem → apideck.com/ecosystem
+ const apiId = slug.replace('~', '/');
+ const githubUrl = `https://github.com/jentic/jentic-public-apis/tree/main/workflows/${slug}`;
+ const encodedSlug = encodeURIComponent(slug);
+ const rawArazzoUrl = `https://raw.githubusercontent.com/jentic/jentic-public-apis/refs/heads/main/workflows/${encodedSlug}/workflows.arazzo.json`;
+ const arazzoUIUrl = `https://arazzo-ui.jentic.com?document=${encodeURIComponent(rawArazzoUrl)}`;
+
+ const handleImport = async () => {
+ setImporting(true);
+ setError(null);
+ try {
+ const catalogRes = await fetch(`/catalog/${apiId}`, { credentials: 'include' });
+ if (!catalogRes.ok) {
+ const body = await catalogRes.json().catch(() => ({}));
+ throw new Error(body.detail || `Catalog lookup failed (${catalogRes.status})`);
+ }
+ const catalogEntry = await catalogRes.json();
+ if (!catalogEntry.spec_url) {
+ throw new Error('No spec URL found for this API in the catalog');
+ }
+ const importRes = await fetch('/import', {
+ method: 'POST',
+ credentials: 'include',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ sources: [{ type: 'url', url: catalogEntry.spec_url, force_api_id: apiId }],
+ }),
+ });
+ if (!importRes.ok) {
+ const body = await importRes.json().catch(() => ({}));
+ throw new Error(body.detail || `Import failed (${importRes.status})`);
+ }
+ const importResult = await importRes.json();
+ if (importResult.failed > 0) {
+ const err = importResult.results?.[0]?.error || 'Unknown error';
+ throw new Error(`Import failed: ${err}`);
+ }
+ queryClient.invalidateQueries({ queryKey: ['workflows'] });
+ navigate('/workflows');
+ } catch (e: any) {
+ setError(e.message);
+ } finally {
+ setImporting(false);
+ }
+ };
+
+ return (
+
+
navigate('/workflows')}
+ className="text-muted-foreground hover:text-foreground flex items-center gap-1.5 text-sm transition-colors"
+ >
+ Back to Workflows
+
+
+
+
+ This workflow is available in the Jentic public catalog. Import it to view
+ details and execute.
+
+ {error &&
{error}
}
+
+
+
+ );
}
export default function WorkflowDetailPage() {
- const { slug } = useParams<{ slug: string }>()
- const navigate = useNavigate()
- const [view, setView] = useState<'diagram' | 'docs' | 'split'>('docs')
-
- const { data: workflow, isLoading, error } = useQuery({
- queryKey: ['workflow', slug],
- queryFn: () => api.getWorkflow(slug!),
- enabled: !!slug,
- retry: (failureCount, err: any) => err?.status !== 404 && failureCount < 2,
- })
-
- // Fetch the raw Arazzo document
- const { data: arazzoDoc, isLoading: isLoadingArazzo } = useQuery({
- queryKey: ['workflow-arazzo', slug],
- queryFn: async () => {
- const res = await fetch(`/workflows/${slug}`, {
- headers: { 'Accept': 'application/vnd.oai.workflows+json' },
- credentials: 'include',
- })
- if (!res.ok) throw new Error('Failed to fetch Arazzo document')
- return res.json()
- },
- enabled: !!slug && !!workflow,
- })
-
- if (isLoading) return Loading workflow...
-
- // 404 → show catalog fallback. Other errors → show error state.
- const is404 = (error as any)?.status === 404
- if (error && !is404) {
- return (
-
-
-
Failed to load workflow
-
{(error as any)?.message || 'Unknown error'}
-
- )
- }
-
- if (!workflow) return
-
- const steps: any[] = workflow.steps ?? []
- const involvedApis: string[] = workflow.involved_apis ?? []
-
- // Check if description is different from name to avoid duplication
- const showDescription = workflow.description && workflow.description !== workflow.name
-
- return (
-
-
navigate('/workflows')}
- className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors">
- Back to Workflows
-
-
- {/* Condensed header */}
-
-
-
Workflow
-
-
-
- {workflow.name ?? workflow.slug}
-
-
-
{workflow.slug}
-
-
- {/* Meta badges and view toggle */}
-
-
- {steps.length > 0 && (
- {steps.length} step{steps.length !== 1 ? 's' : ''}
- )}
- {involvedApis.map((apiId: string) => (
- {apiId}
- ))}
-
-
- {/* View toggle */}
-
- {(['diagram', 'split', 'docs'] as const).map(v => (
- setView(v)}
- className={`px-3 py-1.5 rounded text-xs font-medium transition-colors ${
- view === v ? 'bg-primary text-background' : 'text-muted-foreground hover:text-foreground'
- }`}
- >
- {v === 'diagram' ? 'Diagram' : v === 'split' ? 'Split' : 'Docs'}
-
- ))}
-
-
-
- {showDescription && (
-
{workflow.description}
- )}
-
-
- {/* Arazzo UI Viewer */}
- {isLoadingArazzo ? (
-
-
- Loading workflow visualization...
-
- ) : arazzoDoc ? (
-
-
-
- ) : (
-
- Failed to load workflow visualization.
-
- )}
-
- )
+ const { slug } = useParams<{ slug: string }>();
+ const navigate = useNavigate();
+ const [view, setView] = useState<'diagram' | 'docs' | 'split'>('docs');
+
+ const {
+ data: workflow,
+ isLoading,
+ error,
+ } = useQuery({
+ queryKey: ['workflow', slug],
+ queryFn: () => api.getWorkflow(slug!),
+ enabled: !!slug,
+ retry: (failureCount, err: any) => err?.status !== 404 && failureCount < 2,
+ });
+
+ // Fetch the raw Arazzo document
+ const { data: arazzoDoc, isLoading: isLoadingArazzo } = useQuery({
+ queryKey: ['workflow-arazzo', slug],
+ queryFn: async () => {
+ const res = await fetch(`/workflows/${slug}`, {
+ headers: { Accept: 'application/vnd.oai.workflows+json' },
+ credentials: 'include',
+ });
+ if (!res.ok) throw new Error('Failed to fetch Arazzo document');
+ return res.json();
+ },
+ enabled: !!slug && !!workflow,
+ });
+
+ if (isLoading)
+ return Loading workflow...
;
+
+ // 404 → show catalog fallback. Other errors → show error state.
+ const is404 = (error as any)?.status === 404;
+ if (error && !is404) {
+ return (
+
+
+
Failed to load workflow
+
+ {(error as any)?.message || 'Unknown error'}
+
+
+ );
+ }
+
+ if (!workflow) return ;
+
+ const steps: any[] = workflow.steps ?? [];
+ const involvedApis: string[] = workflow.involved_apis ?? [];
+
+ // Check if description is different from name to avoid duplication
+ const showDescription = workflow.description && workflow.description !== workflow.name;
+
+ return (
+
+
navigate('/workflows')}
+ className="text-muted-foreground hover:text-foreground flex items-center gap-1.5 text-sm transition-colors"
+ >
+ Back to Workflows
+
+
+ {/* Condensed header */}
+
+
+
+ Workflow
+
+
+
+
+ {workflow.name ?? workflow.slug}
+
+
+
{workflow.slug}
+
+
+ {/* Meta badges and view toggle */}
+
+
+ {steps.length > 0 && (
+
+ {steps.length} step{steps.length !== 1 ? 's' : ''}
+
+ )}
+ {involvedApis.map((apiId: string) => (
+
+ {apiId}
+
+ ))}
+
+
+ {/* View toggle */}
+
+ {(['diagram', 'split', 'docs'] as const).map((v) => (
+ setView(v)}
+ className={`rounded px-3 py-1.5 text-xs font-medium transition-colors ${
+ view === v
+ ? 'bg-primary text-background'
+ : 'text-muted-foreground hover:text-foreground'
+ }`}
+ >
+ {v === 'diagram' ? 'Diagram' : v === 'split' ? 'Split' : 'Docs'}
+
+ ))}
+
+
+
+ {showDescription && (
+
{workflow.description}
+ )}
+
+
+ {/* Arazzo UI Viewer */}
+ {isLoadingArazzo ? (
+
+
+ Loading workflow visualization...
+
+ ) : arazzoDoc ? (
+
+
+
+ ) : (
+
+ Failed to load workflow visualization.
+
+ )}
+
+ );
}
diff --git a/ui/src/pages/WorkflowsPage.tsx b/ui/src/pages/WorkflowsPage.tsx
index 6b2ef7bb..4c9c0cc2 100644
--- a/ui/src/pages/WorkflowsPage.tsx
+++ b/ui/src/pages/WorkflowsPage.tsx
@@ -1,85 +1,116 @@
-import { useNavigate } from 'react-router-dom'
-import { useQuery } from '@tanstack/react-query'
-import { api } from '../api/client'
-import { Badge } from '../components/ui/Badge'
-import { Workflow, ChevronRight, Zap, Globe } from 'lucide-react'
+import { useNavigate } from 'react-router-dom';
+import { useQuery } from '@tanstack/react-query';
+import { Workflow, ChevronRight, Zap, Globe } from 'lucide-react';
+import { api } from '@/api/client';
+import { Badge } from '@/components/ui/Badge';
export default function WorkflowsPage() {
- const navigate = useNavigate()
+ const navigate = useNavigate();
- const { data: workflows, isLoading, isError } = useQuery({
- queryKey: ['workflows'],
- queryFn: api.listWorkflows,
- })
+ const {
+ data: workflows,
+ isLoading,
+ isError,
+ } = useQuery({
+ queryKey: ['workflows'],
+ queryFn: api.listWorkflows,
+ });
- return (
-
-
+ return (
+
+
+
+ Catalog
+
+
Workflows
+
- {isLoading ? (
-
Loading workflows...
- ) : isError ? (
-
-
Failed to load workflows
-
Please try refreshing the page.
-
- ) : !workflows || !Array.isArray(workflows) || workflows.length === 0 ? (
-
-
-
No workflows registered
-
Import an Arazzo workflow file to get started.
-
- ) : (
-
- {workflows.map((wf: any) => (
-
navigate(`/workflows/${wf.slug}`)}
- onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); navigate(`/workflows/${wf.slug}`) } }}
- className="flex items-center gap-4 px-5 py-3.5 bg-muted border border-border rounded-xl hover:border-primary/40 transition-colors cursor-pointer"
- >
-
-
-
-
- {wf.name ?? wf.slug}
-
-
- {wf.source === 'local' ? : }
- {wf.source === 'local' ? 'local' : 'catalog'}
-
- {wf.steps_count > 0 && (
-
{wf.steps_count} steps
- )}
-
- {wf.description && (
-
{wf.description}
- )}
- {wf.involved_apis && wf.involved_apis.length > 0 && (
-
- {wf.involved_apis.slice(0, 3).map((apiId: any) => (
- {apiId}
- ))}
- {wf.involved_apis.length > 3 && (
- +{wf.involved_apis.length - 3} more
- )}
-
- )}
-
-
-
- ))}
-
- )}
-
- )
+ {isLoading ? (
+
Loading workflows...
+ ) : isError ? (
+
+
Failed to load workflows
+
+ Please try refreshing the page.
+
+
+ ) : !workflows || !Array.isArray(workflows) || workflows.length === 0 ? (
+
+
+
No workflows registered
+
Import an Arazzo workflow file to get started.
+
+ ) : (
+
+ {workflows.map((wf: any) => (
+
navigate(`/workflows/${wf.slug}`)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ navigate(`/workflows/${wf.slug}`);
+ }
+ }}
+ className="bg-muted border-border hover:border-primary/40 flex cursor-pointer items-center gap-4 rounded-xl border px-5 py-3.5 transition-colors"
+ >
+
+
+
+
+ {wf.name ?? wf.slug}
+
+
+ {wf.source === 'local' ? (
+
+ ) : (
+
+ )}
+ {wf.source === 'local' ? 'local' : 'catalog'}
+
+ {wf.steps_count > 0 && (
+
+ {wf.steps_count} steps
+
+ )}
+
+ {wf.description && (
+
+ {wf.description}
+
+ )}
+ {wf.involved_apis && wf.involved_apis.length > 0 && (
+
+ {wf.involved_apis.slice(0, 3).map((apiId: any) => (
+
+ {apiId}
+
+ ))}
+ {wf.involved_apis.length > 3 && (
+
+ +{wf.involved_apis.length - 3} more
+
+ )}
+
+ )}
+
+
+
+ ))}
+
+ )}
+
+ );
}
diff --git a/ui/tsconfig.json b/ui/tsconfig.json
index 66e1723c..34c721f2 100644
--- a/ui/tsconfig.json
+++ b/ui/tsconfig.json
@@ -1,32 +1,30 @@
{
- "compilerOptions": {
- "target": "ES2020",
- "useDefineForClassFields": true,
- "lib": [
- "ES2020",
- "DOM",
- "DOM.Iterable"
- ],
- "types": ["vitest/globals"],
- "module": "ESNext",
- "skipLibCheck": true,
- "moduleResolution": "bundler",
- "allowImportingTsExtensions": true,
- "resolveJsonModule": true,
- "isolatedModules": true,
- "noEmit": true,
- "jsx": "react-jsx",
- "strict": true,
- "noUnusedLocals": false,
- "noUnusedParameters": false,
- "noFallthroughCasesInSwitch": true
- },
- "include": [
- "src"
- ],
- "references": [
- {
- "path": "./tsconfig.node.json"
- }
- ]
-}
\ No newline at end of file
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "types": ["vitest/globals"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "noFallthroughCasesInSwitch": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ }
+ },
+ "include": ["src"],
+ "references": [
+ {
+ "path": "./tsconfig.node.json"
+ }
+ ]
+}
diff --git a/ui/tsconfig.node.json b/ui/tsconfig.node.json
index 42872c59..eca66688 100644
--- a/ui/tsconfig.node.json
+++ b/ui/tsconfig.node.json
@@ -1,10 +1,10 @@
{
- "compilerOptions": {
- "composite": true,
- "skipLibCheck": true,
- "module": "ESNext",
- "moduleResolution": "bundler",
- "allowSyntheticDefaultImports": true
- },
- "include": ["vite.config.ts"]
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true
+ },
+ "include": ["vite.config.ts"]
}
diff --git a/ui/vite.config.ts b/ui/vite.config.ts
index 40448345..2b11c194 100644
--- a/ui/vite.config.ts
+++ b/ui/vite.config.ts
@@ -1,69 +1,83 @@
-import { defineConfig } from 'vite'
-import react from '@vitejs/plugin-react'
-import tailwindcss from '@tailwindcss/vite'
-import { copyFileSync } from 'node:fs'
-import { resolve, dirname } from 'node:path'
-import { fileURLToPath } from 'node:url'
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+import tailwindcss from '@tailwindcss/vite';
+import { copyFileSync } from 'node:fs';
+import { resolve, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
-const __dirname = dirname(fileURLToPath(import.meta.url))
+const __dirname = dirname(fileURLToPath(import.meta.url));
function copyApiDocsAssets(): import('vite').Plugin {
- return {
- name: 'copy-api-docs-assets',
- closeBundle() {
- const outDir = resolve(__dirname, '../static')
- const nm = resolve(__dirname, 'node_modules')
- copyFileSync(resolve(nm, 'swagger-ui-dist/swagger-ui-bundle.js'), resolve(outDir, 'swagger-ui-bundle.js'))
- copyFileSync(resolve(nm, 'swagger-ui-dist/swagger-ui.css'), resolve(outDir, 'swagger-ui.css'))
- copyFileSync(resolve(nm, 'redoc/bundles/redoc.standalone.js'), resolve(outDir, 'redoc.standalone.js'))
- },
- }
+ return {
+ name: 'copy-api-docs-assets',
+ closeBundle() {
+ const outDir = resolve(__dirname, '../static');
+ const nm = resolve(__dirname, 'node_modules');
+ copyFileSync(
+ resolve(nm, 'swagger-ui-dist/swagger-ui-bundle.js'),
+ resolve(outDir, 'swagger-ui-bundle.js'),
+ );
+ copyFileSync(
+ resolve(nm, 'swagger-ui-dist/swagger-ui.css'),
+ resolve(outDir, 'swagger-ui.css'),
+ );
+ copyFileSync(
+ resolve(nm, 'redoc/bundles/redoc.standalone.js'),
+ resolve(outDir, 'redoc.standalone.js'),
+ );
+ },
+ };
}
// In Docker dev (compose.dev.yml) this is overridden to http://jentic-mini:8900
// so the Vite container can reach the API container by service name.
// When running Vite directly on the host, the default http://localhost:8900 applies.
-const apiHost = process.env.VITE_API_HOST || 'http://localhost:8900'
+const apiHost = process.env.VITE_API_HOST || 'http://localhost:8900';
// Paths that are also React Router routes (e.g. /toolkits, /search).
// For these, browser navigations (Accept: text/html) must serve index.html so
// the SPA can render — only JSON/API calls should be proxied to the backend.
// Pure API-only paths (no matching SPA route) can use the simpler string form.
const spaRoute = {
- target: apiHost,
- bypass: (req: import('http').IncomingMessage) =>
- req.headers.accept?.includes('text/html') ? '/index.html' : null,
-}
+ target: apiHost,
+ bypass: (req: import('http').IncomingMessage) =>
+ req.headers.accept?.includes('text/html') ? '/index.html' : null,
+};
export default defineConfig({
- plugins: [react(), tailwindcss(), copyApiDocsAssets()],
- base: '/',
- build: { outDir: '../static', emptyOutDir: true },
- server: {
- host: '0.0.0.0',
- allowedHosts: true,
- proxy: {
- // Pure API routes — no conflicting SPA page
- '/api': apiHost,
- '/user': apiHost,
- '/apis': apiHost,
- '/health': apiHost,
- '/version': apiHost,
- '/import': apiHost,
- '/inspect': apiHost,
- '/notes': apiHost,
- '/default-api-key': apiHost,
- '/oauth-brokers': apiHost,
- '/docs': apiHost,
- '/openapi.json': apiHost,
- // SPA + API dual-use routes — serve index.html for browser navigations
- '/search': spaRoute,
- '/toolkits': spaRoute,
- '/credentials': spaRoute,
- '/traces': spaRoute,
- '/jobs': spaRoute,
- '/workflows': spaRoute,
- '/catalog': spaRoute,
- }
- }
-})
+ resolve: {
+ alias: {
+ '@': resolve(__dirname, 'src'),
+ },
+ },
+ plugins: [react(), tailwindcss(), copyApiDocsAssets()],
+ base: '/',
+ build: { outDir: '../static', emptyOutDir: true },
+ server: {
+ host: '0.0.0.0',
+ allowedHosts: true,
+ proxy: {
+ // Pure API routes — no conflicting SPA page
+ '/api': apiHost,
+ '/user': apiHost,
+ '/apis': apiHost,
+ '/health': apiHost,
+ '/version': apiHost,
+ '/import': apiHost,
+ '/inspect': apiHost,
+ '/notes': apiHost,
+ '/default-api-key': apiHost,
+ '/oauth-brokers': apiHost,
+ '/docs': apiHost,
+ '/openapi.json': apiHost,
+ // SPA + API dual-use routes — serve index.html for browser navigations
+ '/search': spaRoute,
+ '/toolkits': spaRoute,
+ '/credentials': spaRoute,
+ '/traces': spaRoute,
+ '/jobs': spaRoute,
+ '/workflows': spaRoute,
+ '/catalog': spaRoute,
+ },
+ },
+});
diff --git a/ui/vitest.config.ts b/ui/vitest.config.ts
index 3bbc2837..5297829e 100644
--- a/ui/vitest.config.ts
+++ b/ui/vitest.config.ts
@@ -1,33 +1,36 @@
-import { defineConfig } from 'vitest/config'
-import react from '@vitejs/plugin-react'
-import tailwindcss from '@tailwindcss/vite'
-import { playwright } from '@vitest/browser-playwright'
+import { defineConfig } from 'vitest/config';
+import react from '@vitejs/plugin-react';
+import tailwindcss from '@tailwindcss/vite';
+import { playwright } from '@vitest/browser-playwright';
+import { resolve, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
export default defineConfig({
- plugins: [react(), tailwindcss()],
- optimizeDeps: {
- include: ['@jentic/arazzo-ui', 'react-dom/client'],
- },
- test: {
- browser: {
- enabled: true,
- provider: playwright(),
- instances: [{ browser: 'chromium' }],
- },
- globals: true,
- setupFiles: ['./src/__tests__/setup.ts'],
- include: ['src/**/*.test.{ts,tsx}'],
- coverage: {
- provider: 'istanbul',
- reporter: ['text', 'html', 'lcov'],
- include: [
- 'src/components/ui/**',
- 'src/pages/**',
- 'src/hooks/**',
- ],
- exclude: [
- 'src/api/generated/**',
- ],
- },
- },
-})
+ plugins: [react(), tailwindcss()],
+ resolve: {
+ alias: {
+ '@': resolve(__dirname, 'src'),
+ },
+ },
+ optimizeDeps: {
+ include: ['@jentic/arazzo-ui', 'react-dom/client'],
+ },
+ test: {
+ browser: {
+ enabled: true,
+ provider: playwright(),
+ instances: [{ browser: 'chromium' }],
+ },
+ globals: true,
+ setupFiles: ['./src/__tests__/setup.ts'],
+ include: ['src/**/*.test.{ts,tsx}'],
+ coverage: {
+ provider: 'istanbul',
+ reporter: ['text', 'html', 'lcov'],
+ include: ['src/components/ui/**', 'src/pages/**', 'src/hooks/**'],
+ exclude: ['src/api/generated/**'],
+ },
+ },
+});