feat(ui): add event emit form — schema-driven

This commit is contained in:
小橘 2026-04-13 01:06:56 +00:00
parent d520df29d4
commit 4d63e1943f
29 changed files with 5641 additions and 19 deletions

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,116 @@
# OGraph UI 美化完成报告 🍊
## 完成情况 ✅
### 1. 依赖安装
- ✅ 安装 `@headlessui/react`(无障碍 UI 组件库)
- ✅ 安装 `date-fns`(时间格式化)
### 2. Headless UI 集成
- ✅ **Projections.tsx**: 自定义 RefCombobox → Headless UI Combobox
- ✅ **Projections.tsx**: 投影选择器 → Headless UI Listbox
- ✅ **Objects.tsx**: 类型筛选 → Headless UI Listbox
### 3. 整体美化
#### 3.1 表格样式
- ✅ Striped rows(奇偶行背景色区分)
- ✅ Hover highlight(悬停高亮)
- ✅ 更好的 padding(py-3 px-4)
- ✅ 表头样式提升(uppercase, tracking-wider, 半透明背景)
#### 3.2 JSON 显示
- ✅ 简单的 syntax highlighting(key 蓝色,value 绿色)
- ✅ 边框 + 圆角 + 背景优化
#### 3.3 卡片式布局
- ✅ 所有页面内容区域改用卡片(bg-gray-900/50 + backdrop-blur + border)
- ✅ ProjectionDefs 用展开式卡片布局
#### 3.4 状态标签
- ✅ Hash 标签:monospace + 浅色背景 pill(`HashBadge` 组件)
- ✅ Type 标签:蓝色 pill 样式
#### 3.5 时间戳格式化
- ✅ 相对时间显示("2 min ago")用 `formatRelativeTime` 工具函数
- ✅ 应用于 Events、Objects、Reactions
#### 3.6 空状态提示
- ✅ `EmptyState` 组件(图标 + "No xxx found")
- ✅ 应用于所有列表页面
#### 3.7 Loading Spinner
- ✅ `Spinner` 组件(蓝色环形动画)
- ✅ 应用于所有异步加载场景
- ✅ App.tsx 登录检查也加了 spinner
### 4. 全局样式优化
- ✅ 自定义深色滚动条
- ✅ 渐变背景(from-gray-950 via-gray-900 to-gray-950)
- ✅ 平滑过渡动画(transition-all)
- ✅ fadeIn 动画(页面切换)
### 5. 组件优化
- ✅ Layout: 渐变标题 + 状态指示器 + 改进导航样式
- ✅ App.tsx: 登录页美化 + 渐变标题 + 初始 loading 状态
- ✅ Health: 心跳动画(脉冲绿点)
### 6. 构建 & 部署
- ✅ `npm run build` 成功
- ✅ dist/index.html: **331KB** < 500KB
- ✅ 已复制到 `../src/ui.html`
## 关键文件变更
```
ui/src/
├── components/
│ ├── Common.tsx (新增) — Spinner, EmptyState, HashBadge
│ ├── Projections.tsx (重写) — Headless UI Combobox + Listbox
│ ├── Objects.tsx (重写) — Headless UI Listbox
│ ├── Events.tsx (美化) — 相对时间 + JSON 高亮
│ ├── EventDefs.tsx (美化) — 卡片 + 标签
│ ├── ProjectionDefs.tsx (美化) — 展开式卡片 + JSON 高亮
│ ├── Reactions.tsx (美化) — 相对时间 + 按钮美化
│ ├── ObjectDefs.tsx (美化) — 表格优化
│ ├── Health.tsx (美化) — 心跳动画
│ ├── Layout.tsx (美化) — 渐变标题 + 导航优化
│ └── App.tsx (优化) — 登录页美化 + loading
├── utils.ts (新增) — 时间格式化
└── index.css (扩展) — 全局样式 + 动画
```
## 技术亮点
- 🎨 **Headless UI**: 无障碍组件库,键盘导航友好
- ⏰ **date-fns**: 轻量时间格式化(相对时间)
- 🎭 **backdrop-blur**: 毛玻璃效果,现代感强
- 🌈 **渐变标题**: bg-clip-text 文字渐变
- ⚡ **CSS 动画**: 脉冲、淡入、旋转 spinner
- 📦 **单文件构建**: vite-plugin-singlefile,331KB
## 最终效果
- 暗色主题(bg-gray-950, text-gray-100)✅
- 响应式卡片布局 ✅
- 高对比度表格(striped + hover)✅
- JSON 语法高亮 ✅
- 相对时间显示 ✅
- 空状态友好提示 ✅
- 流畅加载动画 ✅
- 文件体积 < 500KB
小橘 🍊 (NEKO Team)
2026-04-12

