- Custom color system (surface-0..4, accent, mint) replacing raw gray-xxx - Inter + JetBrains Mono fonts via Google Fonts - Refined sidebar: compact logo, geometric icons, subtle active states - Fixed bg-gray-850 bug (invalid Tailwind class) in 9 components - Polished login page with centered card + gradient logo - Unified card/table/button/input styling across all components - Subtle grain texture overlay for depth - Smoother animations (fade-in, slide-up)
329 lines
13 KiB
TypeScript
329 lines
13 KiB
TypeScript
import { useState, useEffect, useMemo } from 'react'
|
|
import { Combobox, Listbox } from '@headlessui/react'
|
|
import { api, getProjectionDefs, getObjects } from '../api'
|
|
import { Spinner, HashBadge } from './Common'
|
|
|
|
interface ProjectionDef {
|
|
name: string
|
|
hash: string
|
|
params: Record<string, { type: string; object_type?: string }>
|
|
value_schema: { type: string }
|
|
initial_value: any
|
|
sources: Array<{ event_def_hash: string; bindings: Record<string, string>; expression: string }>
|
|
}
|
|
|
|
export default function Projections() {
|
|
const [defs, setDefs] = useState<ProjectionDef[]>([])
|
|
const [selectedDef, setSelectedDef] = useState<ProjectionDef | null>(null)
|
|
const [params, setParams] = useState<Record<string, string>>({})
|
|
const [objects, setObjects] = useState<Array<{ id: string; type: string }>>([])
|
|
const [result, setResult] = useState<any>(null)
|
|
const [error, setError] = useState('')
|
|
const [loading, setLoading] = useState(false)
|
|
const [initialLoading, setInitialLoading] = useState(true)
|
|
|
|
useEffect(() => {
|
|
Promise.all([getProjectionDefs(), getObjects()])
|
|
.then(([defsRes, objsRes]) => {
|
|
setDefs(defsRes.projection_defs)
|
|
setObjects(objsRes.objects.map((o: any) => ({ ...o, id: String(o.id) })))
|
|
})
|
|
.catch(() => {})
|
|
.finally(() => setInitialLoading(false))
|
|
}, [])
|
|
|
|
const handleSelectDef = (def: ProjectionDef | null) => {
|
|
setSelectedDef(def)
|
|
setResult(null)
|
|
setError('')
|
|
if (def) {
|
|
const initial: Record<string, string> = {}
|
|
for (const key of Object.keys(def.params)) {
|
|
initial[key] = ''
|
|
}
|
|
setParams(initial)
|
|
} else {
|
|
setParams({})
|
|
}
|
|
}
|
|
|
|
const handleQuery = async () => {
|
|
if (!selectedDef) return
|
|
setLoading(true)
|
|
setError('')
|
|
setResult(null)
|
|
|
|
const queryParams = new URLSearchParams()
|
|
for (const [k, v] of Object.entries(params)) {
|
|
if (v.trim()) queryParams.set(k, v.trim())
|
|
}
|
|
|
|
try {
|
|
const res = await api<{ value: any }>(`/projections/${selectedDef.name}?${queryParams.toString()}`)
|
|
setResult(res.value)
|
|
} catch (e: any) {
|
|
setError(e.message)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const objectsByType = useMemo(() => {
|
|
const map: Record<string, string[]> = {}
|
|
for (const obj of objects) {
|
|
if (!map[obj.type]) map[obj.type] = []
|
|
map[obj.type].push(String(obj.id))
|
|
}
|
|
return map
|
|
}, [objects])
|
|
|
|
if (initialLoading) return <Spinner />
|
|
|
|
return (
|
|
<div className="max-w-4xl mx-auto">
|
|
<h2 className="text-lg font-semibold text-white tracking-tight mb-5">Query Projections</h2>
|
|
<div className="bg-surface-1 rounded-xl p-6 space-y-6 border border-white/[0.06]">
|
|
{/* Projection selector with Listbox */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-400 mb-2">Projection</label>
|
|
<Listbox value={selectedDef} onChange={handleSelectDef}>
|
|
<div className="relative">
|
|
<Listbox.Button className="w-full px-4 py-2.5 bg-surface-3 border border-gray-700 rounded-lg text-left focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 transition-all">
|
|
<span className={selectedDef ? 'text-gray-100' : 'text-gray-500'}>
|
|
{selectedDef?.name || 'Select a projection...'}
|
|
</span>
|
|
<span className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
|
<svg className="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
|
|
<path
|
|
fillRule="evenodd"
|
|
d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
|
|
clipRule="evenodd"
|
|
/>
|
|
</svg>
|
|
</span>
|
|
</Listbox.Button>
|
|
<Listbox.Options className="absolute z-10 mt-1 w-full bg-surface-3 border border-gray-700 rounded-lg shadow-lg max-h-60 overflow-auto focus:outline-none">
|
|
{defs.map((d) => (
|
|
<Listbox.Option
|
|
key={d.name}
|
|
value={d}
|
|
className={({ active }) =>
|
|
`cursor-pointer select-none px-4 py-2.5 transition-colors ${
|
|
active ? 'bg-blue-600 text-white' : 'text-gray-100'
|
|
}`
|
|
}
|
|
>
|
|
{({ selected }) => <span className={selected ? 'font-semibold' : 'font-normal'}>{d.name}</span>}
|
|
</Listbox.Option>
|
|
))}
|
|
</Listbox.Options>
|
|
</div>
|
|
</Listbox>
|
|
</div>
|
|
|
|
{/* Auto-generated params form */}
|
|
{selectedDef && (
|
|
<div className="space-y-4">
|
|
<div className="flex items-baseline gap-2">
|
|
<label className="block text-sm font-medium text-gray-400">Parameters</label>
|
|
<span className="text-xs text-gray-600">
|
|
→ {selectedDef.value_schema?.type || 'any'}
|
|
{selectedDef.initial_value !== undefined && (
|
|
<span className="ml-1">(initial: {JSON.stringify(selectedDef.initial_value)})</span>
|
|
)}
|
|
</span>
|
|
</div>
|
|
<div className="space-y-3 bg-surface-3/30 rounded-lg p-4 border border-gray-700/50">
|
|
{Object.entries(selectedDef.params).map(([key, schema]) => (
|
|
<div key={key}>
|
|
<label className="block text-xs font-medium text-gray-500 mb-1.5">
|
|
{key}
|
|
<span className="ml-2 px-1.5 py-0.5 bg-gray-700 rounded text-xs text-gray-400">
|
|
{schema.type}
|
|
{schema.object_type ? ` → ${schema.object_type}` : ''}
|
|
</span>
|
|
</label>
|
|
{schema.type === 'ref' ? (
|
|
<RefCombobox
|
|
value={params[key] || ''}
|
|
onChange={(v) => setParams({ ...params, [key]: v })}
|
|
objects={objects}
|
|
objectsByType={objectsByType}
|
|
objectType={schema.object_type}
|
|
paramName={key}
|
|
/>
|
|
) : (
|
|
<input
|
|
type={schema.type === 'number' ? 'number' : 'text'}
|
|
className="w-full px-3 py-2 bg-surface-3 border border-white/[0.08] rounded-lg focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent/30 transition-all"
|
|
placeholder={`Enter ${schema.type} value...`}
|
|
value={params[key] || ''}
|
|
onChange={(e) => setParams({ ...params, [key]: e.target.value })}
|
|
/>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Projection info */}
|
|
{selectedDef && (
|
|
<div className="text-xs space-y-2 bg-surface-3/20 rounded-lg p-4 border border-gray-700/30">
|
|
<div>
|
|
<span className="text-gray-500 font-medium">sources:</span>
|
|
<div className="mt-1 space-y-2">
|
|
{selectedDef.sources?.map((s, i) => (
|
|
<div key={i} className="pl-3 border-l border-gray-700">
|
|
<div className="flex items-center gap-2">
|
|
<HashBadge hash={s.event_def_hash} short={false} />
|
|
</div>
|
|
<div className="text-xs mt-1">
|
|
<span className="text-gray-500">bindings:</span>{' '}
|
|
{Object.keys(s.bindings).length === 0 ? (
|
|
<span className="text-gray-500 italic">none</span>
|
|
) : (
|
|
<span className="text-yellow-400">
|
|
{Object.entries(s.bindings)
|
|
.map(([k, v]) => `${k}=${v}`)
|
|
.join(', ')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="text-xs mt-0.5">
|
|
<span className="text-gray-500">expression:</span>{' '}
|
|
<code className="text-green-400">{s.expression}</code>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
onClick={handleQuery}
|
|
disabled={!selectedDef || loading}
|
|
className="w-full px-4 py-3 bg-blue-600 hover:bg-blue-700 rounded-lg font-semibold disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-lg shadow-blue-500/20 hover:shadow-blue-500/40"
|
|
>
|
|
{loading ? 'Querying...' : 'Query Projection'}
|
|
</button>
|
|
|
|
{error && (
|
|
<div className="p-4 bg-red-900/20 border border-red-800 rounded-lg text-red-400 text-sm">{error}</div>
|
|
)}
|
|
|
|
{result !== null && (
|
|
<div>
|
|
<h3 className="text-sm font-medium text-gray-400 mb-2">Result</h3>
|
|
<pre className="p-4 bg-gray-950 rounded-lg overflow-x-auto text-sm text-green-300 border border-white/[0.06]">
|
|
{JSON.stringify(result, null, 2)}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Headless UI Combobox for object ref params
|
|
function RefCombobox({
|
|
value,
|
|
onChange,
|
|
objects,
|
|
objectsByType,
|
|
objectType,
|
|
paramName,
|
|
}: {
|
|
value: string
|
|
onChange: (v: string) => void
|
|
objects: Array<{ id: string; type: string }>
|
|
objectsByType: Record<string, string[]>
|
|
objectType?: string
|
|
paramName?: string
|
|
}) {
|
|
const [query, setQuery] = useState('')
|
|
|
|
// Filter objects by object_type if specified, or infer from param name
|
|
// Convention: param name often matches the object type (e.g. param "agent" → type "agent")
|
|
const effectiveObjectType = useMemo(() => {
|
|
if (objectType) return objectType
|
|
if (paramName) {
|
|
const types = new Set(objects.map(o => o.type))
|
|
if (types.has(paramName)) return paramName
|
|
}
|
|
return undefined
|
|
}, [objectType, paramName, objects])
|
|
|
|
const relevantObjects = useMemo(() => {
|
|
if (!effectiveObjectType) return objects
|
|
return objects.filter((o) => o.type === effectiveObjectType)
|
|
}, [objects, effectiveObjectType])
|
|
|
|
const relevantByType = useMemo(() => {
|
|
if (!effectiveObjectType) return objectsByType
|
|
const filtered: Record<string, string[]> = {}
|
|
if (objectsByType[effectiveObjectType]) {
|
|
filtered[effectiveObjectType] = objectsByType[effectiveObjectType]
|
|
}
|
|
return filtered
|
|
}, [objectsByType, effectiveObjectType])
|
|
|
|
const filtered = useMemo(() => {
|
|
if (!query) return relevantObjects
|
|
const q = query.toLowerCase()
|
|
return relevantObjects.filter((o) => String(o.id).toLowerCase().includes(q) || o.type.toLowerCase().includes(q))
|
|
}, [relevantObjects, query])
|
|
|
|
return (
|
|
<Combobox value={value} onChange={(v) => onChange(v || '')}>
|
|
<div className="relative">
|
|
<div className="relative">
|
|
<Combobox.Input
|
|
className="w-full px-3 py-2 pr-10 bg-surface-3 border border-white/[0.08] rounded-lg focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent/30 transition-all"
|
|
placeholder="Type object ID or select..."
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
displayValue={(val: string) => val}
|
|
/>
|
|
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center pr-3">
|
|
<svg className="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
|
|
<path
|
|
fillRule="evenodd"
|
|
d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
|
|
clipRule="evenodd"
|
|
/>
|
|
</svg>
|
|
</Combobox.Button>
|
|
</div>
|
|
<Combobox.Options className="absolute z-10 mt-1 w-full bg-surface-3 border border-gray-700 rounded-lg shadow-lg max-h-60 overflow-auto focus:outline-none">
|
|
{Object.entries(relevantByType).map(([type, ids]) => {
|
|
const filteredIds = ids.filter((id) => !query || id.toLowerCase().includes(query.toLowerCase()))
|
|
if (filteredIds.length === 0) return null
|
|
return (
|
|
<div key={type}>
|
|
<div className="sticky top-0 px-3 py-1.5 text-xs font-medium text-gray-500 bg-surface-3 border-b border-white/[0.06]">
|
|
{type}
|
|
</div>
|
|
{filteredIds.map((id) => (
|
|
<Combobox.Option
|
|
key={id}
|
|
value={id}
|
|
className={({ active }) =>
|
|
`cursor-pointer select-none px-3 py-2 text-sm transition-colors ${
|
|
active ? 'bg-blue-600 text-white' : 'text-gray-100'
|
|
}`
|
|
}
|
|
>
|
|
{({ selected }) => <span className={selected ? 'font-semibold' : 'font-normal'}>{id}</span>}
|
|
</Combobox.Option>
|
|
))}
|
|
</div>
|
|
)
|
|
})}
|
|
{filtered.length === 0 && <div className="px-3 py-2 text-sm text-gray-500">No objects found</div>}
|
|
</Combobox.Options>
|
|
</div>
|
|
</Combobox>
|
|
)
|
|
}
|