feat(ui): add event emit form — schema-driven
This commit is contained in:
parent
d520df29d4
commit
4d63e1943f
File diff suppressed because one or more lines are too long
116
packages/engine/ui/POLISH_REPORT.md
Normal file
116
packages/engine/ui/POLISH_REPORT.md
Normal 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
|
||||||
12
packages/engine/ui/index.html
Normal file
12
packages/engine/ui/index.html
Normal 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
3043
packages/engine/ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
packages/engine/ui/package.json
Normal file
28
packages/engine/ui/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
packages/engine/ui/postcss.config.js
Normal file
6
packages/engine/ui/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
109
packages/engine/ui/src/App.tsx
Normal file
109
packages/engine/ui/src/App.tsx
Normal 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
|
||||||
103
packages/engine/ui/src/api.ts
Normal file
103
packages/engine/ui/src/api.ts
Normal 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)
|
||||||
|
}
|
||||||
238
packages/engine/ui/src/components/ApiKeys.tsx
Normal file
238
packages/engine/ui/src/components/ApiKeys.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
98
packages/engine/ui/src/components/Common.tsx
Normal file
98
packages/engine/ui/src/components/Common.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
174
packages/engine/ui/src/components/EmitEventModal.tsx
Normal file
174
packages/engine/ui/src/components/EmitEventModal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
136
packages/engine/ui/src/components/EventDefs.tsx
Normal file
136
packages/engine/ui/src/components/EventDefs.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
142
packages/engine/ui/src/components/Events.tsx
Normal file
142
packages/engine/ui/src/components/Events.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
38
packages/engine/ui/src/components/Health.tsx
Normal file
38
packages/engine/ui/src/components/Health.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
108
packages/engine/ui/src/components/Layout.tsx
Normal file
108
packages/engine/ui/src/components/Layout.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
52
packages/engine/ui/src/components/ObjectDefs.tsx
Normal file
52
packages/engine/ui/src/components/ObjectDefs.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
128
packages/engine/ui/src/components/Objects.tsx
Normal file
128
packages/engine/ui/src/components/Objects.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
130
packages/engine/ui/src/components/ProjectionDefs.tsx
Normal file
130
packages/engine/ui/src/components/ProjectionDefs.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
315
packages/engine/ui/src/components/Projections.tsx
Normal file
315
packages/engine/ui/src/components/Projections.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
212
packages/engine/ui/src/components/ReactionLogs.tsx
Normal file
212
packages/engine/ui/src/components/ReactionLogs.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
129
packages/engine/ui/src/components/Reactions.tsx
Normal file
129
packages/engine/ui/src/components/Reactions.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
168
packages/engine/ui/src/components/RequestLogs.tsx
Normal file
168
packages/engine/ui/src/components/RequestLogs.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
60
packages/engine/ui/src/index.css
Normal file
60
packages/engine/ui/src/index.css
Normal 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;
|
||||||
|
}
|
||||||
10
packages/engine/ui/src/main.tsx
Normal file
10
packages/engine/ui/src/main.tsx
Normal 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>,
|
||||||
|
)
|
||||||
18
packages/engine/ui/src/utils.ts
Normal file
18
packages/engine/ui/src/utils.ts
Normal 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
1
packages/engine/ui/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
declare module '*.css'
|
||||||
8
packages/engine/ui/tailwind.config.ts
Normal file
8
packages/engine/ui/tailwind.config.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
21
packages/engine/ui/tsconfig.json
Normal file
21
packages/engine/ui/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
19
packages/engine/ui/vite.config.ts
Normal file
19
packages/engine/ui/vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user