View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OGraph UI</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3043
packages/engine/ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,28 @@
{
"name": "ograph-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@headlessui/react": "^2.2.10",
"date-fns": "^4.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"vite": "^6.0.5",
"vite-plugin-singlefile": "^2.0.2"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -0,0 +1,109 @@
import { useState, useEffect } from 'react'
import Layout from './components/Layout'
import Health from './components/Health'
import ObjectDefs from './components/ObjectDefs'
import Objects from './components/Objects'
import EventDefs from './components/EventDefs'
import Events from './components/Events'
import ProjectionDefs from './components/ProjectionDefs'
import Projections from './components/Projections'
import Reactions from './components/Reactions'
import ApiKeys from './components/ApiKeys'
import ReactionLogs from './components/ReactionLogs'
import RequestLogs from './components/RequestLogs'
import { getToken, setToken } from './api'
import { Spinner } from './components/Common'
type Page =
| 'health'
| 'object-defs'
| 'objects'
| 'event-defs'
| 'events'
| 'projection-defs'
| 'projections'
| 'reactions'
| 'api-keys'
| 'reaction-logs'
| 'request-logs'
function App() {
const [page, setPage] = useState<Page>('health')
const [needsAuth, setNeedsAuth] = useState(false)
const [tokenInput, setTokenInput] = useState('')
const [checking, setChecking] = useState(true)
useEffect(() => {
setTimeout(() => {
if (!getToken()) {
setNeedsAuth(true)
}
setChecking(false)
}, 100)
}, [])
const handleAuth = () => {
if (tokenInput.trim()) {
setToken(tokenInput.trim())
setNeedsAuth(false)
}
}
if (checking) {
return (
<div className="flex items-center justify-center h-screen bg-gray-950">
<Spinner />
</div>
)
}
if (needsAuth) {
return (
<div className="flex items-center justify-center h-screen bg-gray-950 text-gray-100 px-4">
<div className="bg-gray-900/80 backdrop-blur border border-gray-800 p-8 rounded-xl shadow-2xl max-w-md w-full animate-fadeIn">
<div className="text-center mb-6">
<h2 className="text-3xl font-bold mb-2 bg-gradient-to-r from-blue-400 to-purple-500 bg-clip-text text-transparent">
OGraph UI
</h2>
<p className="text-gray-400 text-sm">Enter your API token to continue</p>
</div>
<input
type="text"
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg mb-4 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder="API Token"
value={tokenInput}
onChange={(e) => setTokenInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAuth()}
autoFocus
/>
<button
onClick={handleAuth}
className="w-full px-4 py-3 bg-blue-600 hover:bg-blue-700 rounded-lg font-semibold shadow-lg shadow-blue-500/30 hover:shadow-blue-500/50 transition-all"
>
Continue
</button>
</div>
</div>
)
}
return (
<Layout page={page} onPageChange={setPage}>
<div className="animate-fadeIn">
{page === 'health' && <Health />}
{page === 'object-defs' && <ObjectDefs />}
{page === 'objects' && <Objects />}
{page === 'event-defs' && <EventDefs />}
{page === 'events' && <Events />}
{page === 'projection-defs' && <ProjectionDefs />}
{page === 'projections' && <Projections />}
{page === 'reactions' && <Reactions />}
{page === 'api-keys' && <ApiKeys />}
{page === 'reaction-logs' && <ReactionLogs />}
{page === 'request-logs' && <RequestLogs />}
</div>
</Layout>
)
}
export default App

View File

@ -0,0 +1,103 @@
const BASE = ''
let token = localStorage.getItem('ograph_token') || ''
export function setToken(t: string) {
token = t
localStorage.setItem('ograph_token', t)
}
export function getToken() {
return token
}
export async function api<T = any>(path: string, opts?: RequestInit): Promise<T> {
const res = await fetch(BASE + path, {
...opts,
headers: {
Authorization: token ? `Bearer ${token}` : '',
'Content-Type': 'application/json',
...opts?.headers,
},
})
if (res.status === 401) {
throw new Error('UNAUTHORIZED')
}
if (!res.ok) {
throw new Error(`API error: ${res.status}`)
}
return res.json()
}
// Health
export const getHealth = () => api<{ version: string }>('/health')
// Object Defs
export const getObjectDefs = () => api<{ object_defs: Array<{ name: string }> }>('/object-defs')
// Objects
export const getObjects = (type?: string, limit = 50, offset = 0) =>
api<{ objects: Array<{ id: string; type: string; created_at: string }>; total: number }>(
type ? `/objects?type=${type}&limit=${limit}&offset=${offset}` : `/objects?limit=${limit}&offset=${offset}`,
)
// Event Defs
export const getEventDefs = () =>
api<{ event_defs: Array<{ name: string; hash: string; parent_hash: string | null; schema: any }> }>('/event-defs')
// Events
export const getEvents = (ref?: string, limit = 50, offset = 0) =>
api<{ events: Array<{ id: string; type_hash: string; payload: any; created_at: string }>; total: number }>(
ref ? `/events?ref=${ref}&limit=${limit}&offset=${offset}` : `/events?limit=${limit}&offset=${offset}`,
)
// Projection Defs
export const getProjectionDefs = () => api<{ projection_defs: Array<any> }>('/projection-defs')
// Query Projection
export const queryProjection = (name: string, params: Record<string, any>) => {
const qs = new URLSearchParams()
for (const [k, v] of Object.entries(params)) {
if (v !== '' && v !== undefined) qs.set(k, String(v))
}
return api<{ value: any }>(`/projections/${name}?${qs.toString()}`)
}
// Reactions
export const getReactions = (limit = 50, offset = 0) =>
api<{ reactions: Array<any>; total: number }>(`/reactions?limit=${limit}&offset=${offset}`)
export const deleteReaction = (id: string) => api(`/reactions/${id}`, { method: 'DELETE' })
// API Keys
export const getApiKeys = (limit = 50, offset = 0) =>
api<{ api_keys: Array<any>; total: number }>(`/api-keys?limit=${limit}&offset=${offset}`)
export const createApiKey = (data: { name: string; allowed_events?: string[]; rate_limit?: number }) =>
api<{ api_key: any; plaintext_key: string }>('/api-keys', { method: 'POST', body: JSON.stringify(data) })
export const deleteApiKey = (id: number) => api(`/api-keys/${id}`, { method: 'DELETE' })
// Emit Event
export const createEvent = (type: string, payload: Record<string, any>) =>
api<{ event: any; reactions_fired: number; reaction_results: any[] }>('/events', {
method: 'POST',
body: JSON.stringify({ type, payload }),
})
// Reaction Logs
export const getReactionLogs = (limit = 50, offset = 0, reactionId?: number) => {
let url = `/reaction-logs?limit=${limit}&offset=${offset}`
if (reactionId) url += `&reaction_id=${reactionId}`
return api<{ reaction_logs: Array<any>; total: number }>(url)
}
// Request Logs
export const getRequestLogs = (limit = 50, offset = 0, apiKeyId?: number) => {
let url = `/request-logs?limit=${limit}&offset=${offset}`
if (apiKeyId) url += `&api_key_id=${apiKeyId}`
return api<{ request_logs: Array<any>; total: number }>(url)
}

View File

@ -0,0 +1,238 @@
import { useState, useEffect } from 'react'
import { getApiKeys, createApiKey, deleteApiKey } from '../api'
import { formatRelativeTime } from '../utils'
import { Spinner, EmptyState, Pagination } from './Common'
export default function ApiKeys() {
const [data, setData] = useState<any[]>([])
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [limit, setLimit] = useState(50)
const [offset, setOffset] = useState(0)
const [name, setName] = useState('')
const [allowedEvents, setAllowedEvents] = useState('')
const [rateLimit, setRateLimit] = useState('')
const [creating, setCreating] = useState(false)
const [shownKey, setShownKey] = useState('')
const [copied, setCopied] = useState(false)
const load = () => {
setLoading(true)
getApiKeys(limit, offset)
.then((res) => {
setData(res.api_keys)
setTotal(res.total)
})
.catch((e) => setError(e.message))
.finally(() => setLoading(false))
}
useEffect(() => {
load()
}, [limit, offset])
const handleCreate = async () => {
if (!name.trim()) return
setCreating(true)
try {
const payload: { name: string; allowed_events?: string[]; rate_limit?: number } = { name: name.trim() }
if (allowedEvents.trim()) {
payload.allowed_events = allowedEvents
.split(',')
.map((s) => s.trim())
.filter(Boolean)
}
if (rateLimit.trim()) {
payload.rate_limit = parseInt(rateLimit, 10)
}
const res = await createApiKey(payload)
setShownKey(res.plaintext_key)
setCopied(false)
setName('')
setAllowedEvents('')
setRateLimit('')
load()
} catch (e: any) {
setError(e.message)
} finally {
setCreating(false)
}
}
const handleDelete = async (id: number) => {
if (!confirm('Delete this API key?')) return
try {
await deleteApiKey(id)
load()
} catch (e: any) {
setError(e.message)
}
}
const handleCopy = () => {
navigator.clipboard.writeText(shownKey)
setCopied(true)
}
if (loading) return <Spinner />
if (error) return <div className="text-red-500 text-center p-8">Error: {error}</div>
return (
<div className="max-w-7xl mx-auto">
<h2 className="text-2xl font-bold mb-6">API Keys</h2>
{/* Key reveal modal */}
{shownKey && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 max-w-lg w-full mx-4 shadow-2xl">
<h3 className="text-lg font-bold text-yellow-400 mb-2">Save your API key</h3>
<p className="text-sm text-gray-400 mb-4">
This key will only be shown once. Copy it now and store it securely.
</p>
<div className="bg-gray-800 rounded-lg p-3 font-mono text-sm text-green-400 break-all mb-4">{shownKey}</div>
<div className="flex gap-3 justify-end">
<button
onClick={handleCopy}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-sm font-medium transition-colors"
>
{copied ? 'Copied!' : 'Copy'}
</button>
<button
onClick={() => setShownKey('')}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm font-medium transition-colors"
>
Close
</button>
</div>
</div>
</div>
)}
{/* Create form */}
<div className="bg-gray-900/50 backdrop-blur rounded-lg border border-gray-800 p-4 mb-6">
<div className="flex flex-wrap items-end gap-3">
<div>
<label className="block text-xs text-gray-400 mb-1">Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="my-service"
className="px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-xs text-gray-400 mb-1">Allowed Events (comma-separated)</label>
<input
type="text"
value={allowedEvents}
onChange={(e) => setAllowedEvents(e.target.value)}
placeholder="order.created, user.signed_up"
className="px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 w-64"
/>
</div>
<div>
<label className="block text-xs text-gray-400 mb-1">Rate Limit</label>
<input
type="number"
value={rateLimit}
onChange={(e) => setRateLimit(e.target.value)}
placeholder="1000"
className="px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 w-24"
/>
</div>
<button
onClick={handleCreate}
disabled={creating || !name.trim()}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-lg text-sm font-medium transition-colors shadow-lg shadow-blue-500/20"
>
{creating ? 'Creating...' : 'Create'}
</button>
</div>
</div>
<div className="bg-gray-900/50 backdrop-blur rounded-lg overflow-hidden border border-gray-800">
{data.length === 0 ? (
<EmptyState message="No API keys found" />
) : (
<table className="w-full">
<thead className="bg-gray-800/80 border-b border-gray-700">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">ID</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
Name
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
Role
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
Allowed Events
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
Rate Limit
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
Last Used
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
Created
</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{data.map((key, i) => (
<tr
key={key.id}
className={`transition-colors ${
i % 2 === 0 ? 'bg-gray-900/30' : 'bg-gray-850/30'
} hover:bg-gray-800/60`}
>
<td className="px-4 py-3 font-mono text-sm text-gray-300">{key.id}</td>
<td className="px-4 py-3 text-sm text-gray-200 font-medium">{key.name}</td>
<td className="px-4 py-3">
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-500/20 text-purple-300 border border-purple-500/30">
{key.role || 'default'}
</span>
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{key.allowed_events && key.allowed_events.length > 0 ? (
key.allowed_events.map((evt: string, j: number) => (
<span
key={j}
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-500/20 text-blue-300 border border-blue-500/30"
>
{evt}
</span>
))
) : (
<span className="text-gray-500 text-xs">all</span>
)}
</div>
</td>
<td className="px-4 py-3 text-sm text-gray-400 font-mono">{key.rate_limit ?? '—'}</td>
<td className="px-4 py-3 text-sm text-gray-400">
{key.last_used_at ? formatRelativeTime(key.last_used_at) : '—'}
</td>
<td className="px-4 py-3 text-sm text-gray-400">{formatRelativeTime(key.created_at)}</td>
<td className="px-4 py-3">
<button
onClick={() => handleDelete(key.id)}
className="px-3 py-1.5 bg-red-600 hover:bg-red-700 rounded text-sm font-medium transition-colors shadow-lg shadow-red-500/20 hover:shadow-red-500/40"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
<Pagination total={total} limit={limit} offset={offset} onPageChange={setOffset} onLimitChange={setLimit} />
</div>
</div>
)
}

View File

@ -0,0 +1,98 @@
export function Spinner() {
return (
<div className="flex items-center justify-center p-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
</div>
)
}
export function EmptyState({ message = 'No data found' }: { message?: string }) {
return (
<div className="flex items-center justify-center p-12 text-gray-500">
<div className="text-center">
<svg className="mx-auto h-12 w-12 text-gray-600 mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
/>
</svg>
<p>{message}</p>
</div>
</div>
)
}
export function HashBadge({ hash, short = true }: { hash: string; short?: boolean }) {
const display = short && hash.length > 8 ? hash.slice(0, 8) : hash
return (
<span className="inline-block px-2 py-1 bg-gray-800/50 rounded font-mono text-xs text-gray-400">{display}</span>
)
}
export function Pagination({
total,
limit,
offset,
onPageChange,
onLimitChange,
}: {
total: number
limit: number
offset: number
onPageChange: (newOffset: number) => void
onLimitChange: (newLimit: number) => void
}) {
const currentPage = Math.floor(offset / limit) + 1
const totalPages = Math.ceil(total / limit)
const startItem = total === 0 ? 0 : offset + 1
const endItem = Math.min(offset + limit, total)
const canPrev = offset > 0
const canNext = offset + limit < total
return (
<div className="flex items-center justify-between border-t border-gray-800 bg-gray-900/50 px-4 py-3 mt-4">
<div className="flex items-center gap-4">
<span className="text-sm text-gray-400">
{startItem}-{endItem} of {total}
</span>
<div className="flex items-center gap-2">
<label className="text-sm text-gray-500">Per page:</label>
<select
value={limit}
onChange={(e) => {
onLimitChange(parseInt(e.target.value, 10))
onPageChange(0) // reset to first page
}}
className="bg-gray-800 text-gray-300 border border-gray-700 rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => onPageChange(offset - limit)}
disabled={!canPrev}
className="px-3 py-1 bg-gray-800 text-gray-300 rounded border border-gray-700 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-700 text-sm"
>
Previous
</button>
<span className="text-sm text-gray-400">
Page {currentPage} / {totalPages || 1}
</span>
<button
onClick={() => onPageChange(offset + limit)}
disabled={!canNext}
className="px-3 py-1 bg-gray-800 text-gray-300 rounded border border-gray-700 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-700 text-sm"
>
Next
</button>
</div>
</div>
)
}

View File

@ -0,0 +1,174 @@
import { useState } from 'react'
import { createEvent } from '../api'
interface EventDef {
name: string
schema: {
properties?: Record<string, { type: string }>
}
}
interface EmitEventModalProps {
eventDef: EventDef
onClose: () => void
onSuccess: () => void
}
export default function EmitEventModal({ eventDef, onClose, onSuccess }: EmitEventModalProps) {
const properties = eventDef.schema?.properties || {}
const fieldNames = Object.keys(properties)
const [values, setValues] = useState<Record<string, any>>(() => {
const init: Record<string, any> = {}
for (const [key, def] of Object.entries(properties)) {
if (def.type === 'boolean') init[key] = false
else if (def.type === 'number' || def.type === 'ref') init[key] = ''
else init[key] = ''
}
return init
})
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState<{ eventId: string; reactionsFired: number } | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError('')
setSuccess(null)
try {
// Build payload with correct types
const payload: Record<string, any> = {}
for (const [key, def] of Object.entries(properties)) {
const val = values[key]
if (def.type === 'number' || def.type === 'ref') {
payload[key] = val === '' ? 0 : Number(val)
} else if (def.type === 'boolean') {
payload[key] = Boolean(val)
} else {
payload[key] = String(val)
}
}
const res = await createEvent(eventDef.name, payload)
setSuccess({
eventId: res.event?.id ?? '?',
reactionsFired: res.reactions_fired ?? 0,
})
setTimeout(() => {
onSuccess()
onClose()
}, 2000)
} catch (err: any) {
setError(err.message || 'Failed to emit event')
} finally {
setLoading(false)
}
}
const renderField = (name: string, def: { type: string }) => {
if (def.type === 'boolean') {
return (
<label key={name} className="flex items-center gap-3 py-2">
<input
type="checkbox"
checked={!!values[name]}
onChange={(e) => setValues((v) => ({ ...v, [name]: e.target.checked }))}
className="w-4 h-4 rounded border-gray-600 bg-gray-800 text-blue-500 focus:ring-blue-500 focus:ring-offset-0"
/>
<span className="text-sm text-gray-200 font-mono">{name}</span>
</label>
)
}
const isNumeric = def.type === 'number' || def.type === 'ref'
const label = def.type === 'ref' ? `${name} (Object ID)` : name
return (
<div key={name} className="space-y-1">
<label className="block text-sm text-gray-300 font-mono">{label}</label>
<input
type={isNumeric ? 'number' : 'text'}
value={values[name]}
onChange={(e) => setValues((v) => ({ ...v, [name]: e.target.value }))}
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-gray-100 text-sm font-mono placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder={def.type === 'ref' ? 'Enter object ID' : `Enter ${def.type}`}
/>
</div>
)
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={onClose}>
<div
className="bg-gray-900 border border-gray-700 rounded-xl shadow-2xl w-full max-w-md mx-4"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-800">
<h3 className="text-lg font-semibold text-gray-100">
Emit <span className="text-blue-400 font-mono">{eventDef.name}</span>
</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-200 transition-colors"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Body */}
<form onSubmit={handleSubmit} className="px-6 py-4 space-y-4">
{fieldNames.length === 0 ? (
<p className="text-sm text-gray-500 italic">No payload fields event will be emitted with empty payload.</p>
) : (
fieldNames.map((name) => renderField(name, properties[name]))
)}
{/* Success message */}
{success && (
<div className="p-3 bg-green-900/40 border border-green-700 rounded-lg text-sm text-green-300">
Event <span className="font-mono font-bold">{success.eventId}</span> emitted
{success.reactionsFired > 0 && (
<span> {success.reactionsFired} reaction{success.reactionsFired > 1 ? 's' : ''} fired</span>
)}
</div>
)}
{/* Error message */}
{error && (
<div className="p-3 bg-red-900/40 border border-red-700 rounded-lg text-sm text-red-300">
{error}
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm text-gray-400 hover:text-gray-200 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={loading || !!success}
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-800 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors flex items-center gap-2"
>
{loading && (
<div className="animate-spin rounded-full h-4 w-4 border-2 border-white/30 border-t-white"></div>
)}
{loading ? 'Emitting…' : '▶ Emit'}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@ -0,0 +1,136 @@
import { useState, useEffect } from 'react'
import { getEventDefs } from '../api'
import { Spinner, EmptyState, HashBadge } from './Common'
import EmitEventModal from './EmitEventModal'
export default function EventDefs() {
const [data, setData] = useState<Array<{ name: string; hash: string; parent_hash: string | null; schema: any }>>([])
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const [emitTarget, setEmitTarget] = useState<{ name: string; schema: any } | null>(null)
const fetchData = () => {
setLoading(true)
getEventDefs()
.then((res) => setData(res.event_defs))
.catch((e) => setError(e.message))
.finally(() => setLoading(false))
}
useEffect(() => {
fetchData()
}, [])
const toggleExpand = (hash: string) => {
setExpanded((prev) => {
const next = new Set(prev)
if (next.has(hash)) {
next.delete(hash)
} else {
next.add(hash)
}
return next
})
}
if (loading) return <Spinner />
if (error) return <div className="text-red-500 text-center p-8">Error: {error}</div>
return (
<div className="max-w-6xl mx-auto">
<h2 className="text-2xl font-bold mb-6">Event Definitions</h2>
<div className="bg-gray-900/50 backdrop-blur rounded-lg overflow-hidden border border-gray-800">
{data.length === 0 ? (
<EmptyState message="No event definitions found" />
) : (
<table className="w-full">
<thead className="bg-gray-800/80 border-b border-gray-700">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
Name
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
Hash
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
Parent
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
Schema
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{data.map((def, i) => (
<tr
key={i}
className={`transition-colors ${
i % 2 === 0 ? 'bg-gray-900/30' : 'bg-gray-850/30'
} hover:bg-gray-800/60`}
>
<td className="px-4 py-3 font-mono text-gray-100">{def.name}</td>
<td className="px-4 py-3">
<HashBadge hash={def.hash} />
</td>
<td className="px-4 py-3">
{def.parent_hash ? <HashBadge hash={def.parent_hash} /> : <span className="text-gray-600">-</span>}
</td>
<td className="px-4 py-3">
<button
onClick={() => toggleExpand(def.hash)}
className="text-blue-400 hover:text-blue-300 text-sm font-medium transition-colors"
>
{expanded.has(def.hash) ? 'Hide' : 'Show'}
</button>
{expanded.has(def.hash) && (
<pre className="mt-2 p-3 bg-gray-950 rounded-lg text-xs overflow-x-auto border border-gray-800">
{JSON.stringify(def.schema, null, 2)
.split('\n')
.map((line, i) => {
if (line.includes(':')) {
const [key, ...rest] = line.split(':')
return (
<div key={i}>
<span className="text-blue-400">{key}:</span>
<span className="text-green-300">{rest.join(':')}</span>
</div>
)
}
return (
<div key={i} className="text-gray-300">
{line}
</div>
)
})}
</pre>
)}
</td>
<td className="px-4 py-3">
<button
onClick={() => setEmitTarget({ name: def.name, schema: def.schema })}
className="px-3 py-1.5 bg-green-700/30 hover:bg-green-600/40 text-green-300 hover:text-green-200 border border-green-700/50 rounded-lg text-sm font-medium transition-colors"
>
Emit
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{emitTarget && (
<EmitEventModal
eventDef={emitTarget}
onClose={() => setEmitTarget(null)}
onSuccess={() => {}}
/>
)}
</div>
)
}

View File

@ -0,0 +1,142 @@
import { useState, useEffect } from 'react'
import { getEvents } from '../api'
import { formatRelativeTime } from '../utils'
import { Spinner, EmptyState, HashBadge, Pagination } from './Common'
export default function Events() {
const [data, setData] = useState<Array<{ id: string; type_hash: string; payload: any; created_at: string }>>([])
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [refFilter, setRefFilter] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [limit, setLimit] = useState(50)
const [offset, setOffset] = useState(0)
const load = () => {
setLoading(true)
getEvents(refFilter || undefined, limit, offset)
.then((res) => {
setData(res.events)
setTotal(res.total)
})
.catch((e) => setError(e.message))
.finally(() => setLoading(false))
}
useEffect(() => {
load()
}, [limit, offset])
const handleSearch = () => {
setOffset(0) // reset to first page
load()
}
const toggleExpand = (id: string) => {
setExpanded((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}
if (loading) return <Spinner />
if (error) return <div className="text-red-500">Error: {error}</div>
return (
<div className="max-w-6xl mx-auto">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold">Events</h2>
<div className="flex gap-2">
<input
type="text"
className="px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 transition-all"
placeholder="Filter by ref..."
value={refFilter}
onChange={(e) => setRefFilter(e.target.value)}
/>
<button
onClick={handleSearch}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg font-semibold transition-all shadow-lg shadow-blue-500/20 hover:shadow-blue-500/40"
>
Search
</button>
</div>
</div>
<div className="bg-gray-900/50 backdrop-blur rounded-lg overflow-hidden border border-gray-800">
{data.length === 0 ? (
<EmptyState message="No events yet" />
) : (
<table className="w-full">
<thead className="bg-gray-800/80 border-b border-gray-700">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">ID</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
Type Hash
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
Created
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
Payload
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{data.map((event, i) => (
<tr
key={i}
className={`transition-colors ${
i % 2 === 0 ? 'bg-gray-900/30' : 'bg-gray-850/30'
} hover:bg-gray-800/60`}
>
<td className="px-4 py-3 font-mono text-sm text-gray-300">{event.id}</td>
<td className="px-4 py-3">
<HashBadge hash={event.type_hash} />
</td>
<td className="px-4 py-3 text-sm text-gray-400">{formatRelativeTime(event.created_at)}</td>
<td className="px-4 py-3">
<button
onClick={() => toggleExpand(event.id)}
className="text-blue-400 hover:text-blue-300 text-sm font-medium transition-colors"
>
{expanded.has(event.id) ? 'Hide' : 'Show'}
</button>
{expanded.has(event.id) && (
<pre className="mt-2 p-3 bg-gray-950 rounded-lg text-xs overflow-x-auto border border-gray-800">
{JSON.stringify(event.payload, null, 2)
.split('\n')
.map((line, i) => {
if (line.includes(':')) {
const [key, ...rest] = line.split(':')
return (
<div key={i}>
<span className="text-blue-400">{key}:</span>
<span className="text-green-300">{rest.join(':')}</span>
</div>
)
}
return (
<div key={i} className="text-gray-300">
{line}
</div>
)
})}
</pre>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
<Pagination total={total} limit={limit} offset={offset} onPageChange={setOffset} onLimitChange={setLimit} />
</div>
</div>
)
}

View File

@ -0,0 +1,38 @@
import { useState, useEffect } from 'react'
import { getHealth } from '../api'
import { Spinner } from './Common'
export default function Health() {
const [data, setData] = useState<{ version: string } | null>(null)
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
getHealth()
.then(setData)
.catch((e) => setError(e.message))
.finally(() => setLoading(false))
}, [])
if (loading) return <Spinner />
if (error) return <div className="text-red-500 text-center p-8">Error: {error}</div>
return (
<div className="max-w-2xl mx-auto">
<h2 className="text-2xl font-bold mb-6">Health Check</h2>
<div className="bg-gray-900/50 backdrop-blur border border-gray-800 rounded-lg p-8">
<div className="flex items-center gap-3 mb-4">
<div className="relative">
<div className="w-4 h-4 bg-green-500 rounded-full"></div>
<div className="absolute inset-0 w-4 h-4 bg-green-500 rounded-full animate-ping opacity-75"></div>
</div>
<span className="text-green-500 font-semibold text-lg">System Online</span>
</div>
<div className="text-gray-400 mt-4 flex items-baseline gap-2">
<span>Version:</span>
<span className="text-white font-mono bg-gray-800/50 px-3 py-1 rounded">{data?.version}</span>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,108 @@
import { ReactNode } from 'react'
type Page =
| 'health'
| 'object-defs'
| 'objects'
| 'event-defs'
| 'events'
| 'projection-defs'
| 'projections'
| 'reactions'
| 'api-keys'
| 'reaction-logs'
| 'request-logs'
interface Props {
page: Page
onPageChange: (page: Page) => void
children: ReactNode
}
const navGroups: Array<{ label: string; items: Array<{ id: Page; label: string; icon: string }> }> = [
{
label: 'Schema',
items: [
{ id: 'object-defs', label: 'Object Defs', icon: '📦' },
{ id: 'event-defs', label: 'Event Defs', icon: '📋' },
{ id: 'projection-defs', label: 'Projection Defs', icon: '📊' },
],
},
{
label: 'Data',
items: [
{ id: 'objects', label: 'Objects', icon: '🗂️' },
{ id: 'events', label: 'Events', icon: '⚡' },
{ id: 'projections', label: 'Projections', icon: '📈' },
{ id: 'reactions', label: 'Reactions', icon: '🔔' },
],
},
{
label: 'Observability',
items: [
{ id: 'reaction-logs', label: 'Reaction Logs', icon: '📜' },
{ id: 'request-logs', label: 'Request Logs', icon: '📝' },
],
},
{
label: 'Security',
items: [{ id: 'api-keys', label: 'API Keys', icon: '🔑' }],
},
{
label: 'System',
items: [{ id: 'health', label: 'Health', icon: '🏠' }],
},
]
export default function Layout({ page, onPageChange, children }: Props) {
return (
<div className="flex h-screen bg-gray-950 text-gray-100">
{/* Sidebar */}
<aside className="w-64 bg-gray-900/50 backdrop-blur border-r border-gray-800 flex flex-col">
<div className="p-6 border-b border-gray-800">
<h1 className="text-2xl font-bold bg-gradient-to-r from-blue-400 to-purple-500 bg-clip-text text-transparent">
OGraph UI
</h1>
<p className="text-xs text-gray-500 mt-1">Event Sourcing Dashboard</p>
</div>
<nav className="flex-1 overflow-y-auto p-3">
{navGroups.map((group) => (
<div key={group.label} className="mb-4">
<div className="px-4 py-1 text-xs font-semibold text-gray-500 uppercase tracking-wider">
{group.label}
</div>
{group.items.map((item) => (
<button
key={item.id}
onClick={() => onPageChange(item.id)}
className={`
w-full text-left px-4 py-2.5 rounded-lg mb-1 flex items-center gap-3 font-medium transition-all
${
page === item.id
? 'bg-blue-600 text-white shadow-lg shadow-blue-500/30'
: 'text-gray-400 hover:bg-gray-800/60 hover:text-gray-100'
}
`}
>
<span className="text-lg">{item.icon}</span>
<span className="text-sm">{item.label}</span>
</button>
))}
</div>
))}
</nav>
<div className="p-4 border-t border-gray-800 text-xs text-gray-600">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
<span>Connected</span>
</div>
</div>
</aside>
{/* Main content */}
<main className="flex-1 overflow-y-auto bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950">
<div className="p-8 max-w-[1600px] mx-auto">{children}</div>
</main>
</div>
)
}

View File

@ -0,0 +1,52 @@
import { useState, useEffect } from 'react'
import { getObjectDefs } from '../api'
import { Spinner, EmptyState } from './Common'
export default function ObjectDefs() {
const [data, setData] = useState<Array<{ name: string }>>([])
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
getObjectDefs()
.then((res) => setData(res.object_defs))
.catch((e) => setError(e.message))
.finally(() => setLoading(false))
}, [])
if (loading) return <Spinner />
if (error) return <div className="text-red-500 text-center p-8">Error: {error}</div>
return (
<div className="max-w-4xl mx-auto">
<h2 className="text-2xl font-bold mb-6">Object Definitions</h2>
<div className="bg-gray-900/50 backdrop-blur rounded-lg overflow-hidden border border-gray-800">
{data.length === 0 ? (
<EmptyState message="No object definitions found" />
) : (
<table className="w-full">
<thead className="bg-gray-800/80 border-b border-gray-700">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
Name
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{data.map((def, i) => (
<tr
key={i}
className={`transition-colors ${
i % 2 === 0 ? 'bg-gray-900/30' : 'bg-gray-850/30'
} hover:bg-gray-800/60`}
>
<td className="px-4 py-3 font-mono text-gray-100">{def.name}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,128 @@
import { useState, useEffect } from 'react'
import { Listbox } from '@headlessui/react'
import { getObjects, getObjectDefs } from '../api'
import { formatRelativeTime } from '../utils'
import { Spinner, EmptyState, Pagination } from './Common'
export default function Objects() {
const [data, setData] = useState<Array<{ id: string; type: string; created_at: string }>>([])
const [types, setTypes] = useState<string[]>([])
const [filter, setFilter] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [limit, setLimit] = useState(50)
const [offset, setOffset] = useState(0)
useEffect(() => {
getObjectDefs()
.then((res) => setTypes(res.object_defs.map((d) => d.name)))
.catch(() => {})
}, [])
useEffect(() => {
setLoading(true)
getObjects(filter || undefined, limit, offset)
.then((res) => {
setData(res.objects)
setTotal(res.total)
})
.catch((e) => setError(e.message))
.finally(() => setLoading(false))
}, [filter, limit, offset])
const handleFilterChange = (newFilter: string) => {
setFilter(newFilter)
setOffset(0) // reset to first page
}
if (loading) return <Spinner />
if (error) return <div className="text-red-500">Error: {error}</div>
return (
<div className="max-w-6xl mx-auto">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold">Objects</h2>
<Listbox value={filter} onChange={handleFilterChange}>
<div className="relative w-64">
<Listbox.Button className="w-full px-4 py-2 bg-gray-800 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={filter ? 'text-gray-100' : 'text-gray-500'}>{filter || 'All Types'}</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-gray-800 border border-gray-700 rounded-lg shadow-lg max-h-60 overflow-auto focus:outline-none">
<Listbox.Option
value=""
className={({ active }) =>
`cursor-pointer select-none px-4 py-2 transition-colors ${
active ? 'bg-blue-600 text-white' : 'text-gray-100'
}`
}
>
{({ selected }) => <span className={selected ? 'font-semibold' : 'font-normal'}>All Types</span>}
</Listbox.Option>
{types.map((t) => (
<Listbox.Option
key={t}
value={t}
className={({ active }) =>
`cursor-pointer select-none px-4 py-2 transition-colors ${
active ? 'bg-blue-600 text-white' : 'text-gray-100'
}`
}
>
{({ selected }) => <span className={selected ? 'font-semibold' : 'font-normal'}>{t}</span>}
</Listbox.Option>
))}
</Listbox.Options>
</div>
</Listbox>
</div>
<div className="bg-gray-900/50 backdrop-blur rounded-lg overflow-hidden border border-gray-800">
{data.length === 0 ? (
<EmptyState message="No objects found" />
) : (
<table className="w-full">
<thead className="bg-gray-800/80 border-b border-gray-700">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">ID</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
Type
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
Created
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{data.map((obj, i) => (
<tr
key={i}
className={`transition-colors ${
i % 2 === 0 ? 'bg-gray-900/30' : 'bg-gray-850/30'
} hover:bg-gray-800/60`}
>
<td className="px-4 py-3 font-mono text-sm text-gray-300">{obj.id}</td>
<td className="px-4 py-3 text-gray-100">
<span className="inline-block px-2 py-1 bg-blue-900/30 text-blue-300 rounded text-sm">
{obj.type}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-400">{formatRelativeTime(obj.created_at)}</td>
</tr>
))}
</tbody>
</table>
)}
<Pagination total={total} limit={limit} offset={offset} onPageChange={setOffset} onLimitChange={setLimit} />
</div>
</div>
)
}

View File

@ -0,0 +1,130 @@
import { useState, useEffect } from 'react'
import { getProjectionDefs } from '../api'
import { Spinner, EmptyState, HashBadge } from './Common'
export default function ProjectionDefs() {
const [data, setData] = useState<any[]>([])
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
getProjectionDefs()
.then((res) => setData(res.projection_defs))
.catch((e) => setError(e.message))
.finally(() => setLoading(false))
}, [])
const toggleExpand = (hash: string) => {
setExpanded((prev) => {
const next = new Set(prev)
if (next.has(hash)) {
next.delete(hash)
} else {
next.add(hash)
}
return next
})
}
if (loading) return <Spinner />
if (error) return <div className="text-red-500 text-center p-8">Error: {error}</div>
return (
<div className="max-w-5xl mx-auto">
<h2 className="text-2xl font-bold mb-6">Projection Definitions</h2>
{data.length === 0 ? (
<EmptyState message="No projection definitions found" />
) : (
<div className="space-y-4">
{data.map((def, i) => (
<div
key={i}
className="bg-gray-900/50 backdrop-blur rounded-lg p-5 border border-gray-800 hover:border-gray-700 transition-colors"
>
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="font-mono text-lg text-gray-100">{def.name}</h3>
<div className="mt-1">
<HashBadge hash={def.hash || 'unknown'} short={false} />
</div>
</div>
<button
onClick={() => toggleExpand(def.hash || i)}
className="text-blue-400 hover:text-blue-300 text-sm font-medium transition-colors"
>
{expanded.has(def.hash || i) ? 'Hide Details' : 'Show Details'}
</button>
</div>
{expanded.has(def.hash || i) && (
<div className="mt-4 space-y-3 text-sm bg-gray-800/30 rounded-lg p-4 border border-gray-700/50">
{def.sources && (
<div>
<span className="text-gray-400 font-medium">Sources:</span>
<div className="mt-2 space-y-3">
{def.sources.map((s: any, i: number) => (
<div key={i} className="pl-3 border-l-2 border-gray-600">
<div className="text-gray-200 font-mono text-xs">{s.event_def_hash}</div>
<div className="mt-1">
<span className="text-gray-500 text-xs">bindings:</span>{' '}
<span className="text-yellow-400 text-xs">{JSON.stringify(s.bindings)}</span>
</div>
<div className="mt-0.5">
<span className="text-gray-500 text-xs">expression:</span>{' '}
<code className="text-green-400 text-xs">{s.expression}</code>
</div>
</div>
))}
</div>
</div>
)}
{def.params && (
<div>
<span className="text-gray-400 font-medium">Params:</span>
<pre className="mt-1.5 p-3 bg-gray-950 rounded-lg text-xs overflow-x-auto border border-gray-800">
{JSON.stringify(def.params, null, 2)
.split('\n')
.map((line, i) => {
if (line.includes(':')) {
const [key, ...rest] = line.split(':')
return (
<div key={i}>
<span className="text-blue-400">{key}:</span>
<span className="text-green-300">{rest.join(':')}</span>
</div>
)
}
return (
<div key={i} className="text-gray-300">
{line}
</div>
)
})}
</pre>
</div>
)}
{def.value_schema && (
<div>
<span className="text-gray-400 font-medium">Value Schema:</span>
<pre className="mt-1.5 p-3 bg-gray-950 rounded-lg text-xs overflow-x-auto border border-gray-800 text-purple-300">
{JSON.stringify(def.value_schema, null, 2)}
</pre>
</div>
)}
{def.initial_value !== undefined && (
<div>
<span className="text-gray-400 font-medium">Initial Value:</span>
<pre className="mt-1.5 p-3 bg-gray-950 rounded-lg text-xs overflow-x-auto border border-gray-800 text-blue-300">
{JSON.stringify(def.initial_value, null, 2)}
</pre>
</div>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,315 @@
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-2xl font-bold mb-6">Query Projections</h2>
<div className="bg-gray-900/50 backdrop-blur rounded-lg p-6 space-y-6 border border-gray-800">
{/* 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-gray-800 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-gray-800 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-gray-800/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}
/>
) : (
<input
type={schema.type === 'number' ? 'number' : 'text'}
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 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-gray-800/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-gray-800">
{JSON.stringify(result, null, 2)}
</pre>
</div>
)}
</div>
</div>
)
}
// Headless UI Combobox for object ref params
function RefCombobox({
value,
onChange,
objects,
objectsByType,
objectType,
}: {
value: string
onChange: (v: string) => void
objects: Array<{ id: string; type: string }>
objectsByType: Record<string, string[]>
objectType?: string
}) {
const [query, setQuery] = useState('')
// Filter objects by object_type if specified
const relevantObjects = useMemo(() => {
if (!objectType) return objects
return objects.filter((o) => o.type === objectType)
}, [objects, objectType])
const relevantByType = useMemo(() => {
if (!objectType) return objectsByType
const filtered: Record<string, string[]> = {}
if (objectsByType[objectType]) {
filtered[objectType] = objectsByType[objectType]
}
return filtered
}, [objectsByType, objectType])
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-gray-800 border border-gray-700 rounded-lg focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 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-gray-800 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-gray-850 border-b border-gray-700">
{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>
)
}

View File

@ -0,0 +1,212 @@
import { useState, useEffect } from 'react'
import { getReactionLogs } from '../api'
import { formatRelativeTime } from '../utils'
import { Spinner, EmptyState, Pagination } from './Common'
function StatusBadge({ status }: { status: string }) {
const colors: Record<string, string> = {
success: 'bg-green-500/20 text-green-300 border-green-500/30',
failed: 'bg-red-500/20 text-red-300 border-red-500/30',
skipped: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
}
return (
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border ${colors[status] || colors.skipped}`}
>
{status}
</span>
)
}
function truncate(value: any, maxLen = 60): string {
if (value === null || value === undefined) return '—'
const str = typeof value === 'string' ? value : JSON.stringify(value)
return str.length > maxLen ? str.slice(0, maxLen) + '...' : str
}
export default function ReactionLogs() {
const [data, setData] = useState<any[]>([])
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [limit, setLimit] = useState(50)
const [offset, setOffset] = useState(0)
const [filterReactionId, setFilterReactionId] = useState('')
const [expandedRows, setExpandedRows] = useState<Set<number>>(new Set())
const load = () => {
setLoading(true)
const rid = filterReactionId.trim() ? parseInt(filterReactionId, 10) : undefined
getReactionLogs(limit, offset, rid)
.then((res) => {
setData(res.reaction_logs)
setTotal(res.total)
})
.catch((e) => setError(e.message))
.finally(() => setLoading(false))
}
useEffect(() => {
load()
}, [limit, offset])
const toggleExpand = (id: number) => {
setExpandedRows((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
if (loading) return <Spinner />
if (error) return <div className="text-red-500 text-center p-8">Error: {error}</div>
return (
<div className="max-w-7xl mx-auto">
<h2 className="text-2xl font-bold mb-6">Reaction Logs</h2>
{/* Filter */}
<div className="bg-gray-900/50 backdrop-blur rounded-lg border border-gray-800 p-4 mb-6">
<div className="flex items-end gap-3">
<div>
<label className="block text-xs text-gray-400 mb-1">Filter by Reaction ID</label>
<input
type="number"
value={filterReactionId}
onChange={(e) => setFilterReactionId(e.target.value)}
placeholder="Reaction ID"
className="px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 w-40"
/>
</div>
<button
onClick={() => {
setOffset(0)
load()
}}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-sm font-medium transition-colors"
>
Filter
</button>
{filterReactionId && (
<button
onClick={() => {
setFilterReactionId('')
setOffset(0)
setTimeout(load, 0)
}}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm font-medium transition-colors"
>
Clear
</button>
)}
</div>
</div>
<div className="bg-gray-900/50 backdrop-blur rounded-lg overflow-hidden border border-gray-800">
{data.length === 0 ? (
<EmptyState message="No reaction logs found" />
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-800/80 border-b border-gray-700">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
ID
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
Reaction
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
Trigger Event
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
Projection
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
Old Value
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
New Value
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
Action
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
Status
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
Output
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
Duration
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
Created
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{data.map((log, i) => (
<tr
key={log.id}
className={`transition-colors ${
i % 2 === 0 ? 'bg-gray-900/30' : 'bg-gray-850/30'
} hover:bg-gray-800/60`}
>
<td className="px-4 py-3 font-mono text-sm text-gray-300">{log.id}</td>
<td className="px-4 py-3 font-mono text-sm text-gray-300">{log.reaction_id}</td>
<td className="px-4 py-3 font-mono text-sm text-gray-300">{log.trigger_event_id}</td>
<td className="px-4 py-3 text-sm text-gray-400 max-w-[120px] truncate">
{truncate(log.projection_def, 30)}
</td>
<td className="px-4 py-3 text-sm text-gray-400 font-mono max-w-[120px] truncate">
{truncate(log.old_value)}
</td>
<td className="px-4 py-3 text-sm text-gray-400 font-mono max-w-[120px] truncate">
{truncate(log.new_value)}
</td>
<td className="px-4 py-3">
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-500/20 text-blue-300 border border-blue-500/30">
{log.action}
</span>
</td>
<td className="px-4 py-3">
<StatusBadge status={log.status} />
</td>
<td className="px-4 py-3 text-sm">
{log.handler_output ? (
<div>
<button
onClick={() => toggleExpand(log.id)}
className="text-blue-400 hover:text-blue-300 text-xs underline"
>
{expandedRows.has(log.id) ? 'Hide' : 'Show'}
</button>
{expandedRows.has(log.id) && (
<pre className="mt-2 p-2 bg-gray-800 rounded text-xs text-gray-300 max-w-xs overflow-auto whitespace-pre-wrap">
{typeof log.handler_output === 'string'
? log.handler_output
: JSON.stringify(log.handler_output, null, 2)}
</pre>
)}
</div>
) : (
<span className="text-gray-600"></span>
)}
</td>
<td className="px-4 py-3 text-sm text-gray-400 font-mono">
{log.duration_ms != null ? `${log.duration_ms}ms` : '—'}
</td>
<td className="px-4 py-3 text-sm text-gray-400">{formatRelativeTime(log.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Pagination total={total} limit={limit} offset={offset} onPageChange={setOffset} onLimitChange={setLimit} />
</div>
</div>
)
}

View File

@ -0,0 +1,129 @@
import { useState, useEffect } from 'react'
import { getReactions, deleteReaction } from '../api'
import { formatRelativeTime } from '../utils'
import { Spinner, EmptyState, HashBadge, Pagination } from './Common'
export default function Reactions() {
const [data, setData] = useState<any[]>([])
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [limit, setLimit] = useState(50)
const [offset, setOffset] = useState(0)
const load = () => {
setLoading(true)
getReactions(limit, offset)
.then((res) => {
setData(res.reactions)
setTotal(res.total)
})
.catch((e) => setError(e.message))
.finally(() => setLoading(false))
}
useEffect(() => {
load()
}, [limit, offset])
const handleDelete = async (id: string) => {
if (!confirm('Delete this reaction?')) return
try {
await deleteReaction(id)
load()
} catch (e: any) {
setError(e.message)
}
}
if (loading) return <Spinner />
if (error) return <div className="text-red-500 text-center p-8">Error: {error}</div>
return (
<div className="max-w-7xl mx-auto">
<h2 className="text-2xl font-bold mb-6">Reactions</h2>
<div className="bg-gray-900/50 backdrop-blur rounded-lg overflow-hidden border border-gray-800">
{data.length === 0 ? (
<EmptyState message="No reactions found" />
) : (
<table className="w-full">
<thead className="bg-gray-800/80 border-b border-gray-700">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">ID</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
Projection Def
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
Params Hash
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
Action
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
Target
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
Created
</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{data.map((reaction, i) => (
<tr
key={i}
className={`transition-colors ${
i % 2 === 0 ? 'bg-gray-900/30' : 'bg-gray-850/30'
} hover:bg-gray-800/60`}
>
<td className="px-4 py-3 font-mono text-sm text-gray-300">{reaction.id}</td>
<td className="px-4 py-3">
<HashBadge hash={reaction.projection_def_hash || 'unknown'} />
</td>
<td className="px-4 py-3">
<HashBadge hash={reaction.params_hash || 'unknown'} />
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
reaction.action === 'emit_event'
? 'bg-purple-500/20 text-purple-300 border border-purple-500/30'
: 'bg-blue-500/20 text-blue-300 border border-blue-500/30'
}`}
>
{reaction.action || 'webhook'}
</span>
</td>
<td className="px-4 py-3 text-sm truncate max-w-xs font-mono">
{reaction.action === 'emit_event' ? (
<div>
<span className="text-purple-400"> {reaction.emit_event_type}</span>
{reaction.emit_payload_template && (
<div className="text-gray-500 text-xs mt-1 truncate">
template: <code className="text-green-400">{reaction.emit_payload_template}</code>
</div>
)}
</div>
) : (
<span className="text-blue-400">{reaction.webhook_url}</span>
)}
</td>
<td className="px-4 py-3 text-sm text-gray-400">{formatRelativeTime(reaction.created_at)}</td>
<td className="px-4 py-3">
<button
onClick={() => handleDelete(reaction.id)}
className="px-3 py-1.5 bg-red-600 hover:bg-red-700 rounded text-sm font-medium transition-colors shadow-lg shadow-red-500/20 hover:shadow-red-500/40"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
<Pagination total={total} limit={limit} offset={offset} onPageChange={setOffset} onLimitChange={setLimit} />
</div>
</div>
)
}

View File

@ -0,0 +1,168 @@
import { useState, useEffect } from 'react'
import { getRequestLogs } from '../api'
import { formatRelativeTime } from '../utils'
import { Spinner, EmptyState, Pagination } from './Common'
function MethodBadge({ method }: { method: string }) {
const colors: Record<string, string> = {
GET: 'bg-blue-500/20 text-blue-300 border-blue-500/30',
POST: 'bg-green-500/20 text-green-300 border-green-500/30',
PUT: 'bg-yellow-500/20 text-yellow-300 border-yellow-500/30',
PATCH: 'bg-orange-500/20 text-orange-300 border-orange-500/30',
DELETE: 'bg-red-500/20 text-red-300 border-red-500/30',
}
return (
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-bold border ${colors[method] || 'bg-gray-500/20 text-gray-300 border-gray-500/30'}`}
>
{method}
</span>
)
}
function StatusCodeBadge({ code }: { code: number }) {
let color = 'bg-gray-500/20 text-gray-300 border-gray-500/30'
if (code >= 200 && code < 300) color = 'bg-green-500/20 text-green-300 border-green-500/30'
else if (code >= 400 && code < 500) color = 'bg-yellow-500/20 text-yellow-300 border-yellow-500/30'
else if (code >= 500) color = 'bg-red-500/20 text-red-300 border-red-500/30'
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-bold border ${color}`}>{code}</span>
)
}
export default function RequestLogs() {
const [data, setData] = useState<any[]>([])
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [limit, setLimit] = useState(50)
const [offset, setOffset] = useState(0)
const [filterApiKeyId, setFilterApiKeyId] = useState('')
const load = () => {
setLoading(true)
const kid = filterApiKeyId.trim() ? parseInt(filterApiKeyId, 10) : undefined
getRequestLogs(limit, offset, kid)
.then((res) => {
setData(res.request_logs)
setTotal(res.total)
})
.catch((e) => setError(e.message))
.finally(() => setLoading(false))
}
useEffect(() => {
load()
}, [limit, offset])
if (loading) return <Spinner />
if (error) return <div className="text-red-500 text-center p-8">Error: {error}</div>
return (
<div className="max-w-7xl mx-auto">
<h2 className="text-2xl font-bold mb-6">Request Logs</h2>
{/* Filter */}
<div className="bg-gray-900/50 backdrop-blur rounded-lg border border-gray-800 p-4 mb-6">
<div className="flex items-end gap-3">
<div>
<label className="block text-xs text-gray-400 mb-1">Filter by API Key ID</label>
<input
type="number"
value={filterApiKeyId}
onChange={(e) => setFilterApiKeyId(e.target.value)}
placeholder="API Key ID"
className="px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 w-40"
/>
</div>
<button
onClick={() => {
setOffset(0)
load()
}}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-sm font-medium transition-colors"
>
Filter
</button>
{filterApiKeyId && (
<button
onClick={() => {
setFilterApiKeyId('')
setOffset(0)
setTimeout(load, 0)
}}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm font-medium transition-colors"
>
Clear
</button>
)}
</div>
</div>
<div className="bg-gray-900/50 backdrop-blur rounded-lg overflow-hidden border border-gray-800">
{data.length === 0 ? (
<EmptyState message="No request logs found" />
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-800/80 border-b border-gray-700">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
ID
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
Method
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
Path
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
API Key
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
Status
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
Error
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
Duration
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
Created
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{data.map((log, i) => (
<tr
key={log.id}
className={`transition-colors ${
i % 2 === 0 ? 'bg-gray-900/30' : 'bg-gray-850/30'
} hover:bg-gray-800/60`}
>
<td className="px-4 py-3 font-mono text-sm text-gray-300">{log.id}</td>
<td className="px-4 py-3">
<MethodBadge method={log.method} />
</td>
<td className="px-4 py-3 text-sm text-gray-300 font-mono">{log.path}</td>
<td className="px-4 py-3 text-sm text-gray-400">{log.api_key_name || '—'}</td>
<td className="px-4 py-3">
<StatusCodeBadge code={log.status_code} />
</td>
<td className="px-4 py-3 text-sm text-red-400 max-w-[200px] truncate">{log.error || '—'}</td>
<td className="px-4 py-3 text-sm text-gray-400 font-mono">
{log.duration_ms != null ? `${log.duration_ms}ms` : '—'}
</td>
<td className="px-4 py-3 text-sm text-gray-400">{formatRelativeTime(log.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Pagination total={total} limit={limit} offset={offset} onPageChange={setOffset} onLimitChange={setLimit} />
</div>
</div>
)
}

View File

@ -0,0 +1,60 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
background: linear-gradient(to bottom, #050505, #0a0a0f);
color: #f3f4f6;
}
#root {
width: 100vw;
height: 100vh;
overflow: hidden;
}
/* Custom scrollbar for dark theme */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #1a1a1f;
}
::-webkit-scrollbar-thumb {
background: #374151;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #4b5563;
}
/* Smooth transitions for interactive elements */
button,
a,
input,
select {
transition: all 0.2s ease;
}
/* Custom animations */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeIn {
animation: fadeIn 0.3s ease-out;
}

View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@ -0,0 +1,18 @@
import { formatDistanceToNow } from 'date-fns'
export function formatRelativeTime(isoString: string): string {
try {
return formatDistanceToNow(new Date(isoString), { addSuffix: true })
} catch {
return isoString
}
}
export function highlightJSON(obj: any): string {
const json = JSON.stringify(obj, null, 2)
return json
.replace(/"([^"]+)":/g, '<span class="text-blue-400">"$1":</span>')
.replace(/: "([^"]+)"/g, ': <span class="text-green-300">"$1"</span>')
.replace(/: (\d+)/g, ': <span class="text-yellow-400">$1</span>')
.replace(/: (true|false|null)/g, ': <span class="text-purple-400">$1</span>')
}

1
packages/engine/ui/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
declare module '*.css'

View File

@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {},
},
plugins: [],
}

View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src", "src/vite-env.d.ts"]
}

View File

@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { viteSingleFile } from 'vite-plugin-singlefile'
export default defineConfig({
plugins: [react(), viteSingleFile()],
build: {
target: 'esnext',
assetsInlineLimit: 100000000,
chunkSizeWarningLimit: 100000000,
cssCodeSplit: false,
brotliSize: false,
rollupOptions: {
output: {
inlineDynamicImports: true,
},
},
},
})